domma-cms 0.102.0 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/message-actions.js +56 -0
  3. package/plugins/_lib/admin/mail/reader-view.js +30 -1
  4. package/plugins/_lib/call/ice.js +58 -0
  5. package/plugins/_lib/call/rooms.js +232 -0
  6. package/plugins/_lib/call/routes.js +88 -0
  7. package/plugins/_lib/public/call/call.css +1 -0
  8. package/plugins/_lib/public/call/call.js +468 -0
  9. package/plugins/blog/lib/layouts.js +4 -2
  10. package/plugins/blog/plugin.json +1 -1
  11. package/plugins/blog/tests/layouts.test.js +6 -0
  12. package/plugins/booking/CLAUDE.md +123 -0
  13. package/plugins/booking/admin/css/index.css +192 -0
  14. package/plugins/booking/admin/lib/details.js +58 -0
  15. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  16. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  17. package/plugins/booking/admin/lib/kit.js +249 -0
  18. package/plugins/booking/admin/templates/booking.html +1 -0
  19. package/plugins/booking/admin/views/booking.js +177 -0
  20. package/plugins/booking/admin/views/bookings.js +127 -0
  21. package/plugins/booking/admin/views/diary.js +374 -0
  22. package/plugins/booking/admin/views/panels.js +516 -0
  23. package/plugins/booking/admin/views/people.js +281 -0
  24. package/plugins/booking/admin/views/services.js +222 -0
  25. package/plugins/booking/admin/views/settings.js +203 -0
  26. package/plugins/booking/collections/booking-bookables.json +17 -0
  27. package/plugins/booking/collections/booking-bookings.json +18 -0
  28. package/plugins/booking/collections/booking-services.json +17 -0
  29. package/plugins/booking/config.js +36 -0
  30. package/plugins/booking/docs/guide.md +69 -0
  31. package/plugins/booking/inject/body.html +1 -0
  32. package/plugins/booking/lib/call.js +58 -0
  33. package/plugins/booking/lib/extensions.js +150 -0
  34. package/plugins/booking/lib/mail.js +113 -0
  35. package/plugins/booking/lib/store.js +384 -0
  36. package/plugins/booking/plugin.js +760 -0
  37. package/plugins/booking/plugin.json +80 -0
  38. package/plugins/booking/plugin.public.js +157 -0
  39. package/plugins/booking/public/booking.css +159 -0
  40. package/plugins/booking/public/booking.js +808 -0
  41. package/plugins/booking/public/lib/clean.js +141 -0
  42. package/plugins/booking/public/lib/page-format.js +121 -0
  43. package/plugins/booking/public/lib/slots.js +247 -0
  44. package/plugins/booking/public/lib/time.js +131 -0
  45. package/plugins/booking/templates/booking.html +5 -0
  46. package/plugins/booking/tests/admin-diary.test.js +73 -0
  47. package/plugins/booking/tests/api.test.js +356 -0
  48. package/plugins/booking/tests/public.test.js +71 -0
  49. package/plugins/booking/tests/pure.test.js +189 -0
  50. package/plugins/free-tier.lock.json +48 -5
  51. package/server/routes/api/related.js +20 -5
  52. package/server/server.js +3 -3
  53. package/server/services/hooks.js +35 -8
  54. package/server/services/markdown.js +10 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.102.0",
3
+ "version": "0.103.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What other Tools add to a message's menu - Booking's "Book Sam...".
3
+ *
4
+ * Tools register actions on the `message` related kind (core 0.103,
5
+ * services/hooks.js); the reader asks for them once and adds them to both the
6
+ * full and the read-only menu. Like Contacts, an optional neighbour: an older
7
+ * core, no such Tool, or no permission all come back as an empty list.
8
+ *
9
+ * @module _lib/admin/mail/message-actions
10
+ */
11
+
12
+ const PROVIDERS_ENDPOINT = '/api/related/providers?kind=message';
13
+
14
+ /**
15
+ * The actions any Tool offers on a message, flattened.
16
+ *
17
+ * @param {Function} api - the view's request helper
18
+ * @returns {Promise<{label: string, icon: string, url: string}[]>}
19
+ */
20
+ export async function loadMessageActions(api) {
21
+ try {
22
+ const providers = await api(PROVIDERS_ENDPOINT);
23
+ if (!Array.isArray(providers)) return [];
24
+ return providers
25
+ .flatMap(p => (Array.isArray(p?.actions) ? p.actions : []))
26
+ .filter(a => typeof a?.label === 'string' && typeof a?.url === 'string' && a.url.startsWith('#/'));
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Who sent a message and what it is about, for the placeholders.
34
+ *
35
+ * @param {object} message
36
+ * @returns {{email: string, name: string, subject: string}}
37
+ */
38
+ export function messageFacts(message) {
39
+ const from = (message?.from ?? [])[0] ?? {};
40
+ const email = String(from.address ?? '').trim();
41
+ return {email, name: String(from.name ?? '').trim() || email, subject: String(message?.subject ?? '').trim()};
42
+ }
43
+
44
+ /**
45
+ * An action's label and hash for one message: `{name}` in the label, and
46
+ * `{email}`, `{name}`, `{subject}` (URL-encoded) in the url.
47
+ *
48
+ * @param {{label: string, url: string}} action
49
+ * @param {{email: string, name: string, subject: string}} facts
50
+ * @returns {{label: string, hash: string}}
51
+ */
52
+ export function fillMessageAction(action, facts) {
53
+ const label = action.label.replace(/\{name\}/g, facts.name || 'sender');
54
+ const hash = action.url.replace(/\{(email|name|subject)\}/g, (_, key) => encodeURIComponent(facts[key] ?? ''));
55
+ return {label, hash};
56
+ }
@@ -42,6 +42,7 @@ const {openCompose: openComposeWindow} = await import(versioned('compose-window.
42
42
  const {makeResizable} = await import(versioned('resizable.js'));
43
43
  const {whenClosed} = await import(versioned('when-closed.js'));
44
44
  const {addContact, isKnownAddress, loadContacts} = await import(versioned('contacts.js'));
45
+ const {fillMessageAction, loadMessageActions, messageFacts} = await import(versioned('message-actions.js'));
45
46
  const {isPlainKey, isTyping, resolveKey, SHORTCUTS} = await import(versioned('keyboard.js'));
46
47
  const {describeWhen, quickTimes} = await import(versioned('scheduling.js'));
47
48
 
@@ -335,6 +336,8 @@ return {
335
336
  })();
336
337
  /** Whether Contacts is installed at all - not the same as it being empty. */
337
338
  let contactsAvailable = false;
339
+ /** What other Tools add to a message's menu (Booking's "Book Sam..."). */
340
+ let messageActions = [];
338
341
  /**
339
342
  * The open message. Observable because selecting one only changes two
340
343
  * rows - the one leaving and the one arriving - and rebuilding the
@@ -2672,6 +2675,7 @@ return {
2672
2675
  },
2673
2676
  {type: 'divider'}
2674
2677
  ] : []),
2678
+ ...toolActionItems(message),
2675
2679
  {type: 'divider'},
2676
2680
  {
2677
2681
  label: 'Mark as spam',
@@ -2750,10 +2754,34 @@ return {
2750
2754
  ...(contactsAvailable ? [
2751
2755
  {type: 'divider'},
2752
2756
  {label: 'Add sender to contacts', icon: 'user-plus', disabled: () => senderKnown || !sender, action: () => addSender(message)}
2753
- ] : [])
2757
+ ] : []),
2758
+ ...(messageActions.length ? [{type: 'divider'}, ...toolActionItems(message)] : [])
2754
2759
  ];
2755
2760
  }
2756
2761
 
2762
+ /**
2763
+ * The entries other Tools add for one message, each followed by a
2764
+ * divider in the full menu's rhythm. Greyed when there is no sender to
2765
+ * act on, since every one of them is about the sender.
2766
+ *
2767
+ * @param {object} message
2768
+ * @returns {object[]}
2769
+ */
2770
+ function toolActionItems(message) {
2771
+ if (!messageActions.length) return [];
2772
+ const facts = messageFacts(message);
2773
+ const items = messageActions.map(action => {
2774
+ const {label, hash} = fillMessageAction(action, facts);
2775
+ return {
2776
+ label,
2777
+ icon: action.icon || 'arrow-right',
2778
+ disabled: () => !facts.email,
2779
+ action: () => { location.hash = hash.replace(/^#/, ''); }
2780
+ };
2781
+ });
2782
+ return features.write ? [...items, {type: 'divider'}] : items;
2783
+ }
2784
+
2757
2785
  /**
2758
2786
  * Put text on the clipboard, saying so - and saying so when it could not.
2759
2787
  *
@@ -4510,6 +4538,7 @@ return {
4510
4538
  // all; the menu and the compose window both read whatever has
4511
4539
  // arrived by the time they are opened.
4512
4540
  refreshContacts();
4541
+ loadMessageActions(api).then(actions => { messageActions = actions; });
4513
4542
 
4514
4543
  mailboxes = (await api(`${BASE}/accounts`)).accounts;
4515
4544
  } catch (err) {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Where a call's browsers look for a route to each other (ICE servers).
3
+ *
4
+ * `config/calls.json` (optional - absent means a public STUN server only):
5
+ *
6
+ * {
7
+ * "stun": ["stun:stun.l.google.com:19302"],
8
+ * "turn": {"urls": ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"],
9
+ * "secret": "<coturn static-auth-secret>", "ttlHours": 12}
10
+ * }
11
+ *
12
+ * TURN credentials are coturn's time-limited kind (use-auth-secret): the
13
+ * username is "<expiry>:<label>" and the password an HMAC of it, so no
14
+ * account is ever stored and a copied credential stops working on its own.
15
+ *
16
+ * @module _lib/call/ice
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import {createHmac} from 'node:crypto';
22
+
23
+ export const DEFAULT_STUN = ['stun:stun.l.google.com:19302'];
24
+
25
+ const list = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : [])
26
+ .map(x => String(x).trim()).filter(x => /^(stun|stuns|turn|turns):/.test(x));
27
+
28
+ /** The saved call settings, cleaned. Never throws. */
29
+ export function readCallConfig(root = process.cwd()) {
30
+ try {
31
+ const raw = JSON.parse(fs.readFileSync(path.join(root, 'config', 'calls.json'), 'utf8'));
32
+ const stun = list(raw?.stun);
33
+ const turnUrls = list(raw?.turn?.urls);
34
+ const secret = typeof raw?.turn?.secret === 'string' ? raw.turn.secret : '';
35
+ const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) || 12));
36
+ return {stun: stun.length ? stun : DEFAULT_STUN, turn: turnUrls.length && secret ? {urls: turnUrls, secret, ttlHours} : null};
37
+ } catch {
38
+ return {stun: DEFAULT_STUN, turn: null};
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The ICE servers for one participant (the RTCPeerConnection `iceServers`).
44
+ *
45
+ * @param {{stun: string[], turn: object|null}} config
46
+ * @param {string} label - who the credential is for (logged by coturn), e.g. the site's host
47
+ * @param {number} [now]
48
+ */
49
+ export function iceServers(config, label = 'call', now = Date.now()) {
50
+ const servers = [{urls: config.stun}];
51
+ if (config.turn) {
52
+ const expiry = Math.floor(now / 1000) + config.turn.ttlHours * 3600;
53
+ const username = `${expiry}:${String(label).replace(/[^\w.-]/g, '').slice(0, 60) || 'call'}`;
54
+ const credential = createHmac('sha1', config.turn.secret).update(username).digest('base64');
55
+ servers.push({urls: config.turn.urls, username, credential});
56
+ }
57
+ return servers;
58
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Call rooms - who is in a call, who is waiting to be let in, and the
3
+ * messages between them. Held in memory by the site process; nothing about a
4
+ * call is written anywhere, so a restart simply ends the calls in progress.
5
+ *
6
+ * The browsers talk to each other directly (WebRTC). This only carries the
7
+ * short messages that set that up (offers, answers, candidates) plus the
8
+ * call's own events, over plain HTTP: a participant posts, and long-polls for
9
+ * what is waiting for them. Every request after joining carries the
10
+ * participant's id and secret.
11
+ *
12
+ * @module _lib/call/rooms
13
+ */
14
+
15
+ import {randomBytes, timingSafeEqual} from 'node:crypto';
16
+
17
+ export const MAX_PEOPLE = 6;
18
+ const SILENT_MS = 35000; // no poll for this long: gone
19
+ const POLL_MS = 25000; // a poll waits this long for something to happen
20
+ const QUEUE_MAX = 500; // events kept per participant (a stuck browser cannot grow it forever)
21
+ const CHAT_MAX = 1000;
22
+ const EMPTY_KEEP_MS = 2 * 3600e3; // an empty room remembering who was turned away
23
+
24
+ export class CallError extends Error {
25
+ constructor(code, message) {
26
+ super(message);
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ const secretOk = (a, b) => {
32
+ const x = Buffer.from(String(a || ''));
33
+ const y = Buffer.from(String(b || ''));
34
+ return x.length === y.length && x.length > 0 && timingSafeEqual(x, y);
35
+ };
36
+
37
+ /**
38
+ * @param {{now?: () => number, pollMs?: number, silentMs?: number}} [opts]
39
+ */
40
+ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT_MS} = {}) {
41
+ /** key -> {key, title, max, recordingAllowed, recording, people: Map<pid, person>} */
42
+ const rooms = new Map();
43
+
44
+ const newId = (n) => randomBytes(n).toString('base64url');
45
+ const admitted = (room) => [...room.people.values()].filter(p => p.state === 'in');
46
+ const hosts = (room) => admitted(room).filter(p => p.host);
47
+ const pub = (p) => ({id: p.id, name: p.name, host: p.host});
48
+
49
+ function push(person, event) {
50
+ person.seq += 1;
51
+ person.queue.push({seq: person.seq, at: now(), ...event});
52
+ if (person.queue.length > QUEUE_MAX) person.queue.splice(0, person.queue.length - QUEUE_MAX);
53
+ const wake = person.waiters.splice(0);
54
+ for (const w of wake) w();
55
+ }
56
+ const tell = (people, event) => people.forEach(p => push(p, event));
57
+
58
+ function me(key, pid, secret) {
59
+ const room = rooms.get(key);
60
+ const person = room?.people.get(String(pid || ''));
61
+ if (!person || !secretOk(person.secret, secret)) throw new CallError('gone', 'You are not in this call. Join again.');
62
+ person.seen = now();
63
+ return {room, person};
64
+ }
65
+
66
+ function remove(room, person, why) {
67
+ if (!room.people.delete(person.id)) return;
68
+ for (const w of person.waiters.splice(0)) w();
69
+ if (person.state === 'in') tell(admitted(room), {type: 'left', id: person.id, name: person.name, why});
70
+ else tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
71
+ // The last host leaving stops a recording; nobody else can.
72
+ if (room.recording && !hosts(room).length) {
73
+ room.recording = false;
74
+ tell(admitted(room), {type: 'recording', on: false});
75
+ }
76
+ // An empty room goes - or, while it remembers who was turned away, two hours after it emptied (sweep).
77
+ if (!room.people.size) {
78
+ if (!room.away.size) rooms.delete(room.key);
79
+ else room.emptySince = now();
80
+ }
81
+ }
82
+
83
+ const waitingList = (room) => [...room.people.values()].filter(p => p.state === 'waiting').map(p => ({id: p.id, name: p.name}));
84
+
85
+ function admit(room, person) {
86
+ if (admitted(room).length >= room.max) throw new CallError('full', `This call is full (${room.max} people).`);
87
+ person.state = 'in';
88
+ tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person)});
89
+ push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording});
90
+ tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
91
+ }
92
+
93
+ return {
94
+ /**
95
+ * Join (or knock on) a room. A host is let straight in; a guest waits
96
+ * for a host unless the room lets everyone straight in.
97
+ *
98
+ * @param {string} key - the room, as the Tool names it ("booking:<id>")
99
+ * @param {{name: string, host?: boolean, title?: string, max?: number, recordingAllowed?: boolean, autoAdmit?: boolean}} who
100
+ */
101
+ join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = ''}) {
102
+ let room = rooms.get(key);
103
+ if (!room) {
104
+ room = {key, title, max: Math.max(2, Math.min(MAX_PEOPLE, max | 0 || MAX_PEOPLE)), recordingAllowed, recording: false, people: new Map(), away: new Set()};
105
+ rooms.set(key, room);
106
+ }
107
+ room.emptySince = 0;
108
+ // Turned away once: the same link cannot knock again for as long as the call lasts.
109
+ if (!host && identity && room.away.has(identity)) throw new CallError('away', 'The host did not let you in to this call.');
110
+ // The same link joining again (a reload, a second tab) replaces its old place at once, rather than
111
+ // leaving a frozen copy of them in the call until the sweep notices.
112
+ if (identity) for (const p of [...room.people.values()]) if (p.identity === identity) remove(room, p, 'rejoined');
113
+ // remove() deletes a room it leaves empty - this one is about to have someone in it again.
114
+ rooms.set(key, room);
115
+ if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
116
+ const person = {id: newId(9), secret: newId(24), identity: String(identity || ''), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
117
+ host: Boolean(host), state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
118
+ room.people.set(person.id, person);
119
+ if (host || autoAdmit) {
120
+ // admit() also tells every host - this one included - who is already waiting.
121
+ admit(room, person);
122
+ } else {
123
+ push(person, {type: 'knocking'});
124
+ tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
125
+ }
126
+ return {id: person.id, secret: person.secret, name: person.name, host: person.host, state: person.state, title: room.title,
127
+ max: room.max, recordingAllowed: room.recordingAllowed};
128
+ },
129
+
130
+ /** Events after `since`, waiting up to pollMs for the first one. */
131
+ async poll(key, pid, secret, since = 0) {
132
+ const {person} = me(key, pid, secret);
133
+ const after = () => person.queue.filter(e => e.seq > since);
134
+ if (!after().length) {
135
+ await new Promise(resolve => {
136
+ const t = setTimeout(done, pollMs);
137
+ // Taken off the list however it ends: a waiter left behind would make sweep() think this
138
+ // person is still polling, and someone whose tab died would stay in the call for ever.
139
+ function done() {
140
+ clearTimeout(t);
141
+ const i = person.waiters.indexOf(done);
142
+ if (i >= 0) person.waiters.splice(i, 1);
143
+ resolve();
144
+ }
145
+ person.waiters.push(done);
146
+ });
147
+ }
148
+ person.seen = now();
149
+ // Acknowledged events are dropped: the browser only ever asks for what came after.
150
+ person.queue = person.queue.filter(e => e.seq > since);
151
+ return {events: after(), state: person.state};
152
+ },
153
+
154
+ /** One browser's message for another (offer, answer, candidate). */
155
+ signal(key, pid, secret, to, data) {
156
+ const {room, person} = me(key, pid, secret);
157
+ if (person.state !== 'in') throw new CallError('waiting', 'Wait to be let in first.');
158
+ const other = room.people.get(String(to || ''));
159
+ if (!other || other.state !== 'in') return false;
160
+ push(other, {type: 'signal', from: person.id, data});
161
+ return true;
162
+ },
163
+
164
+ chat(key, pid, secret, text) {
165
+ const {room, person} = me(key, pid, secret);
166
+ if (person.state !== 'in') throw new CallError('waiting', 'Wait to be let in first.');
167
+ const clean = String(text || '').trim().slice(0, CHAT_MAX);
168
+ if (!clean) return false;
169
+ tell(admitted(room), {type: 'chat', from: person.id, name: person.name, text: clean});
170
+ return true;
171
+ },
172
+
173
+ /** A host lets someone in (yes) or turns them away (no). */
174
+ answer(key, pid, secret, who, yes) {
175
+ const {room, person} = me(key, pid, secret);
176
+ if (!person.host || person.state !== 'in') throw new CallError('not-host', 'Only the host can do that.');
177
+ const other = room.people.get(String(who || ''));
178
+ if (!other || other.state !== 'waiting') return false;
179
+ if (yes) admit(room, other);
180
+ else {
181
+ push(other, {type: 'turned-away'});
182
+ other.state = 'away';
183
+ if (other.identity) room.away.add(other.identity);
184
+ // They are told, then dropped when they stop polling.
185
+ tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
186
+ }
187
+ return true;
188
+ },
189
+
190
+ /** A host starts or stops recording; everyone is told. */
191
+ recording(key, pid, secret, on) {
192
+ const {room, person} = me(key, pid, secret);
193
+ if (!person.host || person.state !== 'in') throw new CallError('not-host', 'Only the host can record.');
194
+ if (!room.recordingAllowed && on) throw new CallError('no-recording', 'Recording is switched off for this call.');
195
+ room.recording = Boolean(on);
196
+ tell(admitted(room), {type: 'recording', on: room.recording, by: person.name});
197
+ return room.recording;
198
+ },
199
+
200
+ leave(key, pid, secret) {
201
+ const {room, person} = me(key, pid, secret);
202
+ remove(room, person, 'left');
203
+ return true;
204
+ },
205
+
206
+ /** Drop everyone who has stopped polling. Call every few seconds. */
207
+ sweep() {
208
+ const cut = now() - silentMs;
209
+ for (const room of [...rooms.values()]) {
210
+ for (const p of [...room.people.values()]) if (p.seen < cut && !p.waiters.length) remove(room, p, 'lost');
211
+ if (!room.people.size && room.emptySince && now() - room.emptySince > EMPTY_KEEP_MS) rooms.delete(room.key);
212
+ }
213
+ },
214
+
215
+ /** For the admin: how many are in a room and waiting (0 when there is no call). */
216
+ count(key) {
217
+ const room = rooms.get(key);
218
+ return room ? {in: admitted(room).length, waiting: waitingList(room).length} : {in: 0, waiting: 0};
219
+ },
220
+
221
+ /** End a call for everyone (a booking cancelled, an event deleted). */
222
+ close(key) {
223
+ const room = rooms.get(key);
224
+ if (!room) return;
225
+ tell([...room.people.values()], {type: 'ended'});
226
+ for (const p of room.people.values()) for (const w of p.waiters.splice(0)) w();
227
+ rooms.delete(key);
228
+ },
229
+
230
+ size: () => rooms.size
231
+ };
232
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The call's HTTP routes, mounted by a Tool (Booking Pro, Calendar) under its
3
+ * own API prefix with its OWN access rule. `_lib` on its own gives a site
4
+ * nothing: only a Tool that mounts this, and says who may join which room,
5
+ * has calls.
6
+ *
7
+ * POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state, iceServers, ...}
8
+ * GET {prefix}/:room/poll?since=N -> {events, state} (long-poll, 25 s)
9
+ * POST {prefix}/:room/signal {to, data}
10
+ * POST {prefix}/:room/chat {text}
11
+ * POST {prefix}/:room/answer {who, yes} (host: let in / turn away)
12
+ * POST {prefix}/:room/recording {on} (host)
13
+ * POST {prefix}/:room/leave
14
+ *
15
+ * After joining, every request carries `X-Call-Id` and `X-Call-Secret`
16
+ * (headers, so the secret never lands in an access log).
17
+ *
18
+ * @module _lib/call/routes
19
+ */
20
+
21
+ import {CallError, createRooms} from './rooms.js';
22
+ import {iceServers, readCallConfig} from './ice.js';
23
+
24
+ const STATUS = {gone: 410, full: 409, waiting: 409, away: 403, 'not-host': 403, 'no-recording': 403};
25
+ const BUSY = {config: {rateLimit: {max: 1200, timeWindow: '1 minute'}}};
26
+
27
+ /**
28
+ * @param {import('fastify').FastifyInstance} fastify
29
+ * @param {object} opts
30
+ * @param {string} opts.prefix - e.g. '/call' (under the plugin's own prefix)
31
+ * @param {string} opts.ns - the Tool's room namespace, e.g. 'booking'
32
+ * @param {(request: object, room: string, body: object) => Promise<null|{title: string, host: boolean, name?: string, identity?: string,
33
+ * max?: number, recordingAllowed?: boolean, autoAdmit?: boolean, opensAt?: string, closesAt?: string}>} opts.resolve
34
+ * who the caller is in this room, or null when they may not join. Throw nothing: null answers 404.
35
+ * @param {ReturnType<typeof createRooms>} [opts.rooms] - for tests
36
+ * @param {() => {stun: string[], turn: object|null}} [opts.config]
37
+ * @returns {ReturnType<typeof createRooms>} the rooms, so the Tool can close() one or count() it
38
+ */
39
+ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig()}) {
40
+ if (!ns) throw new Error('mountCallRoutes needs a namespace');
41
+ const keyOf = (room) => `${ns}:${String(room || '').slice(0, 120)}`;
42
+ const who = (request) => [request.headers['x-call-id'], request.headers['x-call-secret']];
43
+ const fail = (reply, err) => {
44
+ if (err instanceof CallError) return reply.code(STATUS[err.code] || 400).send({error: err.message, code: err.code});
45
+ throw err;
46
+ };
47
+
48
+ const sweeper = setInterval(() => rooms.sweep(), 5000);
49
+ sweeper.unref?.();
50
+ fastify.addHook('onClose', async () => clearInterval(sweeper));
51
+
52
+ fastify.post(`${prefix}/:room/join`, {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
53
+ const body = request.body || {};
54
+ const r = await resolve(request, String(request.params.room), body);
55
+ if (!r) return reply.code(404).send({error: 'This call link is not valid.'});
56
+ const t = Date.now();
57
+ if (r.opensAt && t < Date.parse(r.opensAt)) return reply.code(425).send({error: 'This call has not opened yet.', code: 'early', opensAt: r.opensAt});
58
+ if (r.closesAt && t > Date.parse(r.closesAt)) return reply.code(410).send({error: 'This call has ended.', code: 'closed'});
59
+ try {
60
+ const joined = rooms.join(keyOf(request.params.room), {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
61
+ recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || ''});
62
+ return reply.send({...joined, iceServers: iceServers(config(), request.hostname), closesAt: r.closesAt || null});
63
+ } catch (err) { return fail(reply, err); }
64
+ });
65
+
66
+ fastify.get(`${prefix}/:room/poll`, BUSY, async (request, reply) => {
67
+ const [id, secret] = who(request);
68
+ try {
69
+ const out = await rooms.poll(keyOf(request.params.room), id, secret, Number(request.query?.since) || 0);
70
+ reply.header('Cache-Control', 'no-store');
71
+ return reply.send(out);
72
+ } catch (err) { return fail(reply, err); }
73
+ });
74
+
75
+ const action = (name, run) => fastify.post(`${prefix}/:room/${name}`, BUSY, async (request, reply) => {
76
+ const [id, secret] = who(request);
77
+ try {
78
+ return reply.send({ok: true, result: run(keyOf(request.params.room), id, secret, request.body || {})});
79
+ } catch (err) { return fail(reply, err); }
80
+ });
81
+ action('signal', (k, id, s, b) => rooms.signal(k, id, s, b.to, b.data));
82
+ action('chat', (k, id, s, b) => rooms.chat(k, id, s, b.text));
83
+ action('answer', (k, id, s, b) => rooms.answer(k, id, s, b.who, b.yes === true));
84
+ action('recording', (k, id, s, b) => rooms.recording(k, id, s, b.on === true));
85
+ action('leave', (k, id, s) => rooms.leave(k, id, s));
86
+
87
+ return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room))};
88
+ }
@@ -0,0 +1 @@
1
+ .dmc{--dmc-gap: .75rem;display:flex;flex-direction:column;gap:1rem;min-height:70vh;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.35rem;margin:0;flex:1 1 auto}.dmc-rec{color:#fff;background:var(--dm-danger, #c62828);border-radius:999px;padding:.2rem .7rem;font-size:.85rem;font-weight:600}.dmc-count{color:var(--dm-text-muted, #666);font-size:.9rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.5rem;align-items:start}.dmc-panel--msg{display:block;text-align:center;padding:3rem 1rem}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:grid;place-items:center;color:#ddd;margin:0}.dmc-check h2{margin-top:0}.dmc-label{display:grid;gap:.35rem;margin:1rem 0;font-weight:600}.dmc-input{font:inherit;padding:.55rem .7rem;border:1px solid var(--dm-border, #ccc);border-radius:.5rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.5rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:disabled{opacity:.6;cursor:default}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff}.dmc-btn--leave{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--rec{border-color:var(--dm-danger, #c62828)}.dmc-btn--small{padding:.3rem .65rem;font-size:.9rem}.dmc-call{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr auto;gap:var(--dmc-gap)}.dmc-main{display:grid;gap:var(--dmc-gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));align-content:start}.dmc-main[data-people="1"]{grid-template-columns:minmax(0,1fr)}.dmc-main[data-people="2"]{grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))}.dmc-grid,.dmc-remote{display:contents}.dmc-tile{position:relative;margin:0;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-tile figcaption{position:absolute;left:.5rem;bottom:.5rem;padding:.15rem .5rem;border-radius:.35rem;background:#0009;color:#fff;font-size:.85rem}.dmc-side{width:300px;display:flex;flex-direction:column;gap:1rem;grid-row:1 / span 1;grid-column:2}.dmc-side h3{margin:0 0 .5rem;font-size:1rem}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.dmc-waiting li{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dmc-waiting li span{flex:1 1 100%;font-weight:600}.dmc-chat{display:flex;flex-direction:column;min-height:0;flex:1}.dmc-messages{list-style:none;margin:0 0 .5rem;padding:0;overflow-y:auto;max-height:50vh;display:grid;gap:.4rem}.dmc-messages li.is-mine strong{color:var(--dm-primary, #1f6feb)}.dmc-send{display:flex;gap:.4rem}.dmc-send .dmc-input{flex:1}.dmc-bar{grid-column:1 / -1;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}@media(max-width:760px){.dmc-panel,.dmc-call{grid-template-columns:1fr}.dmc-side{width:auto;grid-column:1;grid-row:auto}.dmc-bar{position:sticky;bottom:0;background:var(--dm-background, #fff);padding:.5rem 0}.dmc-bar .dmc-btn{flex:1 1 30%}}