domma-cms 0.102.1 → 0.104.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.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +249 -0
- package/plugins/_lib/call/routes.js +89 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +477 -0
- package/plugins/booking/CLAUDE.md +137 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +207 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +70 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +72 -0
- package/plugins/booking/lib/extensions.js +169 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +390 -0
- package/plugins/booking/plugin.js +806 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +445 -0
- package/plugins/booking/tests/public.test.js +72 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +44 -1
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- package/server/services/hooks.js +35 -8
package/package.json
CHANGED
|
@@ -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,249 @@
|
|
|
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
|
+
/**
|
|
222
|
+
* Take one person out of a call - everyone joined with `identity` (someone who left the conversation
|
|
223
|
+
* the call belongs to). They are told the call ended for them; the others see them leave.
|
|
224
|
+
*/
|
|
225
|
+
kick(key, identity) {
|
|
226
|
+
const room = rooms.get(key);
|
|
227
|
+
if (!room || !identity) return 0;
|
|
228
|
+
let n = 0;
|
|
229
|
+
for (const p of [...room.people.values()]) {
|
|
230
|
+
if (p.identity !== identity) continue;
|
|
231
|
+
push(p, {type: 'ended'});
|
|
232
|
+
remove(room, p, 'removed');
|
|
233
|
+
n += 1;
|
|
234
|
+
}
|
|
235
|
+
return n;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/** End a call for everyone (a booking cancelled, an event deleted). */
|
|
239
|
+
close(key) {
|
|
240
|
+
const room = rooms.get(key);
|
|
241
|
+
if (!room) return;
|
|
242
|
+
tell([...room.people.values()], {type: 'ended'});
|
|
243
|
+
for (const p of room.people.values()) for (const w of p.waiters.splice(0)) w();
|
|
244
|
+
rooms.delete(key);
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
size: () => rooms.size
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
kickFromRoom: (room, identity) => rooms.kick(keyOf(room), identity)};
|
|
89
|
+
}
|
|
@@ -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;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;position:relative}.dmc-i{display:inline-flex}.dmc-btn svg,.dmc-rec svg,.dmc-count svg{width:18px;height:18px;flex:none}.dmc-rec svg{width:10px;height:10px;fill:currentColor}.dmc-count{display:inline-flex;align-items:center;gap:.35rem}.dmc-tool{flex-direction:column;gap:.2rem;min-width:5.5rem;padding:.5rem .75rem;font-size:.85rem}.dmc-btn.is-on{border-color:var(--dm-primary, #1f6feb);color:var(--dm-primary, #1f6feb)}.dmc-btn--rec.is-on{border-color:var(--dm-danger, #c62828);color:var(--dm-danger, #c62828)}.dmc-btn--rec.is-on svg{fill:currentColor}.dmc-badge{position:absolute;top:.2rem;right:.35rem;min-width:1.1rem;height:1.1rem;padding:0 .25rem;border-radius:999px;background:var(--dm-danger, #c62828);color:#fff;font-size:.7rem;line-height:1.1rem;text-align:center}.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 auto;min-width:3rem}.dmc-lbl{display:none}.dmc-tool{padding:.6rem}}
|