domma-cms 0.106.0 → 0.107.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,238 @@
1
+ /**
2
+ * "Add people" to a call - what core does for every Tool with calls (instant
3
+ * calls, Booking, Calendar, Messages). The pure parts (the request's shape, the
4
+ * invite pass, matching, limits) are `plugins/_lib/call/invite.js`; the routes
5
+ * are the engine's (`plugins/_lib/call/routes.js`). This file finds people and
6
+ * sends what the host chose:
7
+ *
8
+ * searchInvitees({hostUserId, q, excludeUserIds, contacts, team})
9
+ * the host's readable contacts (their own plus what a sharing Tool lets
10
+ * them read - `readableContactIds`) and the team, matched to `q`
11
+ * resolveInvitees(people, {hostUserId, contacts, team, emails})
12
+ * the request's {kind, id} / {kind: 'email'} list as real people - a
13
+ * contact the host may read, an active team member - else an error row
14
+ * deliverInvites(targets, {linkFor, hostName, hostUserId, title, via, key})
15
+ * per person: the Join email (via.email, any address), a notification
16
+ * (via.notify, team members: bell + desktop, `core:call-invites`,
17
+ * dedupeKey per call + person) and a direct message from the host
18
+ * (via.message, team members, when a Tool registered a messenger -
19
+ * Messages; hooks.sendDirectMessage). Never throws.
20
+ *
21
+ * "The team": active users whose most senior role is at the base `user` level
22
+ * or above - roles a plugin makes for people from outside (Job Board's
23
+ * candidates) are not the team.
24
+ *
25
+ * @module services/callInvites
26
+ */
27
+ import {getConfig} from '../config.js';
28
+ import {getEntry, listEntries} from './collections.js';
29
+ import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
30
+ import {hasDirectMessenger, readableContactIds, sendDirectMessage} from './hooks.js';
31
+ import {notify, registerNotificationSource} from './notify.js';
32
+ import {getPermissionsFor, getRoleLevel} from './roles.js';
33
+ import {isToolEnabled} from './tools.js';
34
+ import {getUserById, listUsers} from './users.js';
35
+ import {getEffectiveLevel, getEffectiveRoles} from './userRoles.js';
36
+ import {cleanText, createLimiter, inviteMessage, inviteTitle, matchPeople} from '../../plugins/_lib/call/invite.js';
37
+
38
+ export const SOURCE = 'core:call-invites';
39
+ const CONTACTS_SLUG = 'contacts-contacts';
40
+ /** Join emails one address may be sent in an hour, whoever sends them. */
41
+ export const EMAILS_PER_ADDRESS_HOUR = 3;
42
+ /** Join emails the whole site may send in an hour (every call, every Tool) - a leaked host link cannot spam. */
43
+ export const EMAILS_PER_SITE_HOUR = 30;
44
+
45
+ try {
46
+ registerNotificationSource({id: SOURCE, owner: 'core', label: 'Call invitations', severity: 'info', defaultOn: true,
47
+ description: 'Someone in a video call adds a team member to it ("Add people") - told to that person, with the Join link.'});
48
+ } catch { /* never stops the calls */ }
49
+
50
+ const isActive = (u) => Boolean(u) && u.disabled !== true && u.active !== false && u.isActive !== false;
51
+
52
+ /** Is this user one of the team (active, at the base user level or more senior)? */
53
+ export function isTeamMember(u) {
54
+ if (!isActive(u)) return false;
55
+ const base = getRoleLevel('user');
56
+ try { return getEffectiveLevel(u) <= (Number.isFinite(base) ? base : 2); } catch { return false; }
57
+ }
58
+
59
+ /** The team, by name. */
60
+ export async function teamMembers() {
61
+ return (await listUsers().catch(() => [])).filter(isTeamMember);
62
+ }
63
+
64
+ /** May this user read contacts at all (Contacts on, a role with contacts.read)? */
65
+ function mayReadContacts(user) {
66
+ try {
67
+ const roles = getEffectiveRoles(user || {});
68
+ return isToolEnabled('contacts') && roles.some(r => getPermissionsFor('contacts', 'read').includes(r));
69
+ } catch { return false; }
70
+ }
71
+
72
+ /** The contacts this user may read, as {id, name, email} (with an email only). */
73
+ export async function contactsOf(hostUserId) {
74
+ const user = hostUserId ? await getUserById(String(hostUserId)).catch(() => null) : null;
75
+ if (!user || !mayReadContacts(user)) return [];
76
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000}).catch(() => ({entries: []}));
77
+ const withEmail = (entries || []).filter(e => String(e?.data?.email || '').includes('@'));
78
+ const ok = await readableContactIds(withEmail, {id: user.id, roles: getEffectiveRoles(user)});
79
+ return withEmail.filter(e => ok.has(e.id)).map(e => ({id: e.id, name: String(e.data?.name || '').trim(), email: String(e.data.email).trim()}));
80
+ }
81
+
82
+ /**
83
+ * Search: the team (not `excludeUserIds`, not the host) and the host's contacts.
84
+ *
85
+ * @param {{hostUserId?: string, q?: string, excludeUserIds?: string[], contacts?: boolean, team?: boolean, teamFilter?: (u: object) => boolean}} opts
86
+ */
87
+ export async function searchInvitees({hostUserId = '', q = '', excludeUserIds = [], contacts = true, team = true, teamFilter = null} = {}) {
88
+ const users = team ? (await teamMembers()).filter(u => !teamFilter || teamFilter(u)) : [];
89
+ const known = contacts && String(q || '').trim() ? await contactsOf(hostUserId) : [];
90
+ return matchPeople({q, users, contacts: known, excludeUserIds: [...excludeUserIds, hostUserId].filter(Boolean),
91
+ canMessage: Boolean(hostUserId) && hasDirectMessenger()});
92
+ }
93
+
94
+ /**
95
+ * The request's people as real people: `{kind, id?, name, email, userId?}`, or `{name, error}` for someone who
96
+ * cannot be invited (a contact the host may not read, not a team member, an email not allowed here).
97
+ *
98
+ * @param {object[]} people - cleanInviteRequest()'s list
99
+ * @param {{hostUserId?: string, contacts?: boolean, team?: boolean, emails?: boolean, teamFilter?: (u: object) => boolean}} opts
100
+ */
101
+ export async function resolveInvitees(people = [], {hostUserId = '', contacts = true, team = true, emails = true, teamFilter = null} = {}) {
102
+ const out = [];
103
+ let readable = null;
104
+ for (const p of people) {
105
+ if (p.kind === 'email') {
106
+ out.push(emails ? {kind: 'email', name: p.name, email: p.email} : {name: p.name, error: 'Only team members can be added to this call.'});
107
+ } else if (p.kind === 'user') {
108
+ const u = team ? await getUserById(p.id).catch(() => null) : null;
109
+ if (!u || !isTeamMember(u) || (teamFilter && !teamFilter(u)) || u.id === hostUserId) out.push({name: 'Someone', error: 'That person cannot be added.'});
110
+ else out.push({kind: 'user', id: u.id, name: String(u.name || '').trim() || 'Team member', email: String(u.email || '').trim()});
111
+ } else if (p.kind === 'contact') {
112
+ readable ||= contacts ? await contactsOf(hostUserId) : [];
113
+ const c = readable.find(x => x.id === p.id);
114
+ out.push(c ? {kind: 'contact', id: c.id, name: c.name || c.email.split('@')[0], email: c.email.toLowerCase()} : {name: 'Someone', error: 'That contact could not be found.'});
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The Join email
122
+ // ---------------------------------------------------------------------------
123
+
124
+ let mailer = null;
125
+ let transport = null;
126
+ let transportFor = '';
127
+ const emailsTo = createLimiter({max: EMAILS_PER_ADDRESS_HOUR, windowMs: 3600000});
128
+ const emailsFromSite = createLimiter({max: EMAILS_PER_SITE_HOUR, windowMs: 3600000});
129
+
130
+ /** Tests only: `fn(msg)` receives every message instead of SMTP. null restores. */
131
+ export function setInviteMailer(fn) { mailer = fn; }
132
+ /** Tests only. */
133
+ export function resetInviteLimits() { emailsTo.reset(); emailsFromSite.reset(); }
134
+
135
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
136
+
137
+ /** Can mail really leave this site? */
138
+ export const inviteMailReady = () => Boolean(mailer) || isSmtpConfigured(site().smtp || {});
139
+
140
+ /** The message, escaped. */
141
+ export function composeInviteEmail({name = '', hostName = '', title = '', link = ''}) {
142
+ const host = cleanText(hostName, 80) || 'Someone';
143
+ name = cleanText(name, 120);
144
+ title = cleanText(title, 120);
145
+ const subject = `${host} is inviting you to a video call now`;
146
+ const lines = [`Hello${name ? ` ${name}` : ''}, ${host} is in a video call${title ? ` (${title})` : ''} and would like you to join now.`,
147
+ 'Use the button below. You will wait a moment until you are let in. You need a camera and microphone, or you can join with just one.'];
148
+ const html = [...lines.map(l => `<p>${escapeHtml(l)}</p>`),
149
+ `<p><a href="${escapeHtml(link)}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;border-radius:6px;text-decoration:none">Join the call</a></p>`,
150
+ `<p style="color:#666;font-size:13px">Or paste this into your browser: ${escapeHtml(link)}</p>`].join('\n');
151
+ return {subject, html, text: [...lines, `Join the call: ${link}`].join('\n\n')};
152
+ }
153
+
154
+ /** Send it. Never throws; true only when it left. */
155
+ export async function sendInviteEmail({to, name, hostName, title, link}, {log} = {}) {
156
+ try {
157
+ if (!to || !link) return false;
158
+ const msg = {to, ...composeInviteEmail({name, hostName, title, link})};
159
+ if (mailer) { await mailer(msg); return true; }
160
+ const s = site();
161
+ const smtp = s.smtp || {};
162
+ if (!isSmtpConfigured(smtp)) return false;
163
+ const k = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
164
+ if (!transport || transportFor !== k) { transport = await createTransport(smtp); transportFor = k; }
165
+ await sendEmail(transport, {from: smtp.fromAddress || 'noreply@example.com', fromName: smtp.fromName || s.title || 'Calls', ...msg});
166
+ return true;
167
+ } catch (err) {
168
+ try { (log?.error || console.error).call(log || console, `[calls] Invitation to ${to} not sent: ${err?.message || err}`); } catch { /* never */ }
169
+ return false;
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Sending
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Tell each person. `linkFor(target)` is their Join link (absolute). Resolves to one result per target:
179
+ * `{name, emailed, notified, messaged, error?}` - `error` says why nothing reached them.
180
+ *
181
+ * @param {object[]} targets - resolveInvitees()'s list
182
+ * @param {{linkFor: (t: object) => string|Promise<string>, hostName?: string, hostUserId?: string, title?: string,
183
+ * via?: {email?: boolean, notify?: boolean, message?: boolean}, key?: string, expiresAt?: string, log?: object,
184
+ * deps?: {notify?: Function, sendDirectMessage?: Function}}} opts - `key`: the call, for the notification's dedupeKey
185
+ */
186
+ export async function deliverInvites(targets = [], {linkFor, hostName = '', hostUserId = '', title = '', via = {}, key = '', expiresAt, log, deps = {}} = {}) {
187
+ const raise = deps.notify || notify;
188
+ const dm = deps.sendDirectMessage || sendDirectMessage;
189
+ const results = [];
190
+ for (const t of targets) {
191
+ if (t.error) { results.push({name: t.name, emailed: false, notified: false, messaged: false, error: t.error}); continue; }
192
+ const r = {name: t.name, emailed: false, notified: false, messaged: false};
193
+ let link = '';
194
+ try { link = await linkFor(t); } catch { link = ''; }
195
+ if (!link) { results.push({...r, error: 'No link could be made for them.'}); continue; }
196
+ const why = [];
197
+ if (via.email && t.email) {
198
+ if (!inviteMailReady()) why.push('email is not set up on this site');
199
+ else if (!emailsTo.take(t.email)) why.push('they have been emailed several times this hour');
200
+ else if (!emailsFromSite.take('site')) why.push('this site has sent a lot of call invitations this hour');
201
+ else if (!(r.emailed = await sendInviteEmail({to: t.email, name: t.name, hostName, title, link}, {log}))) why.push('the email could not be sent');
202
+ }
203
+ if (t.kind === 'user') {
204
+ if (via.notify) {
205
+ try {
206
+ r.notified = Boolean(await raise({title: inviteTitle(hostName), body: title ? `Video call: ${title}` : 'A video call, now.', severity: 'info', link,
207
+ source: SOURCE, dedupeKey: `call-invite:${key}:${t.id}`.slice(0, 200), audience: {userIds: [t.id]},
208
+ expiresAt: expiresAt || new Date(Date.now() + 3 * 3600e3).toISOString()}));
209
+ } catch { r.notified = false; }
210
+ if (!r.notified) why.push('the notification was not raised');
211
+ }
212
+ if (via.message) {
213
+ if (!hostUserId) why.push('a message needs a host who is a team member');
214
+ else if (!hasDirectMessenger() && !deps.sendDirectMessage) why.push('Messages is not on');
215
+ else {
216
+ let out = null;
217
+ try { out = await dm({fromUserId: hostUserId, toUserId: t.id, text: inviteMessage(hostName, title, link)}); } catch { out = null; }
218
+ r.messaged = out === true || out?.sent === true;
219
+ if (!r.messaged) why.push(`no message: ${out?.reason || 'it could not be sent'}`);
220
+ }
221
+ }
222
+ }
223
+ if (!r.emailed && !r.notified && !r.messaged) r.error = why.length ? `Not reached: ${why.join('; ')}.` : 'Nothing was sent - copy the link and pass it on.';
224
+ results.push(r);
225
+ }
226
+ return results;
227
+ }
228
+
229
+ /**
230
+ * The origin Join links are built on: the site's configured `baseUrl` whenever there is one (links in emails
231
+ * and notices must never follow a Host header), else the request's own origin.
232
+ */
233
+ export function inviteOrigin(request) {
234
+ const base = String(site().baseUrl || '').trim();
235
+ if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
236
+ if (!request) return '';
237
+ return `${request.protocol}://${request.host || request.headers?.host || request.hostname}`;
238
+ }
@@ -404,6 +404,61 @@ export async function readableContactIds(entries, viewer) {
404
404
  return out;
405
405
  }
406
406
 
407
+ // ---------------------------------------------------------------------------
408
+ // 7c. Direct messages - one team member writing to another (Messages)
409
+ // ---------------------------------------------------------------------------
410
+ //
411
+ // Core has no messaging of its own. A Tool that has it (Messages) registers a
412
+ // messenger, and core code - a call's "Add people" (services/callInvites.js) -
413
+ // sends a direct message from one user to another through it, without knowing
414
+ // the Tool. No messenger (Messages off or absent): sendDirectMessage answers
415
+ // false and the caller carries on without it.
416
+
417
+ const _messengers = new Map(); // owner -> fn({fromUserId, toUserId, text}) => Promise<boolean | {sent, reason}>
418
+
419
+ /**
420
+ * @param {string} owner - the plugin
421
+ * @param {(msg: {fromUserId: string, toUserId: string, text: string}) => Promise<boolean|{sent: boolean, reason?: string}>} fn
422
+ * post `text` from `fromUserId` to `toUserId` in their direct conversation (made when there is none), by the
423
+ * Tool's OWN rules (who may message whom): true / {sent: true} when posted, else false or {sent: false, reason}
424
+ */
425
+ export function registerDirectMessenger(owner, fn) {
426
+ if (typeof owner !== 'string' || !owner || typeof fn !== 'function') return false;
427
+ _messengers.set(owner, fn);
428
+ return true;
429
+ }
430
+
431
+ /** @param {string} owner */
432
+ export function unregisterDirectMessenger(owner) {
433
+ _messengers.delete(owner);
434
+ }
435
+
436
+ /** Is there a Tool that can send a direct message? */
437
+ export const hasDirectMessenger = () => _messengers.size > 0;
438
+
439
+ /**
440
+ * Send a direct message through the registered messenger. Never throws.
441
+ *
442
+ * @param {{fromUserId: string, toUserId: string, text: string}} msg
443
+ * @returns {Promise<{sent: boolean, reason?: string}>} sent when a messenger posted it; else why not
444
+ */
445
+ export async function sendDirectMessage({fromUserId, toUserId, text} = {}) {
446
+ const from = String(fromUserId || '');
447
+ const to = String(toUserId || '');
448
+ const body = String(text || '').trim().slice(0, 4000);
449
+ if (!from || !to || from === to || !body) return {sent: false, reason: 'nobody to send it to'};
450
+ if (!_messengers.size) return {sent: false, reason: 'Messages is not on'};
451
+ let reason = 'the message could not be sent';
452
+ for (const fn of _messengers.values()) {
453
+ try {
454
+ const out = await fn({fromUserId: from, toUserId: to, text: body});
455
+ if (out === true || out?.sent === true) return {sent: true};
456
+ if (out?.reason) reason = String(out.reason).slice(0, 200);
457
+ } catch { /* a failing messenger sends nothing */ }
458
+ }
459
+ return {sent: false, reason};
460
+ }
461
+
407
462
  // ---------------------------------------------------------------------------
408
463
  // 8. Sitemap sources (0.89) - URLs a plugin serves, in /sitemap.xml
409
464
  // ---------------------------------------------------------------------------
@@ -19,7 +19,10 @@
19
19
  * actions and list stand aside (checked per request), so nobody sees two.
20
20
  *
21
21
  * A record: {id, name, email, contactId, hostId, hostName, token, hostKey,
22
- * createdAt, expiresAt, chat?}. `chat` is the call's chat, saved against the
22
+ * createdAt, expiresAt, chat?, inviteKey?}. `inviteKey` (made on the first "Add
23
+ * people", `inviteKeyOf`) signs the passes of people added to the call
24
+ * (`?g=<pass>`, _lib/call/invite.js) - never the guest's token, which carries
25
+ * the guest's name and place. `chat` is the call's chat, saved against the
23
26
  * call (the engine's items - typed messages plus joined/left lines - the last
24
27
  * CHAT_MAX); the engine seeds a reopened room from it, and the host (or an
25
28
  * admin) reads it afterwards through GET /api/calls/:id/chat. Kept in memory and in one JSON file with the sign-in
@@ -43,6 +46,7 @@ import {hasRelatedProvider, registerRelatedItems, unregisterRelatedItems} from '
43
46
  import {getPermissionsFor} from './roles.js';
44
47
  import {getEffectiveRoles} from './userRoles.js';
45
48
  import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
49
+ import {checkInvitePass, invitePass, newInviteKey} from '../../plugins/_lib/call/invite.js';
46
50
 
47
51
  export const INSTANT_HOURS = 3;
48
52
  export const KEEP_DAYS = 30;
@@ -244,6 +248,25 @@ export async function getCall(id) {
244
248
  return (await load()).get(id) || null;
245
249
  }
246
250
 
251
+ /** The call's invite key, made (and stored) the first time someone is added to it. Null for no such call. */
252
+ export async function inviteKeyOf(id) {
253
+ if (typeof id !== 'string' || !ID_RE.test(id)) return null;
254
+ const all = await load();
255
+ const rec = all.get(id);
256
+ if (!rec) return null;
257
+ if (!rec.inviteKey) {
258
+ rec.inviteKey = newInviteKey();
259
+ await save(all);
260
+ }
261
+ return rec.inviteKey;
262
+ }
263
+
264
+ /** A new Join link (relative) for someone added to the call: `?g=<pass>`. */
265
+ export async function invitePath(id) {
266
+ const k = await inviteKeyOf(id);
267
+ return k ? `${CALL_PATH}/${encodeURIComponent(id)}?g=${encodeURIComponent(invitePass(k, id))}` : '';
268
+ }
269
+
247
270
  /** The chat item as it is kept on a record; null for junk. System lines other than joined/left are not kept. */
248
271
  function chatItem(raw) {
249
272
  if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || !raw.id) return null;
@@ -299,8 +322,10 @@ const same = (a, b) => {
299
322
 
300
323
  /**
301
324
  * Who the caller is in an instant call, or null (the engine answers 404).
302
- * `cred` is what the link carried: {t} for the guest, {h} for the host. The
303
- * engine answers 410 once `closesAt` has passed.
325
+ * `cred` is what the link carried: {t} for the guest, {h} for the host, {g}
326
+ * for someone added to the call (a pass under `inviteKey`: they give their own
327
+ * name and knock; each pass is its own identity). The engine answers 410 once
328
+ * `closesAt` has passed.
304
329
  */
305
330
  export function whoIsCalling(rec, cred = {}) {
306
331
  if (!rec) return null;
@@ -314,6 +339,8 @@ export function whoIsCalling(rec, cred = {}) {
314
339
  if (cred.t && same(cred.t, rec.token)) {
315
340
  return {...base, host: false, name: rec.name || '', identity: `${rec.id}:guest`};
316
341
  }
342
+ const nonce = cred.g && rec.inviteKey ? checkInvitePass(rec.inviteKey, rec.id, cred.g) : null;
343
+ if (nonce) return {...base, host: false, name: '', identity: `${rec.id}:inv:${nonce}`, invited: true};
317
344
  return null;
318
345
  }
319
346
 
@@ -28,6 +28,8 @@ import {
28
28
  registerComponent,
29
29
  registerContactAccess,
30
30
  unregisterContactAccess,
31
+ registerDirectMessenger,
32
+ unregisterDirectMessenger,
31
33
  registerSidebarItem,
32
34
  registerTransform
33
35
  } from './hooks.js';
@@ -559,7 +561,9 @@ export async function registerPlugins(fastify) {
559
561
  // loads under a licence may.
560
562
  enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
561
563
  // Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
562
- registerContactAccess: (fn) => registerContactAccess(manifest.name, fn)},
564
+ registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
565
+ // Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
566
+ registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn)},
563
567
  settings,
564
568
  config: {}
565
569
  });
@@ -1120,6 +1124,7 @@ export async function runLifecycleHook(name, hook, fastify) {
1120
1124
  if (hook === 'onDisable') {
1121
1125
  disableInstantCalls(name);
1122
1126
  unregisterContactAccess(name);
1127
+ unregisterDirectMessenger(name);
1123
1128
  }
1124
1129
 
1125
1130
  const pluginJsPath = path.join(PLUGINS_DIR, name, 'plugin.js');