domma-cms 0.104.1 → 0.105.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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Instant calls - /api/calls (services/instantCalls.js)
3
+ *
4
+ * Every route here answers 404 until a Pro Tool with calls (Booking Pro,
5
+ * Contacts Pro, Calendar) has switched instant calls on - a free site has none -
6
+ * and again the moment the last one is disabled.
7
+ *
8
+ * Signed in, `calls` permission:
9
+ * GET /api/calls/status {mailReady, contacts} (calls.create)
10
+ * GET /api/calls/contacts?q= contacts the caller may READ (own + shared), to pick (calls.create)
11
+ * GET /api/calls/contact/:id one contact the caller may read, to fill in (calls.create)
12
+ * POST /api/calls {name, email?, contactId?, notify} -> 201 {id, hostUrl, joinUrl, emailed, emailLimited?, expiresAt}
13
+ * GET /api/calls?contact=<id> the caller's recent calls with that contact (calls.read)
14
+ *
15
+ * Contacts are read through `readableContactIds` (services/hooks.js): the
16
+ * caller's own, plus whatever a sharing plugin (Contacts Pro) lets them read -
17
+ * its rule, not a copy of it. Reading contacts also needs Contacts on and the
18
+ * `contacts.read` permission.
19
+ *
20
+ * Limits on POST: 20 a minute per address (the route), CREATES_PER_MINUTE per
21
+ * user, and EMAILS_PER_ADDRESS_HOUR emails to any one recipient - past that the
22
+ * call is still made, with `emailed: false, emailLimited: true`.
23
+ *
24
+ * The call itself (no sign-in - the link is the credential):
25
+ * /api/calls/room/:id/{join,poll,signal,chat,answer,recording,leave} (plugins/_lib/call/routes.js)
26
+ * join takes {t} (the guest) or {h} (the host); guests knock and the host lets them in.
27
+ */
28
+ import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
29
+ import {getEntry, listEntries} from '../../services/collections.js';
30
+ import {readableContactIds} from '../../services/hooks.js';
31
+ import {getPermissionsFor} from '../../services/roles.js';
32
+ import {getEffectiveRoles} from '../../services/userRoles.js';
33
+ import {isToolEnabled} from '../../services/tools.js';
34
+ import {mountCallRoutes} from '../../../plugins/_lib/call/routes.js';
35
+ import {getBaseUrl} from '../public.js';
36
+ import {
37
+ EMAIL_RE, callMailReady, callPaths, callsOfContact, createCall, getCall, instantCallsEnabled, isOpen,
38
+ mayEmailAddress, mayStartCall, sendCallEmail, whoIsCalling
39
+ } from '../../services/instantCalls.js';
40
+
41
+ const CONTACTS_SLUG = 'contacts-contacts';
42
+
43
+ /**
44
+ * @param {import('fastify').FastifyInstance} fastify
45
+ * @param {{authenticate?: Function, requirePermission?: Function, isEnabled?: () => boolean, rooms?: object}} [opts] - injectable for tests
46
+ */
47
+ export async function callsRoutes(fastify, opts = {}) {
48
+ const authenticate = opts.authenticate || defaultAuthenticate;
49
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
50
+ const isEnabled = opts.isEnabled || instantCallsEnabled;
51
+ const can = (action) => ({preHandler: [authenticate, requirePermission('calls', action)]});
52
+ const uidOf = (request) => String(request.user?.id ?? request.user?.sub ?? '');
53
+ const hostNameOf = (request) => String(request.user?.name || request.user?.email || '').trim();
54
+ const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
55
+ const viewerOf = (request) => ({id: uidOf(request), roles: getEffectiveRoles(request.user || {})});
56
+ /** Contacts on, and a role that may read them. */
57
+ const mayReadContacts = (viewer) => isToolEnabled('contacts')
58
+ && viewer.roles.some(r => getPermissionsFor('contacts', 'read').includes(r));
59
+ const shape = (e) => ({id: e.id, name: String(e.data?.name || ''), email: String(e.data?.email || '')});
60
+
61
+ // No Pro Tool with calls: nothing here exists.
62
+ fastify.addHook('onRequest', async (request, reply) => {
63
+ if (!isEnabled()) return reply.code(404).send({error: 'Not found'});
64
+ });
65
+
66
+ /** One contact the caller may read, or null (Contacts off, not readable, gone). */
67
+ async function readableContact(id, viewer) {
68
+ if (!id || !viewer.id || !mayReadContacts(viewer)) return null;
69
+ const e = await getEntry(CONTACTS_SLUG, String(id)).catch(() => null);
70
+ if (!e || !(await readableContactIds([e], viewer)).has(e.id)) return null;
71
+ return {...shape(e), mine: e.data?.userId === viewer.id};
72
+ }
73
+
74
+ fastify.get('/status', can('create'), async (request, reply) => {
75
+ return reply.send({mailReady: callMailReady(), contacts: mayReadContacts(viewerOf(request))});
76
+ });
77
+
78
+ fastify.get('/contacts', can('create'), async (request, reply) => {
79
+ const viewer = viewerOf(request);
80
+ if (!mayReadContacts(viewer)) return fail(reply, 403, 'Contacts are not available to you.');
81
+ const q = String(request.query?.q || '').trim().toLowerCase().slice(0, 100);
82
+ if (q.length < 2) return reply.send([]);
83
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000}).catch(() => ({entries: []}));
84
+ const hits = (entries || []).filter(e => [e.data?.name, e.data?.email, e.data?.phone]
85
+ .some(v => String(v || '').toLowerCase().includes(q)));
86
+ const ok = await readableContactIds(hits, viewer);
87
+ const out = hits.filter(e => ok.has(e.id)).map(e => ({...shape(e), mine: e.data?.userId === viewer.id}))
88
+ .sort((a, b) => Number(b.mine) - Number(a.mine) || a.name.localeCompare(b.name)).slice(0, 20);
89
+ return reply.send(out);
90
+ });
91
+
92
+ fastify.get('/contact/:id', can('create'), async (request, reply) => {
93
+ const c = await readableContact(String(request.params.id), viewerOf(request));
94
+ if (!c) return fail(reply, 404, 'That contact could not be found.');
95
+ return reply.send(c);
96
+ });
97
+
98
+ fastify.post('/', {...can('create'), config: {rateLimit: {max: 20, timeWindow: '1 minute'}}}, async (request, reply) => {
99
+ const body = request.body || {};
100
+ const name = String(body.name || '').trim().slice(0, 120);
101
+ const email = String(body.email || '').trim().toLowerCase().slice(0, 200);
102
+ if (email && !EMAIL_RE.test(email)) return fail(reply, 400, 'That email address does not look right.', {errors: {email: 'That email address does not look right.'}});
103
+ if (body.notify === true && !email) return fail(reply, 400, 'An email address, to send them the link.', {errors: {email: 'An email address, to send them the link.'}});
104
+ const viewer = viewerOf(request);
105
+ if (!mayStartCall(viewer.id)) return fail(reply, 429, 'That is a lot of calls in a minute - wait a moment and try again.');
106
+ const contact = body.contactId ? await readableContact(String(body.contactId), viewer) : null;
107
+ const rec = await createCall({name, email, contactId: contact?.id || '', hostId: viewer.id, hostName: hostNameOf(request)});
108
+ const paths = callPaths(rec);
109
+ const joinUrl = `${getBaseUrl(request)}${paths.join}`;
110
+ let emailed = false;
111
+ let emailLimited = false;
112
+ if (body.notify === true && email) {
113
+ if (mayEmailAddress(email)) emailed = await sendCallEmail({to: email, name, hostName: rec.hostName, joinUrl}, {log: request.log});
114
+ else emailLimited = true;
115
+ }
116
+ return reply.code(201).send({id: rec.id, hostUrl: paths.host, joinUrl, emailed: emailed === true,
117
+ ...(emailLimited ? {emailLimited: true} : {}), expiresAt: rec.expiresAt});
118
+ });
119
+
120
+ fastify.get('/', can('read'), async (request, reply) => {
121
+ const contact = String(request.query?.contact || '');
122
+ if (!contact) return fail(reply, 400, 'contact is required');
123
+ const now = Date.now();
124
+ const list = (await callsOfContact(contact, uidOf(request))).slice(0, 20)
125
+ .map(c => ({id: c.id, name: c.name, createdAt: c.createdAt, expiresAt: c.expiresAt, open: isOpen(c, now)}));
126
+ return reply.send(list);
127
+ });
128
+
129
+ // The call engine: a room per call, guests knock, the host lets them in, open createdAt..expiresAt.
130
+ mountCallRoutes(fastify, {
131
+ prefix: '/room', ns: 'instant', ...(opts.rooms ? {rooms: opts.rooms} : {}),
132
+ resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h})
133
+ });
134
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * An instant call's page (services/instantCalls.js), in the site's own shell:
3
+ *
4
+ * /_call/<id>?t=<guest token> the person called
5
+ * /_call/<id>?h=<host key> the host
6
+ *
7
+ * Under `/_call`, like `/_preview`. Answered from an onRequest hook, and ONLY
8
+ * while instant calls are on: with no Pro Tool with calls the request goes on
9
+ * to the normal page handling, so the path is never claimed on a free site.
10
+ * The link is checked here only to choose between the call and a short note;
11
+ * the call's join checks it again, and the time.
12
+ *
13
+ * The credential is in the URL, so: no referrer, no caching, no indexing, and
14
+ * the page is rendered WITHOUT the site's and plugins' injected head/body
15
+ * snippets or the analytics beacon (renderBlogPage `injection: false`) - no
16
+ * third-party script sees `?h=`. call.js then takes the credentials out of the
17
+ * address bar (keeping them in sessionStorage for this tab), and a reload of
18
+ * the bare address comes back to them through the small script on the "not
19
+ * valid" page (`recoverScript`).
20
+ *
21
+ * @module routes/call-public
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import {fileURLToPath} from 'node:url';
26
+ import {renderBlogPage} from '../services/renderer.js';
27
+ import {getBaseUrl} from './public.js';
28
+ import {CALL_PATH, ROOM_API, getCall, instantCallsEnabled, whoIsCalling} from '../services/instantCalls.js';
29
+
30
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
31
+ const TEMPLATE = path.join(HERE, '..', 'templates', 'call.html');
32
+ const ASSET_V = (() => {
33
+ try { return JSON.parse(fs.readFileSync(path.join(HERE, '..', '..', 'package.json'), 'utf8')).version || '1'; } catch { return '1'; }
34
+ })();
35
+
36
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
37
+
38
+ /**
39
+ * The page's body and status: the call (200), "not valid" (404) or "ended" (410).
40
+ *
41
+ * @param {object|null} rec
42
+ * @param {object|null} who - whoIsCalling(rec, cred)
43
+ * @param {{t?: string, h?: string}} cred
44
+ */
45
+ /**
46
+ * On the "not valid" page of a bare `/_call/<id>` (no credentials in the
47
+ * address): this tab's call page stripped them (call.js) - go back to it with them.
48
+ * A wrong credential always has a query string, so this cannot loop.
49
+ */
50
+ export const recoverScript = '<script>(function(){try{if(location.search)return;'
51
+ + 'var s=sessionStorage.getItem("dm-call:"+location.pathname);'
52
+ + 'if(s&&s.charAt(0)==="?")location.replace(location.pathname+s);}catch(e){}})();</script>';
53
+
54
+ export function callPage(rec, who, cred, now = Date.now()) {
55
+ if (!rec || !who) {
56
+ const bare = !cred?.t && !cred?.h;
57
+ return {status: 404, title: 'Call', body: '<h1>This call link is not valid</h1><p>Ask the person who sent it for a new one.</p>'
58
+ + (bare ? recoverScript : '')};
59
+ }
60
+ if (!(Date.parse(rec.expiresAt) > now)) {
61
+ return {status: 410, title: 'Call', body: '<h1>This call has ended</h1><p>Ask the person who sent the link for a new one.</p>'};
62
+ }
63
+ const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && !rec.name, recordingAllowed: false, closesAt: rec.expiresAt};
64
+ return {
65
+ status: 200,
66
+ title: who.title,
67
+ body: `<link rel="stylesheet" href="/plugins/_lib/public/call/call.css?v=${esc(ASSET_V)}">`
68
+ + `<div data-dm-call='${esc(JSON.stringify(cfg))}'><noscript><p>The call needs JavaScript switched on.</p></noscript></div>`
69
+ + `<script type="module" src="/plugins/_lib/public/call/call.js?v=${esc(ASSET_V)}"></script>`
70
+ };
71
+ }
72
+
73
+ /**
74
+ * @param {import('fastify').FastifyInstance} fastify
75
+ * @param {{isEnabled?: () => boolean}} [opts]
76
+ */
77
+ export async function callPublicRoutes(fastify, opts = {}) {
78
+ const isEnabled = opts.isEnabled || instantCallsEnabled;
79
+ const PREFIX = `${CALL_PATH}/`;
80
+
81
+ fastify.addHook('onRequest', async (request, reply) => {
82
+ if (request.method !== 'GET' && request.method !== 'HEAD') return;
83
+ const [pathname, search = ''] = String(request.url || '').split('?');
84
+ if (!pathname.startsWith(PREFIX) || !isEnabled()) return;
85
+ let id = '';
86
+ try { id = decodeURIComponent(pathname.slice(PREFIX.length)).slice(0, 64); } catch { return; }
87
+ if (!id || id.includes('/')) return;
88
+ reply.header('referrer-policy', 'no-referrer');
89
+ reply.header('cache-control', 'no-store, private');
90
+ reply.header('x-robots-tag', 'noindex, nofollow');
91
+ const q = new URLSearchParams(search);
92
+ const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
93
+ const rec = await getCall(id);
94
+ const page = callPage(rec, whoIsCalling(rec, cred), cred);
95
+ const html = await renderBlogPage(TEMPLATE, {body: page.body}, {
96
+ title: page.title, urlPath: `${CALL_PATH}/${encodeURIComponent(id)}`, noindex: true, baseUrl: getBaseUrl(request),
97
+ injection: false
98
+ });
99
+ return reply.code(page.status).type('text/html; charset=utf-8').send(html);
100
+ });
101
+ }
102
+
103
+ // The onRequest hook must see every request, not only routes registered here (it registers none).
104
+ callPublicRoutes[Symbol.for('skip-override')] = true;
package/server/server.js CHANGED
@@ -579,6 +579,12 @@ await app.register(toolGate('seo', seoRoutes), {prefix: '/api/seo'});
579
579
  await app.register(toolsRoutes, {prefix: '/api'});
580
580
  // What plugins show beside a contact or a note (Calendar's Events) - 0.79.
581
581
  await app.register(relatedRoutes, {prefix: '/api/related'});
582
+ // Instant calls ("Call Sam now…") - core code, switched on only by a Pro Tool with calls
583
+ // (hooks.enableInstantCalls, services/instantCalls.js); 404 on every route until then.
584
+ {
585
+ const {callsRoutes} = await import('./routes/api/calls.js');
586
+ await app.register(callsRoutes, {prefix: '/api/calls'});
587
+ }
582
588
 
583
589
  // ---------------------------------------------------------------------------
584
590
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
@@ -702,6 +708,9 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
702
708
  // out-prioritises the catch-all; must be registered before it.
703
709
  const { docsPublicRoutes } = await import('./routes/docs-public.js');
704
710
  await app.register(docsPublicRoutes);
711
+ // An instant call's page, /_call/<id> (a reserved prefix, like /_preview) - before the catch-all.
712
+ const { callPublicRoutes } = await import('./routes/call-public.js');
713
+ await app.register(callPublicRoutes);
705
714
 
706
715
  const { publicRoutes } = await import('./routes/public.js');
707
716
  await app.register(publicRoutes);
@@ -299,9 +299,12 @@ const _relatedProviders = new Map(); // kind -> Map(id -> provider)
299
299
  * `actions` are shown in the record's menu, `{id}` in a url replaced by the
300
300
  * record's id: `[{label, icon?, url, permission?}]`. `permission`
301
301
  * (`{resource, action}`) shows the action only to roles that hold it.
302
+ * `when` (optional, `() => boolean`) is asked on every request: a provider that
303
+ * answers false is left out for now (its actions and its list) - core's instant
304
+ * calls stand aside that way while an older Booking Pro registers its own.
302
305
  *
303
306
  * @param {'contact'|'note'|'message'} kind
304
- * @param {{id: string, label: string, icon?: string, list: Function, actions?: object[], owner?: string}} provider
307
+ * @param {{id: string, label: string, icon?: string, list: Function, actions?: object[], owner?: string, when?: Function}} provider
305
308
  * @returns {boolean}
306
309
  */
307
310
  export function registerRelatedItems(kind, provider = {}) {
@@ -326,11 +329,17 @@ export function registerRelatedItems(kind, provider = {}) {
326
329
  icon: typeof provider.icon === 'string' ? provider.icon : 'link',
327
330
  owner: typeof provider.owner === 'string' ? provider.owner : 'core',
328
331
  actions,
329
- list: typeof list === 'function' ? list : () => []
332
+ list: typeof list === 'function' ? list : () => [],
333
+ ...(typeof provider.when === 'function' ? {when: provider.when} : {})
330
334
  });
331
335
  return true;
332
336
  }
333
337
 
338
+ /** Is a provider with this id registered for this kind (whatever its `when` says)? */
339
+ export function hasRelatedProvider(kind, id) {
340
+ return Boolean(_relatedProviders.get(kind)?.has(id));
341
+ }
342
+
334
343
  /** @param {string} kind @param {string} id */
335
344
  export function unregisterRelatedItems(kind, id) {
336
345
  _relatedProviders.get(kind)?.delete(id);
@@ -338,7 +347,61 @@ export function unregisterRelatedItems(kind, id) {
338
347
 
339
348
  /** The providers for one kind of record. */
340
349
  export function getRelatedProviders(kind) {
341
- return [...(_relatedProviders.get(kind)?.values() ?? [])];
350
+ return [...(_relatedProviders.get(kind)?.values() ?? [])].filter((p) => {
351
+ try { return !p.when || p.when() !== false; } catch { return true; }
352
+ });
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // 7b. Contact read access - who may read a contact that is not their own
357
+ // ---------------------------------------------------------------------------
358
+ //
359
+ // Core Contacts are per user: only the owner reads one. A plugin that shares
360
+ // contacts (Contacts Pro) registers the rule it enforces itself, so core code
361
+ // that takes a contact id (instant calls) accepts exactly the contacts the
362
+ // caller may read, without reimplementing the plugin's rule.
363
+
364
+ const _contactAccess = new Map(); // owner -> fn(entries, viewer) => Promise<Iterable<id>>
365
+
366
+ /**
367
+ * @param {string} owner - the plugin
368
+ * @param {(entries: object[], viewer: {id: string, roles: string[]}) => Promise<Iterable<string>>} fn
369
+ * of the collection entries given (contacts-contacts, not the viewer's own), the ids the viewer may READ
370
+ */
371
+ export function registerContactAccess(owner, fn) {
372
+ if (typeof owner !== 'string' || !owner || typeof fn !== 'function') return false;
373
+ _contactAccess.set(owner, fn);
374
+ return true;
375
+ }
376
+
377
+ /** @param {string} owner */
378
+ export function unregisterContactAccess(owner) {
379
+ _contactAccess.delete(owner);
380
+ }
381
+
382
+ /**
383
+ * Which of these contact entries may the viewer read? Their own, plus whatever
384
+ * a registered rule allows. A rule that throws allows nothing.
385
+ *
386
+ * @param {object[]} entries - contacts-contacts entries
387
+ * @param {{id: string, roles?: string[]}} viewer
388
+ * @returns {Promise<Set<string>>} entry ids
389
+ */
390
+ export async function readableContactIds(entries, viewer) {
391
+ const out = new Set();
392
+ if (!viewer?.id) return out;
393
+ const others = [];
394
+ for (const e of entries || []) {
395
+ if (e?.data?.userId === viewer.id) out.add(e.id);
396
+ else if (e) others.push(e);
397
+ }
398
+ if (!others.length) return out;
399
+ for (const fn of _contactAccess.values()) {
400
+ try {
401
+ for (const id of (await fn(others, {id: viewer.id, roles: viewer.roles || []})) || []) out.add(String(id));
402
+ } catch { /* a failing rule grants nothing */ }
403
+ }
404
+ return out;
342
405
  }
343
406
 
344
407
  // ---------------------------------------------------------------------------