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,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 => registerPluginAccess(manifest.name, 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 }. Pass `baseUrl` (derived from the
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')
@@ -0,0 +1,3 @@
1
+ <main class="container dm-call-page" style="padding: 2rem 0;">
2
+ {{body}}
3
+ </main>