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,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instant calls - "Call Sam now…": a video call with one person, starting now,
|
|
3
|
+
* that is not a booking or an event. Nothing is reserved; it is a room, a link
|
|
4
|
+
* for the guest and a link for the host, open for INSTANT_HOURS.
|
|
5
|
+
*
|
|
6
|
+
* PRO ONLY. Core holds the code, but a site has instant calls only while a
|
|
7
|
+
* paid Tool with calls (Booking Pro, Contacts Pro, Calendar) is running and has
|
|
8
|
+
* called `hooks.enableInstantCalls()` at boot. Until then every route answers
|
|
9
|
+
* 404 (routes/api/calls.js, routes/call-public.js) and no "Call … now" action
|
|
10
|
+
* is registered anywhere - a free site never has them. plugins.js binds the
|
|
11
|
+
* hook to the plugin's own name, tier and closedSource flag; only a plugin that
|
|
12
|
+
* is `tier: "paid"` AND `closedSource: true` (so it loads only when licensed)
|
|
13
|
+
* may switch them on - a direct call without those (an import) is refused.
|
|
14
|
+
* Disabling the last such plugin switches them off at once (disableInstantCalls,
|
|
15
|
+
* from plugins.js runLifecycleHook).
|
|
16
|
+
*
|
|
17
|
+
* An OLDER Booking Pro (0.3.x) still carries its own instant calls under the
|
|
18
|
+
* related provider id 'booking-pro-call'. While it is registered, core's own
|
|
19
|
+
* actions and list stand aside (checked per request), so nobody sees two.
|
|
20
|
+
*
|
|
21
|
+
* A record: {id, name, email, contactId, hostId, hostName, token, hostKey,
|
|
22
|
+
* createdAt, expiresAt}. Kept in memory and in one JSON file with the sign-in
|
|
23
|
+
* sessions (content/sessions/ - runtime state, never committed; see
|
|
24
|
+
* siteGitignore.js), so a restart keeps the links working. The call itself (who
|
|
25
|
+
* is in it) is the call engine's, in memory. Records are forgotten KEEP_DAYS
|
|
26
|
+
* after they end (a contact's card lists the recent ones), and never more than
|
|
27
|
+
* MAX_KEPT are kept.
|
|
28
|
+
*
|
|
29
|
+
* The page is `/_call/<id>?t=<guest token>` or `?h=<host key>`; the engine's
|
|
30
|
+
* routes are /api/calls/room/<id>/... Guests knock; the host lets them in.
|
|
31
|
+
*
|
|
32
|
+
* @module services/instantCalls
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from 'node:fs/promises';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import {randomBytes, timingSafeEqual} from 'node:crypto';
|
|
38
|
+
import {config, getConfig} from '../config.js';
|
|
39
|
+
import {hasRelatedProvider, registerRelatedItems, unregisterRelatedItems} from './hooks.js';
|
|
40
|
+
import {getPermissionsFor} from './roles.js';
|
|
41
|
+
import {getEffectiveRoles} from './userRoles.js';
|
|
42
|
+
import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
|
|
43
|
+
|
|
44
|
+
export const INSTANT_HOURS = 3;
|
|
45
|
+
export const KEEP_DAYS = 30;
|
|
46
|
+
export const MAX_KEPT = 2000;
|
|
47
|
+
export const CALL_PATH = '/_call';
|
|
48
|
+
export const ROOM_API = '/api/calls/room';
|
|
49
|
+
export const ID_RE = /^[\w-]{16,40}$/;
|
|
50
|
+
export const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
51
|
+
/** The related-items provider id, on a contact and on an email. */
|
|
52
|
+
export const PROVIDER_ID = 'instant-calls';
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Switched on by a Pro Tool
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/** The related provider id an older Booking Pro (0.3.x) registers for its own instant calls. */
|
|
59
|
+
export const LEGACY_PROVIDER_ID = 'booking-pro-call';
|
|
60
|
+
|
|
61
|
+
const owners = new Set();
|
|
62
|
+
let granted = false;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A Pro Tool switches instant calls on (plugins.js hands it to each plugin as
|
|
66
|
+
* `hooks.enableInstantCalls`, bound to the plugin's name, tier and closedSource).
|
|
67
|
+
*
|
|
68
|
+
* @param {{owner: string, tier?: string, closedSource?: boolean}} opts
|
|
69
|
+
* @returns {boolean} true when the owner is (now) one of the enablers
|
|
70
|
+
*/
|
|
71
|
+
export function enableInstantCalls({owner, tier, closedSource} = {}) {
|
|
72
|
+
if (typeof owner !== 'string' || !owner.trim()) return false;
|
|
73
|
+
// Pro only, and only through the plugin-bound hook: a paid, licensed (closedSource) plugin.
|
|
74
|
+
if (tier !== 'paid' || closedSource !== true) return false;
|
|
75
|
+
const first = owners.size === 0;
|
|
76
|
+
owners.add(owner.trim());
|
|
77
|
+
if (first) registerActions();
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A Tool with calls was disabled: it no longer keeps them on. The last one
|
|
83
|
+
* gone switches them off at once - routes answer 404, the actions go.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} owner
|
|
86
|
+
* @returns {boolean} whether instant calls are still on
|
|
87
|
+
*/
|
|
88
|
+
export function disableInstantCalls(owner) {
|
|
89
|
+
if (owners.delete(String(owner || '').trim()) && owners.size === 0) unregisterActions();
|
|
90
|
+
return owners.size > 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Admins may call; a role editor can give it to other roles. Once only - removed
|
|
95
|
+
* by hand, it stays removed. Awaited by plugins.js in its one-plugin-at-a-time
|
|
96
|
+
* access step (right after registerPluginAccess), so it never interleaves with a
|
|
97
|
+
* plugin's own grants (roles.js rewrites the roles file unlocked).
|
|
98
|
+
*
|
|
99
|
+
* @param {{grantPermissionOnce: Function}} rolesService
|
|
100
|
+
*/
|
|
101
|
+
export async function grantInstantCallsOnce(rolesService) {
|
|
102
|
+
if (granted || owners.size === 0 || typeof rolesService?.grantPermissionOnce !== 'function') return false;
|
|
103
|
+
granted = true;
|
|
104
|
+
try { await rolesService.grantPermissionOnce('admin', 'calls', 'core'); } catch { /* no admin role: nothing to grant */ }
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Does this site have instant calls (a Pro Tool with calls running)? */
|
|
109
|
+
export const instantCallsEnabled = () => owners.size > 0;
|
|
110
|
+
|
|
111
|
+
/** Which Tools switched them on. */
|
|
112
|
+
export const instantCallOwners = () => [...owners];
|
|
113
|
+
|
|
114
|
+
/** Tests only: back to a site without instant calls. */
|
|
115
|
+
export function resetInstantCalls() {
|
|
116
|
+
owners.clear();
|
|
117
|
+
granted = false;
|
|
118
|
+
unregisterActions();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** May this user (a JWT user, or {roles}) do `action` with calls? */
|
|
122
|
+
export function mayCall(user, action = 'create') {
|
|
123
|
+
const roles = Array.isArray(user?.roles) ? user.roles : getEffectiveRoles(user || {});
|
|
124
|
+
return roles.some(r => getPermissionsFor('calls', action).includes(r));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An older Booking Pro's own "Call … now" is registered for this kind: core stands aside. */
|
|
128
|
+
const legacyHere = (kind) => hasRelatedProvider(kind, LEGACY_PROVIDER_ID);
|
|
129
|
+
|
|
130
|
+
function unregisterActions() {
|
|
131
|
+
unregisterRelatedItems('contact', PROVIDER_ID);
|
|
132
|
+
unregisterRelatedItems('message', PROVIDER_ID);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function registerActions() {
|
|
136
|
+
const permission = {resource: 'calls', action: 'create'};
|
|
137
|
+
// A contact: "Call Sam now…" in its menu, and its recent calls on the card.
|
|
138
|
+
registerRelatedItems('contact', {
|
|
139
|
+
id: PROVIDER_ID, label: 'Calls', icon: 'video', owner: 'core', when: () => !legacyHere('contact'),
|
|
140
|
+
actions: [{label: 'Call {name} now…', icon: 'video', url: '#/calls/new?contact={id}', permission}],
|
|
141
|
+
async list({id, user}) {
|
|
142
|
+
if (!user?.id || !mayCall(user, 'read')) return [];
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
return (await callsOfContact(id, String(user.id))).slice(0, 5).map(c => ({
|
|
145
|
+
id: c.id, title: 'Video call', startAt: c.createdAt, past: !isOpen(c, now), meta: isOpen(c, now) ? 'open now' : ''
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
// An email in the mail reader: "Call Sam now…" with the sender filled in.
|
|
150
|
+
registerRelatedItems('message', {
|
|
151
|
+
id: PROVIDER_ID, label: 'Call', icon: 'video', owner: 'core', when: () => !legacyHere('message'),
|
|
152
|
+
actions: [{label: 'Call {name} now…', icon: 'video', url: '#/calls/new?email={email}&name={name}', permission}]
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// The records
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
let file = null;
|
|
161
|
+
let loading = null; // ONE promise of the Map id -> record: concurrent first calls share the same Map
|
|
162
|
+
let writing = Promise.resolve();
|
|
163
|
+
|
|
164
|
+
const defaultFile = () => path.resolve(path.dirname(config.content.usersDir), 'sessions', 'instant-calls.json');
|
|
165
|
+
|
|
166
|
+
/** Tests: where the records live (null = the default). Forgets what was read. */
|
|
167
|
+
export function useCallsFile(p) {
|
|
168
|
+
file = p || null;
|
|
169
|
+
loading = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function load() {
|
|
173
|
+
loading ||= (async () => {
|
|
174
|
+
let list = [];
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(await fs.readFile(file || defaultFile(), 'utf8'));
|
|
177
|
+
if (Array.isArray(parsed)) list = parsed;
|
|
178
|
+
} catch { /* none yet, or unreadable: no link validates */ }
|
|
179
|
+
return new Map(list.filter(c => c && typeof c.id === 'string' && ID_RE.test(c.id)).map(c => [c.id, c]));
|
|
180
|
+
})();
|
|
181
|
+
return loading;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Queue a write of the whole store; writes run one after another, each with the Map as it is then. */
|
|
185
|
+
function save(calls) {
|
|
186
|
+
const target = file || defaultFile();
|
|
187
|
+
const list = [...calls.values()];
|
|
188
|
+
writing = writing.then(async () => {
|
|
189
|
+
await fs.mkdir(path.dirname(target), {recursive: true});
|
|
190
|
+
const tmp = `${target}.tmp`;
|
|
191
|
+
await fs.writeFile(tmp, JSON.stringify(list, null, 2) + '\n', {encoding: 'utf8', mode: 0o600});
|
|
192
|
+
await fs.rename(tmp, target);
|
|
193
|
+
}).catch((err) => { console.warn(`[calls] Could not save the call links: ${err?.message || err}`); });
|
|
194
|
+
return writing;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const key = () => randomBytes(18).toString('base64url');
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Start one: the record, stored. Old records go first (ended KEEP_DAYS ago),
|
|
201
|
+
* then the oldest past MAX_KEPT - the file never grows without bound.
|
|
202
|
+
*
|
|
203
|
+
* @param {{name?: string, email?: string, contactId?: string, hostId?: string, hostName?: string}} who
|
|
204
|
+
* @param {number} [now]
|
|
205
|
+
*/
|
|
206
|
+
export async function createCall({name = '', email = '', contactId = '', hostId = '', hostName = ''} = {}, now = Date.now()) {
|
|
207
|
+
const all = await load();
|
|
208
|
+
for (const [id, c] of all) if (!(Date.parse(c.expiresAt) >= now - KEEP_DAYS * 86400000)) all.delete(id);
|
|
209
|
+
const over = all.size - MAX_KEPT + 1;
|
|
210
|
+
if (over > 0) {
|
|
211
|
+
[...all.entries()].sort((a, b) => String(a[1].createdAt).localeCompare(String(b[1].createdAt)))
|
|
212
|
+
.slice(0, over).forEach(([id]) => all.delete(id));
|
|
213
|
+
}
|
|
214
|
+
const rec = {
|
|
215
|
+
id: randomBytes(12).toString('base64url'),
|
|
216
|
+
name: String(name || '').trim().slice(0, 120),
|
|
217
|
+
email: String(email || '').trim().toLowerCase().slice(0, 200),
|
|
218
|
+
contactId: String(contactId || '').slice(0, 64),
|
|
219
|
+
hostId: String(hostId || '').slice(0, 64),
|
|
220
|
+
hostName: String(hostName || '').trim().slice(0, 120) || 'Your host',
|
|
221
|
+
token: key(),
|
|
222
|
+
hostKey: key(),
|
|
223
|
+
createdAt: new Date(now).toISOString(),
|
|
224
|
+
expiresAt: new Date(now + INSTANT_HOURS * 3600000).toISOString()
|
|
225
|
+
};
|
|
226
|
+
all.set(rec.id, rec);
|
|
227
|
+
await save(all);
|
|
228
|
+
return rec;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One record, or null. */
|
|
232
|
+
export async function getCall(id) {
|
|
233
|
+
if (typeof id !== 'string' || !ID_RE.test(id)) return null;
|
|
234
|
+
return (await load()).get(id) || null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One host's calls with one contact, newest first. */
|
|
238
|
+
export async function callsOfContact(contactId, hostId) {
|
|
239
|
+
if (!contactId || !hostId) return [];
|
|
240
|
+
return [...(await load()).values()].filter(c => c.contactId === contactId && c.hostId === hostId)
|
|
241
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const same = (a, b) => {
|
|
245
|
+
const x = Buffer.from(String(a || ''));
|
|
246
|
+
const y = Buffer.from(String(b || ''));
|
|
247
|
+
return x.length === y.length && x.length > 0 && timingSafeEqual(x, y);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Who the caller is in an instant call, or null (the engine answers 404).
|
|
252
|
+
* `cred` is what the link carried: {t} for the guest, {h} for the host. The
|
|
253
|
+
* engine answers 410 once `closesAt` has passed.
|
|
254
|
+
*/
|
|
255
|
+
export function whoIsCalling(rec, cred = {}) {
|
|
256
|
+
if (!rec) return null;
|
|
257
|
+
// One title for both: the room keeps the title of whoever joined first, so a per-person one
|
|
258
|
+
// ("Call with <the other>") would show the guest their own name.
|
|
259
|
+
const title = `Video call: ${rec.hostName} and ${rec.name || 'a guest'}`;
|
|
260
|
+
const base = {max: 6, recordingAllowed: false, autoAdmit: false, opensAt: rec.createdAt, closesAt: rec.expiresAt, title};
|
|
261
|
+
if (cred.h && same(cred.h, rec.hostKey)) {
|
|
262
|
+
return {...base, host: true, name: rec.hostName, identity: `${rec.id}:host`};
|
|
263
|
+
}
|
|
264
|
+
if (cred.t && same(cred.t, rec.token)) {
|
|
265
|
+
return {...base, host: false, name: rec.name || '', identity: `${rec.id}:guest`};
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Is the call still open? */
|
|
271
|
+
export const isOpen = (rec, now = Date.now()) => Boolean(rec) && Date.parse(rec.expiresAt) > now;
|
|
272
|
+
|
|
273
|
+
/** The page addresses (relative to the site) of a call: the guest's and the host's. */
|
|
274
|
+
export function callPaths(rec) {
|
|
275
|
+
const at = `${CALL_PATH}/${encodeURIComponent(rec.id)}`;
|
|
276
|
+
return {join: `${at}?t=${encodeURIComponent(rec.token)}`, host: `${at}?h=${encodeURIComponent(rec.hostKey)}`};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Limits - a host starting calls, and emails to one address
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/** Calls one user may start in a minute (on top of the route's per-address limit). */
|
|
284
|
+
export const CREATES_PER_MINUTE = 10;
|
|
285
|
+
/** "Would like to talk now" emails one address may be sent in an hour, whoever sends them. */
|
|
286
|
+
export const EMAILS_PER_ADDRESS_HOUR = 3;
|
|
287
|
+
|
|
288
|
+
const createsBy = new Map(); // user id -> [times]
|
|
289
|
+
const emailsTo = new Map(); // address -> [times]
|
|
290
|
+
|
|
291
|
+
function take(map, key, max, windowMs, now) {
|
|
292
|
+
const since = now - windowMs;
|
|
293
|
+
const recent = (map.get(key) || []).filter(t => t > since);
|
|
294
|
+
if (recent.length >= max) { map.set(key, recent); return false; }
|
|
295
|
+
recent.push(now);
|
|
296
|
+
map.set(key, recent);
|
|
297
|
+
if (map.size > 10000) for (const [k, list] of map) if (!list.some(t => t > since)) map.delete(k);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** May this user start another call now? Counts it when yes. */
|
|
302
|
+
export const mayStartCall = (userId, now = Date.now()) => take(createsBy, String(userId || ''), CREATES_PER_MINUTE, 60000, now);
|
|
303
|
+
|
|
304
|
+
/** May this address be emailed another call link now? Counts it when yes. */
|
|
305
|
+
export const mayEmailAddress = (email, now = Date.now()) => take(emailsTo, String(email || '').trim().toLowerCase(), EMAILS_PER_ADDRESS_HOUR, 3600000, now);
|
|
306
|
+
|
|
307
|
+
/** Tests only. */
|
|
308
|
+
export function resetCallLimits() {
|
|
309
|
+
createsBy.clear();
|
|
310
|
+
emailsTo.clear();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// The email ("<host> would like to talk now")
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
let mailer = null;
|
|
318
|
+
let transport = null;
|
|
319
|
+
let transportFor = '';
|
|
320
|
+
|
|
321
|
+
/** Tests only: `fn(msg)` receives every message instead of SMTP. null restores. */
|
|
322
|
+
export function setCallMailer(fn) { mailer = fn; }
|
|
323
|
+
|
|
324
|
+
const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
|
|
325
|
+
|
|
326
|
+
/** Can mail really leave this site? (No SMTP host = Ethereal = delivered to nobody, so not sent at all.) */
|
|
327
|
+
export const callMailReady = () => Boolean(mailer) || isSmtpConfigured(site().smtp || {});
|
|
328
|
+
|
|
329
|
+
/** The message, escaped. */
|
|
330
|
+
export function composeCallEmail({name = '', hostName = '', joinUrl = ''}) {
|
|
331
|
+
const subject = `${hostName} would like to talk now`;
|
|
332
|
+
const lines = [`Hello${name ? ` ${name}` : ''}, ${hostName} would like to talk to you now, on a video call.`,
|
|
333
|
+
'Use the button below to join. You need a camera and microphone, or you can join with just one. The link works for the next few hours.'];
|
|
334
|
+
const html = [...lines.map(l => `<p>${escapeHtml(l)}</p>`),
|
|
335
|
+
`<p><a href="${escapeHtml(joinUrl)}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;border-radius:6px;text-decoration:none">Join the call</a></p>`,
|
|
336
|
+
`<p style="color:#666;font-size:13px">Or paste this into your browser: ${escapeHtml(joinUrl)}</p>`].join('\n');
|
|
337
|
+
const text = [...lines, `Join the call: ${joinUrl}`].join('\n\n');
|
|
338
|
+
return {subject, html, text};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Send it. Never throws: a lost email must not fail the call (the host still
|
|
343
|
+
* has the link to pass on). Resolves to true only when it left.
|
|
344
|
+
*/
|
|
345
|
+
export async function sendCallEmail({to, name, hostName, joinUrl}, {log} = {}) {
|
|
346
|
+
try {
|
|
347
|
+
if (!to) return false;
|
|
348
|
+
const msg = {to, ...composeCallEmail({name, hostName, joinUrl})};
|
|
349
|
+
if (mailer) { await mailer(msg); return true; }
|
|
350
|
+
const s = site();
|
|
351
|
+
const smtp = s.smtp || {};
|
|
352
|
+
if (!isSmtpConfigured(smtp)) return false;
|
|
353
|
+
const k = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
|
|
354
|
+
if (!transport || transportFor !== k) { transport = await createTransport(smtp); transportFor = k; }
|
|
355
|
+
await sendEmail(transport, {from: smtp.fromAddress || 'noreply@example.com', fromName: smtp.fromName || s.title || 'Calls', ...msg});
|
|
356
|
+
return true;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const line = `[calls] Email to ${to} not sent: ${err?.message || err}`;
|
|
359
|
+
try { if (typeof log?.error === 'function') log.error(line); else console.error(line); } catch { /* never */ }
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -216,6 +216,19 @@ export const REGISTRY = [
|
|
|
216
216
|
{key: 'manageGroups', label: 'Manage groups', description: 'Rename or delete any group, not only the ones they created - groups are shared, so this changes everyone\'s contacts'}
|
|
217
217
|
]
|
|
218
218
|
},
|
|
219
|
+
{
|
|
220
|
+
// Instant calls (services/instantCalls.js). Only in use while a Pro Tool with calls
|
|
221
|
+
// (Booking Pro, Contacts Pro, Calendar) is running; admins are granted it then, once.
|
|
222
|
+
key: 'calls',
|
|
223
|
+
label: 'Calls',
|
|
224
|
+
description: 'Call someone now on a video call on your site ("Call Sam now…"). Needs a Pro Tool with calls: Booking Pro, Contacts Pro or Calendar.',
|
|
225
|
+
icon: 'video',
|
|
226
|
+
group: 'Tools',
|
|
227
|
+
actions: [
|
|
228
|
+
{key: 'read', label: 'View', description: 'See their own recent calls on a contact'},
|
|
229
|
+
{key: 'create', label: 'Call', description: 'Start a call and send the link'}
|
|
230
|
+
]
|
|
231
|
+
},
|
|
219
232
|
{
|
|
220
233
|
key: 'analytics',
|
|
221
234
|
label: 'Analytics',
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
registerShortcode,
|
|
27
27
|
hideSidebarUrl,
|
|
28
28
|
registerComponent,
|
|
29
|
+
registerContactAccess,
|
|
30
|
+
unregisterContactAccess,
|
|
29
31
|
registerSidebarItem,
|
|
30
32
|
registerTransform
|
|
31
33
|
} from './hooks.js';
|
|
@@ -33,6 +35,7 @@ import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permi
|
|
|
33
35
|
import {registerThemeProvider} from './themeSettings.js';
|
|
34
36
|
import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
|
|
35
37
|
import {notifierFor} from './notify.js';
|
|
38
|
+
import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
|
|
36
39
|
import {createCollection, getCollection} from './collections.js';
|
|
37
40
|
import * as defaultRolesService from './roles.js';
|
|
38
41
|
import {getAdminScope, normaliseAdminScope} from './roles.js';
|
|
@@ -550,7 +553,13 @@ export async function registerPlugins(fastify) {
|
|
|
550
553
|
registerLoginCheck: (def) => registerLoginCheck(manifest.name, def),
|
|
551
554
|
registerPasswordCheck: (def) => registerPasswordCheck(manifest.name, def),
|
|
552
555
|
// A Domma component (.dmc source) usable as <dm-name> / [component] on pages (0.92.2).
|
|
553
|
-
registerComponent: pluginComponentHook
|
|
556
|
+
registerComponent: pluginComponentHook,
|
|
557
|
+
// Instant calls ("Call Sam now…", services/instantCalls.js) - a Pro Tool with calls switches
|
|
558
|
+
// them on at boot. Bound to the plugin's name, tier and closedSource: only a paid plugin that
|
|
559
|
+
// loads under a licence may.
|
|
560
|
+
enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
|
|
561
|
+
// Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
|
|
562
|
+
registerContactAccess: (fn) => registerContactAccess(manifest.name, fn)},
|
|
554
563
|
settings,
|
|
555
564
|
config: {}
|
|
556
565
|
});
|
|
@@ -587,8 +596,13 @@ export async function registerPlugins(fastify) {
|
|
|
587
596
|
|
|
588
597
|
// Permissions are held in memory, so they are registered on every
|
|
589
598
|
// boot, not only on enable (roles are created once, then kept).
|
|
599
|
+
// Instant calls' admin grant rides the same awaited step, so no two grants ever write the roles
|
|
600
|
+
// file at once (roles.js rewrites it unlocked).
|
|
590
601
|
await import('./roles.js')
|
|
591
|
-
.then(rolesService =>
|
|
602
|
+
.then(async rolesService => {
|
|
603
|
+
await registerPluginAccess(manifest.name, rolesService);
|
|
604
|
+
await grantInstantCallsOnce(rolesService);
|
|
605
|
+
})
|
|
592
606
|
.catch(err => fastify.log.warn(`[plugins] Access setup for "${manifest.name}" failed: ${err.message}`));
|
|
593
607
|
|
|
594
608
|
// Detect optional plugin.public.js for root-level route registration
|
|
@@ -1102,6 +1116,11 @@ export async function teardownPlugin(pluginName, services, fastify) {
|
|
|
1102
1116
|
export async function runLifecycleHook(name, hook, fastify) {
|
|
1103
1117
|
// Validate hook name
|
|
1104
1118
|
if (!['onEnable', 'onDisable'].includes(hook)) return;
|
|
1119
|
+
// A Tool with calls switched off stops keeping instant calls on - at once, not at the next restart.
|
|
1120
|
+
if (hook === 'onDisable') {
|
|
1121
|
+
disableInstantCalls(name);
|
|
1122
|
+
unregisterContactAccess(name);
|
|
1123
|
+
}
|
|
1105
1124
|
|
|
1106
1125
|
const pluginJsPath = path.join(PLUGINS_DIR, name, 'plugin.js');
|
|
1107
1126
|
try {
|
|
@@ -933,7 +933,8 @@ function buildFontVars(fontFamily, fontSize) {
|
|
|
933
933
|
* @param {string} templatePath - Absolute path to the plugin HTML fragment
|
|
934
934
|
* @param {object} data - Token replacement map; keys become {{key}} placeholders
|
|
935
935
|
* @param {object} seoMeta - Optional SEO overrides: { title, description, ogImage,
|
|
936
|
-
* urlPath, ogType, noindex, baseUrl, user }.
|
|
936
|
+
* urlPath, ogType, noindex, baseUrl, user, injection }. `injection: false` leaves out the site's and
|
|
937
|
+
* plugins' head/body snippets and the analytics beacon. Pass `baseUrl` (derived from the
|
|
937
938
|
* request) to populate canonical and og:url tags. Pass `user` (`{role, additionalRoles}`
|
|
938
939
|
* or null) so menu items gated by `visibility` are filtered correctly; blog
|
|
939
940
|
* callers currently default to anonymous (null).
|
|
@@ -941,6 +942,10 @@ function buildFontVars(fontFamily, fontSize) {
|
|
|
941
942
|
*/
|
|
942
943
|
export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
943
944
|
const user = seoMeta.user || null;
|
|
945
|
+
// `injection: false` - no site/plugin head and body snippets and no analytics beacon: a page whose
|
|
946
|
+
// address carries a credential (an instant call's /_call/<id>?h=) must not hand it to a script
|
|
947
|
+
// the site or a plugin injects. The shell, theme and menus stay.
|
|
948
|
+
const bare = seoMeta.injection === false;
|
|
944
949
|
// Plugin fragments know their own URL via seoMeta.urlPath - enough for a
|
|
945
950
|
// menu bound to that page or to the project the URL falls under.
|
|
946
951
|
const menuCtx = seoMeta.urlPath
|
|
@@ -1102,18 +1107,18 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1102
1107
|
: site.footer,
|
|
1103
1108
|
social: site.social || null
|
|
1104
1109
|
}).replace(/<\/script>/gi, '<\\/script>'),
|
|
1105
|
-
headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
1110
|
+
headInject: [bare ? '' : injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
1106
1111
|
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
1107
1112
|
// generated, custom.css is hand-written, and the hand-written one stays
|
|
1108
1113
|
// the last word.
|
|
1109
|
-
headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
1114
|
+
headInjectLate: [bare ? '' : injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
1110
1115
|
.filter(Boolean).join('\n'),
|
|
1111
1116
|
bodyEndInject: [
|
|
1112
1117
|
overlayMenus,
|
|
1113
|
-
injection.bodyEnd,
|
|
1118
|
+
bare ? '' : injection.bodyEnd,
|
|
1114
1119
|
searchAssets.bodyTag,
|
|
1115
1120
|
switcherAssets.bodyTag,
|
|
1116
|
-
buildAnalyticsTag(),
|
|
1121
|
+
bare ? '' : buildAnalyticsTag(),
|
|
1117
1122
|
(seoMeta.usedComponents || [])
|
|
1118
1123
|
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|
|
1119
1124
|
.join('\n')
|