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.
- package/CLAUDE.md +9 -0
- package/admin/js/app.js +1 -1
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/templates/call-new.html +88 -0
- package/admin/js/templates/docs/usage-tools.html +15 -0
- package/admin/js/views/call-new.js +1 -0
- package/admin/js/views/index.js +1 -1
- package/package.json +1 -1
- package/plugins/_lib/public/call/call.js +7 -0
- package/plugins/booking/CLAUDE.md +4 -0
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/plugin.public.js +6 -1
- package/plugins/free-tier.lock.json +5 -5
- package/server/routes/api/calls.js +134 -0
- package/server/routes/call-public.js +104 -0
- package/server/server.js +9 -0
- package/server/services/hooks.js +66 -3
- package/server/services/instantCalls.js +362 -0
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/plugins.js +21 -2
- package/server/services/renderer.js +10 -5
- package/server/templates/call.html +3 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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);
|
package/server/services/hooks.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|