domma-cms 1.6.34 → 1.6.36

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.
Files changed (106) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/lib/effects-builder.js +1 -1
  3. package/admin/js/lib/pages-arrange.js +1 -1
  4. package/admin/js/lib/scribe-composer.js +1 -1
  5. package/admin/js/lib/scribe-model.js +1 -1
  6. package/admin/js/lib/shortcode-preview.js +1 -1
  7. package/admin/js/lib/shortcode-registry.js +2 -2
  8. package/admin/js/templates/docs/usage-site-settings.html +2 -1
  9. package/admin/js/templates/page-editor.html +2 -1
  10. package/admin/js/templates/pages.html +1 -1
  11. package/admin/js/templates/theme.html +1 -1
  12. package/admin/js/views/pages.js +3 -3
  13. package/admin/js/views/theme.js +5 -5
  14. package/package.json +1 -1
  15. package/plugins/_lib/call/ice.js +5 -2
  16. package/plugins/_lib/call/rooms.js +9 -3
  17. package/plugins/_lib/call/routes.js +7 -4
  18. package/plugins/_lib/mail/defaults.js +12 -0
  19. package/plugins/_lib/mail/hostGuard.js +86 -0
  20. package/plugins/_lib/mail/readRoutes.js +20 -1
  21. package/plugins/_lib/outreach/audience.js +76 -0
  22. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  23. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  24. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  25. package/plugins/_lib/outreach/expiry.js +46 -0
  26. package/plugins/_lib/outreach/index.js +15 -0
  27. package/plugins/_lib/outreach/ledger.js +127 -0
  28. package/plugins/_lib/outreach/sending.js +121 -0
  29. package/plugins/_lib/outreach/suppression.js +62 -0
  30. package/plugins/_lib/outreach/tokens.js +35 -0
  31. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  32. package/plugins/_lib/public/call/call.js +3 -1
  33. package/plugins/blog/CLAUDE.md +11 -1
  34. package/plugins/blog/docs/guide.md +1 -1
  35. package/plugins/blog/lib/page.js +32 -0
  36. package/plugins/blog/plugin.js +58 -16
  37. package/plugins/blog/plugin.json +2 -2
  38. package/plugins/blog/plugin.public.js +5 -4
  39. package/plugins/blog/tests/public.test.js +30 -1
  40. package/plugins/booking/CLAUDE.md +4 -1
  41. package/plugins/booking/docs/guide.md +1 -1
  42. package/plugins/booking/plugin.js +16 -21
  43. package/plugins/booking/plugin.json +2 -2
  44. package/plugins/booking/tests/api.test.js +26 -1
  45. package/plugins/feedback/CLAUDE.md +7 -2
  46. package/plugins/feedback/admin/views/feedback.js +1 -1
  47. package/plugins/feedback/lib/receiver.js +1 -1
  48. package/plugins/feedback/plugin.json +3 -3
  49. package/plugins/free-tier.lock.json +63 -51
  50. package/plugins/mail-reader/CLAUDE.md +6 -0
  51. package/plugins/mail-reader/docs/guide.md +1 -0
  52. package/plugins/mail-reader/plugin.json +2 -2
  53. package/plugins/shopping-cart/CLAUDE.md +62 -1
  54. package/plugins/shopping-cart/admin/css/index.css +16 -0
  55. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  56. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  57. package/plugins/shopping-cart/admin/views/orders.js +1 -0
  58. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  59. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  60. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  61. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  62. package/plugins/shopping-cart/config.js +1 -0
  63. package/plugins/shopping-cart/docs/guide.md +26 -1
  64. package/plugins/shopping-cart/inject/body.html +2 -2
  65. package/plugins/shopping-cart/lib/accounts.js +185 -0
  66. package/plugins/shopping-cart/lib/commission.js +90 -0
  67. package/plugins/shopping-cart/lib/extensions.js +12 -4
  68. package/plugins/shopping-cart/lib/payments.js +7 -4
  69. package/plugins/shopping-cart/lib/render.js +13 -1
  70. package/plugins/shopping-cart/lib/sales.js +193 -0
  71. package/plugins/shopping-cart/lib/shop.js +21 -3
  72. package/plugins/shopping-cart/plugin.js +180 -35
  73. package/plugins/shopping-cart/plugin.json +3 -3
  74. package/plugins/shopping-cart/plugin.public.js +17 -1
  75. package/plugins/shopping-cart/public/account.js +277 -0
  76. package/plugins/shopping-cart/public/cart.js +16 -0
  77. package/plugins/shopping-cart/public/lib/session.js +82 -0
  78. package/plugins/shopping-cart/public/shop.css +27 -0
  79. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  80. package/plugins/shopping-cart/tests/accounts.test.js +136 -0
  81. package/plugins/shopping-cart/tests/api.test.js +34 -0
  82. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  83. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  84. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  85. package/plugins/support/CLAUDE.md +6 -0
  86. package/plugins/support/admin/views/support.js +1 -1
  87. package/plugins/support/docs/guide.md +90 -0
  88. package/plugins/support/lib/receiver.js +1 -1
  89. package/plugins/support/plugin.json +3 -3
  90. package/public/css/site.css +1 -1
  91. package/public/js/effects.js +1 -1
  92. package/public/js/scribe-style.mjs +1 -1
  93. package/public/js/site.js +1 -1
  94. package/public/js/text-style.mjs +1 -1
  95. package/server/routes/api/analytics.js +32 -0
  96. package/server/routes/api/collections.js +148 -15
  97. package/server/routes/api/theme.js +12 -2
  98. package/server/routes/outreach-public.js +68 -0
  99. package/server/server.js +10 -1
  100. package/server/services/authChecks.js +8 -0
  101. package/server/services/collectionAccess.js +191 -0
  102. package/server/services/collections.js +8 -2
  103. package/server/services/markdown.js +7 -2
  104. package/server/services/outreachUnsubscribe.js +54 -0
  105. package/server/services/request-log.js +70 -0
  106. package/server/templates/page.html +3 -3
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The one-click unsubscribe page every outreach email links to:
3
+ *
4
+ * GET /campaign-unsubscribe?e=<base64url address>&k=<HMAC>
5
+ *
6
+ * Core's, not the sending Tool's, so the link in an email already sent keeps
7
+ * working when that Tool lapses or is removed (PECR). The address goes on the
8
+ * shared suppression list (`outreach-unsubscribes`), which every outreach
9
+ * Tool checks before it sends.
10
+ *
11
+ * A Campaigns Pro older than this engine registers the same path itself, and
12
+ * plugins register before core: then core stands aside rather than collide.
13
+ *
14
+ * @module routes/outreach-public
15
+ */
16
+ import {createCollection, createEntry, deleteEntry, getCollection, listEntries} from '../services/collections.js';
17
+ import {createSuppression} from '../../plugins/_lib/outreach/suppression.js';
18
+ import {UNSUBSCRIBE_PATH, verifyUnsubscribe} from '../../plugins/_lib/outreach/unsubscribe.js';
19
+ import {getUnsubscribeSecret} from '../services/outreachUnsubscribe.js';
20
+
21
+ const SLUG = 'outreach-unsubscribes';
22
+
23
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
+
25
+ function page(title, bodyHtml) {
26
+ return `<!DOCTYPE html><html lang="en-GB"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="robots" content="noindex"><title>${esc(title)}</title><link rel="stylesheet" href="/dist/domma/domma.css"><style>main{max-width:36rem;margin:4rem auto;padding:0 1.25rem;line-height:1.6}</style></head><body><main>${bodyHtml}</main></body></html>`;
27
+ }
28
+
29
+ /** The suppression list's collection - made if no outreach Tool ever made it. */
30
+ async function ensureList() {
31
+ try {
32
+ await getCollection(SLUG);
33
+ } catch {
34
+ await createCollection({
35
+ title: 'Outreach Unsubscribes', slug: SLUG,
36
+ description: 'The shared suppression list - addresses no outreach Tool may email.',
37
+ fields: [
38
+ {name: 'email', label: 'Email', type: 'text', required: true},
39
+ {name: 'at', label: 'At', type: 'text', required: false},
40
+ {name: 'source', label: 'Source', type: 'text', required: false}
41
+ ]
42
+ }).catch(() => {});
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {import('fastify').FastifyInstance} fastify
48
+ * @param {{col?: object, secret?: string}} [opts] - tests inject a collections service and a secret
49
+ */
50
+ export async function outreachPublicRoutes(fastify, opts = {}) {
51
+ if (fastify.hasRoute({method: 'GET', url: UNSUBSCRIBE_PATH})) return;
52
+ const col = opts.col || {createEntry, deleteEntry, listEntries};
53
+ const suppression = createSuppression(col);
54
+
55
+ fastify.get(UNSUBSCRIBE_PATH, async (req, reply) => {
56
+ reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer');
57
+ let email = null;
58
+ try { email = verifyUnsubscribe(opts.secret || getUnsubscribeSecret(), req.query?.e, req.query?.k); } catch { email = null; }
59
+ if (!email) {
60
+ return reply.code(400).type('text/html; charset=utf-8')
61
+ .send(page('Unsubscribe', '<h1>That link is not right.</h1><p>Use the unsubscribe link from one of our emails.</p>'));
62
+ }
63
+ if (!opts.col) await ensureList();
64
+ await suppression.add(email, 'unsubscribe-link');
65
+ return reply.type('text/html; charset=utf-8').send(page('Unsubscribed',
66
+ `<h1>You are unsubscribed.</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`));
67
+ });
68
+ }
package/server/server.js CHANGED
@@ -30,6 +30,7 @@ import {seedDefaultComponents} from './services/components.js';
30
30
  import {refreshComponentTagAllowlist} from './services/markdown.js';
31
31
  import * as cache from './services/cache/index.js';
32
32
  import {parseTrustProxy} from './services/network.js';
33
+ import {requestSerializer} from './services/request-log.js';
33
34
  import {rateLimitOptions} from './services/rate-limit.js';
34
35
  import {frameSources} from './services/embedCsp.js';
35
36
 
@@ -85,7 +86,10 @@ function resolveLogLevel() {
85
86
  }
86
87
 
87
88
  const app = Fastify({
88
- logger: {level: resolveLogLevel()},
89
+ // The request serializer redacts query-string credentials (?token=, ?key=,
90
+ // *secret*, *password*) - see services/request-log.js; everything else is
91
+ // logged as Fastify would.
92
+ logger: {level: resolveLogLevel(), serializers: {req: requestSerializer}},
89
93
  // Behind a reverse proxy (Apache, domma-cms-manager), trust the proxy so
90
94
  // request.ip is the visitor, not the proxy's loopback. TRUST_PROXY=loopback
91
95
  // is the right value (right-most X-Forwarded-For entry a local proxy did not
@@ -723,6 +727,11 @@ await app.register(docsPublicRoutes);
723
727
  const { callPublicRoutes } = await import('./routes/call-public.js');
724
728
  await app.register(callPublicRoutes);
725
729
 
730
+ // The outreach unsubscribe link (/campaign-unsubscribe) - core's, so it outlives the Tool that
731
+ // sent the email. After the plugins: an older Campaigns Pro that registers it itself wins.
732
+ const { outreachPublicRoutes } = await import('./routes/outreach-public.js');
733
+ await app.register(outreachPublicRoutes);
734
+
726
735
  // Before the pages: /favicon.* must never reach the page router.
727
736
  const { faviconRoutes } = await import('./routes/favicon.js');
728
737
  await app.register(faviconRoutes);
@@ -77,6 +77,14 @@ export async function runLoginChecks(phase, ctx, {start = 0, log = console} = {}
77
77
  try {
78
78
  out = await c.check(ctx);
79
79
  } catch (err) {
80
+ // A 'before' check that breaks (a breached-password lookup timing out) must not lock
81
+ // every account out, so it is skipped. An 'after' check is a second factor or a step-up:
82
+ // skipping one is a bypass (Security Pro's cookie parser threw on one bad cookie and a
83
+ // password alone signed in), so a throw there refuses the sign-in.
84
+ if (phase === 'after') {
85
+ log.error?.(`[auth] login check ${c.id} failed, sign-in refused: ${errOf(err)}`);
86
+ return {deny: true, message: 'Sign-in could not be completed. Try again.', status: 503, by: c.id};
87
+ }
80
88
  log.warn?.(`[auth] login check ${c.id} failed, skipped: ${errOf(err)}`);
81
89
  continue;
82
90
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Collection access through the generic Collections API (/api/collections/:slug/...).
3
+ *
4
+ * The generic routes are guarded by the `collections` permission, which every
5
+ * level-1 admin holds. Two kinds of collection need more than that:
6
+ *
7
+ * - The user-data presets (Notes, Todo, Contacts) hold people's private rows.
8
+ * Their own Tool routes scope every read and write to the caller
9
+ * (`data.userId` for notes, tasks and contacts; `meta.createdBy` for the
10
+ * shared contact groups, which anyone may read but only their creator or a
11
+ * `contacts.manageGroups` holder may change). The generic API applies the
12
+ * same rule, so an admin sees and edits their own rows and nobody else's.
13
+ *
14
+ * - A collection a plugin created (`schema.plugin`) belongs to that plugin.
15
+ * When the plugin registers permission resources (plugin.json
16
+ * `permissions`, or `resources` in its role files) the caller must hold one
17
+ * of them; a plugin that registers none falls back to `collections` alone.
18
+ *
19
+ * A level-0 user keeps everything - the same bypass rowAccess.js gives them.
20
+ */
21
+ import {USER_DATA_PRESET_SLUGS} from './presetCollections.js';
22
+ import {getPermissionsFor} from './roles.js';
23
+ import {getEffectiveLevel, getEffectiveRoles} from './userRoles.js';
24
+ import {getEffectiveRegistry} from './permissionRegistry.js';
25
+
26
+ /** Where each user-data preset keeps its owner - the field its Tool routes filter on. */
27
+ const OWNER_FIELD = Object.freeze({
28
+ 'notes-notes': {in: 'data', field: 'userId'},
29
+ 'todo-items': {in: 'data', field: 'userId'},
30
+ 'contacts-contacts': {in: 'data', field: 'userId'},
31
+ 'contacts-groups': {in: 'meta', field: 'createdBy'}
32
+ });
33
+
34
+ /** A row anyone with the Tool may read, whoever made it (ownership gates writes only). */
35
+ const SHARED_READ = new Set(['contacts-groups']);
36
+
37
+ /**
38
+ * Whether the slug is one of the Tools' user-data presets.
39
+ *
40
+ * @param {string} slug
41
+ * @returns {boolean}
42
+ */
43
+ export function isUserDataPreset(slug) {
44
+ return USER_DATA_PRESET_SLUGS.includes(slug);
45
+ }
46
+
47
+ /**
48
+ * The caller's id as the Tools record it (`id`, or `sub` on older tokens).
49
+ *
50
+ * @param {object|null} user
51
+ * @returns {string|null}
52
+ */
53
+ export function callerId(user) {
54
+ return user?.id ?? user?.sub ?? null;
55
+ }
56
+
57
+ /**
58
+ * Whether the user holds the root (level 0) role, directly or through an
59
+ * additional role - the one level the scoping never applies to.
60
+ *
61
+ * @param {object|null} user
62
+ * @returns {boolean}
63
+ */
64
+ export function isRoot(user) {
65
+ return Boolean(user) && getEffectiveLevel(user) === 0;
66
+ }
67
+
68
+ /**
69
+ * Whether any of the user's roles holds the resource (any action) or, with
70
+ * `action`, that specific action. Plugins name their own actions (`manage`,
71
+ * `send`, `kitchen`), so the bare resource is what the plugin check asks for.
72
+ *
73
+ * @param {object|null} user
74
+ * @param {string} resource
75
+ * @param {string} [action]
76
+ * @returns {boolean}
77
+ */
78
+ export function holds(user, resource, action) {
79
+ if (!user) return false;
80
+ const allowed = getPermissionsFor(resource, action);
81
+ return getEffectiveRoles(user).some(r => allowed.includes(r));
82
+ }
83
+
84
+ /**
85
+ * The permission resources a plugin registered at boot (plugin.json
86
+ * `permissions` plus `resources` in its role files).
87
+ *
88
+ * @param {string} plugin
89
+ * @param {Array<{key: string, plugin?: string}>} [registry] - injectable for tests
90
+ * @returns {string[]}
91
+ */
92
+ export function pluginResources(plugin, registry = getEffectiveRegistry()) {
93
+ return registry.filter(r => r.plugin === plugin).map(r => r.key);
94
+ }
95
+
96
+ /**
97
+ * Why `user` may not touch a plugin's collection through the generic API, or
98
+ * null when they may: the collection is nobody's, the plugin registered no
99
+ * permission, the user holds one of them, or the user is root.
100
+ *
101
+ * @param {object} schema
102
+ * @param {object|null} user
103
+ * @param {Array<{key: string, plugin?: string}>} [registry] - injectable for tests
104
+ * @returns {string|null}
105
+ */
106
+ export function pluginDenial(schema, user, registry) {
107
+ const plugin = schema?.plugin;
108
+ if (!plugin || isRoot(user)) return null;
109
+ const resources = pluginResources(plugin, registry);
110
+ if (!resources.length) return null;
111
+ if (resources.some(resource => holds(user, resource))) return null;
112
+ return `This collection belongs to the ${plugin} plugin - its permission is required`;
113
+ }
114
+
115
+ /**
116
+ * The owner recorded on a row of a user-data preset.
117
+ *
118
+ * @param {string} slug
119
+ * @param {object} entry
120
+ * @returns {string|null}
121
+ */
122
+ export function ownerOf(slug, entry) {
123
+ const where = OWNER_FIELD[slug];
124
+ if (!where) return null;
125
+ return entry?.[where.in]?.[where.field] ?? null;
126
+ }
127
+
128
+ /**
129
+ * The list filter that keeps a user-data preset to the caller's own rows, or
130
+ * null when no filter applies (not a user-data preset, root, or a preset whose
131
+ * rows everyone may read). `createdBy` is the filter engine's pseudo-field for
132
+ * `meta.createdBy`; anything else is a `data` field.
133
+ *
134
+ * @param {string} slug
135
+ * @param {object|null} user
136
+ * @returns {object|null}
137
+ */
138
+ export function ownRowsFilter(slug, user) {
139
+ const where = OWNER_FIELD[slug];
140
+ if (!where || SHARED_READ.has(slug) || isRoot(user)) return null;
141
+ return {[where.field]: callerId(user)};
142
+ }
143
+
144
+ /**
145
+ * Whether `user` may read one row of a user-data preset.
146
+ *
147
+ * @param {string} slug
148
+ * @param {object} entry
149
+ * @param {object|null} user
150
+ * @returns {boolean}
151
+ */
152
+ export function mayReadRow(slug, entry, user) {
153
+ if (!OWNER_FIELD[slug] || SHARED_READ.has(slug) || isRoot(user)) return true;
154
+ const uid = callerId(user);
155
+ return Boolean(uid) && ownerOf(slug, entry) === uid;
156
+ }
157
+
158
+ /**
159
+ * Whether `user` may change or remove one row of a user-data preset: its
160
+ * owner, root, or - for contact groups - a `contacts.manageGroups` holder, as
161
+ * the Contacts Tool allows.
162
+ *
163
+ * @param {string} slug
164
+ * @param {object} entry
165
+ * @param {object|null} user
166
+ * @returns {boolean}
167
+ */
168
+ export function mayWriteRow(slug, entry, user) {
169
+ if (!OWNER_FIELD[slug] || isRoot(user)) return true;
170
+ const uid = callerId(user);
171
+ if (uid && ownerOf(slug, entry) === uid) return true;
172
+ return slug === 'contacts-groups' && holds(user, 'contacts', 'manageGroups');
173
+ }
174
+
175
+ /**
176
+ * The data to store for a user-data preset row written by `user`: a new row
177
+ * is theirs, an existing row keeps its owner. Root writes what it sent.
178
+ * Groups record their owner in meta, so their data passes through.
179
+ *
180
+ * @param {string} slug
181
+ * @param {object} data
182
+ * @param {object|null} user
183
+ * @param {object|null} [existing] - the row being replaced, for updates
184
+ * @returns {object}
185
+ */
186
+ export function pinOwner(slug, data, user, existing = null) {
187
+ const where = OWNER_FIELD[slug];
188
+ if (!where || where.in !== 'data' || isRoot(user)) return data;
189
+ const owner = existing ? ownerOf(slug, existing) : callerId(user);
190
+ return {...data, [where.field]: owner ?? null};
191
+ }
@@ -594,14 +594,20 @@ export async function migrateStorage(slug, storage) {
594
594
  *
595
595
  * @param {string} slug
596
596
  * @param {'json'|'csv'} [format='json']
597
+ * @param {object} [opts]
598
+ * @param {Record<string, unknown>|null} [opts.filter] - only the matching rows (filterEngine.js syntax)
597
599
  * @returns {Promise<string>}
598
600
  */
599
- export async function exportEntries(slug, format = 'json') {
601
+ export async function exportEntries(slug, format = 'json', {filter = null} = {}) {
600
602
  const schema = await getCollection(slug);
601
603
  if (!schema) throw new Error(`Collection "${slug}" not found`);
602
604
 
603
605
  const adapter = await getAdapter(slug);
604
- const entries = await adapter.all(slug);
606
+ // With a filter (the generic API exporting one person's rows of a user-data
607
+ // preset) only the matching rows go out; otherwise everything, trash included.
608
+ const entries = filter
609
+ ? (await adapter.list(slug, {limit: 0, sort: 'createdAt', order: 'asc', filter})).entries
610
+ : await adapter.all(slug);
605
611
 
606
612
  if (format === 'csv') {
607
613
  const columns = [
@@ -777,7 +777,8 @@ function renderCollectionCarousel(entries, opts, ctxOpts = {}) {
777
777
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
778
778
  }
779
779
 
780
- const passthrough = ['autoplay', 'interval', 'loop', 'animation', 'animation-duration', 'animation-easing']
780
+ const passthrough = ['autoplay', 'interval', 'loop', 'animation', 'animation-duration', 'animation-easing', 'arrow-position',
781
+ 'background', 'arrow-background', 'arrow-color']
781
782
  .map(k => dataAttrs[k] ? ` data-${k}="${escapeAttr(String(dataAttrs[k]))}"` : '')
782
783
  .join('');
783
784
 
@@ -3157,7 +3158,11 @@ function processCarouselBlocks(markdown) {
3157
3158
  attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
3158
3159
  attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : '',
3159
3160
  attrs['animation-duration'] ? ` data-animation-duration="${escapeAttr(attrs['animation-duration'])}"` : '',
3160
- attrs['animation-easing'] ? ` data-animation-easing="${escapeAttr(attrs['animation-easing'])}"` : ''
3161
+ attrs['animation-easing'] ? ` data-animation-easing="${escapeAttr(attrs['animation-easing'])}"` : '',
3162
+ ['top', 'middle', 'bottom'].includes(attrs['arrow-position']) ? ` data-arrow-position="${escapeAttr(attrs['arrow-position'])}"` : '',
3163
+ attrs.background ? ` data-background="${escapeAttr(attrs.background)}"` : '',
3164
+ attrs['arrow-background'] ? ` data-arrow-background="${escapeAttr(attrs['arrow-background'])}"` : '',
3165
+ attrs['arrow-color'] ? ` data-arrow-color="${escapeAttr(attrs['arrow-color'])}"` : ''
3161
3166
  ].join('');
3162
3167
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
3163
3168
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The site's outreach unsubscribe secret - the key every emailed unsubscribe
3
+ * link is signed with (plugins/_lib/outreach/unsubscribe.js).
4
+ *
5
+ * It belongs to the engine, not to the Tool that sends, so the links in emails
6
+ * already sent keep verifying after that Tool's licence lapses or it is
7
+ * removed: UK marketing email (PECR) needs the opt-out to keep working.
8
+ * Kept in `config/outreach.json`.
9
+ *
10
+ * Before the engine owned it a sending Tool kept it in its own
11
+ * config/plugins.json entry as `unsubscribeSecret`. The first read adopts such
12
+ * a secret (any entry - core does not name Tools), so links signed with it
13
+ * still verify; only a site that never had one gets a fresh secret.
14
+ *
15
+ * @module services/outreachUnsubscribe
16
+ */
17
+ import {getConfig, saveConfig} from '../config.js';
18
+ import {mintSecret} from '../../plugins/_lib/outreach/unsubscribe.js';
19
+ import {getPluginStates} from './plugins.js';
20
+
21
+ let cached = null;
22
+
23
+ /** A secret a Tool left in config/plugins.json before the engine kept one, or null. */
24
+ export function legacySecret(states = getPluginStates()) {
25
+ for (const state of Object.values(states || {})) {
26
+ const s = state && typeof state === 'object' ? state.unsubscribeSecret : null;
27
+ if (typeof s === 'string' && s.length >= 32) return s;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * The secret, minted (or adopted) and stored on first use.
34
+ *
35
+ * @returns {string}
36
+ */
37
+ export function getUnsubscribeSecret() {
38
+ if (cached) return cached;
39
+ let stored = null;
40
+ try { stored = getConfig('outreach'); } catch { /* no file yet */ }
41
+ if (typeof stored?.unsubscribeSecret === 'string' && stored.unsubscribeSecret) {
42
+ cached = stored.unsubscribeSecret;
43
+ return cached;
44
+ }
45
+ const secret = legacySecret() || mintSecret();
46
+ saveConfig('outreach', {...(stored || {}), unsubscribeSecret: secret});
47
+ cached = secret;
48
+ return cached;
49
+ }
50
+
51
+ /** Tests only: forget the cached secret. */
52
+ export function _resetUnsubscribeSecretForTest() {
53
+ cached = null;
54
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Request log redaction - credentials never reach the pino request line.
3
+ *
4
+ * Several routes carry a live credential in the query string: the mail reader's
5
+ * `/body?token=` grant, the ecards `/ecards/view?token=` link, the call invite's
6
+ * `?key=`. Fastify's default `req` serializer logs `req.url` whole, so the log
7
+ * (and anything shipping it) held usable tokens for as long as it was kept.
8
+ *
9
+ * Only the VALUES of the sensitive parameters are replaced; the path, the other
10
+ * parameters and every other field Fastify logs stay exactly as before, so log
11
+ * greps keep working.
12
+ */
13
+
14
+ /** Parameter names redacted whatever their case. */
15
+ const SENSITIVE = new Set(['token', 't', 'key', 'hostkey', 'invitekey']);
16
+
17
+ /** Anything with secret or password in the name is redacted too. */
18
+ const SENSITIVE_PATTERN = /secret|password|passwd/i;
19
+
20
+ export const REDACTED = '[redacted]';
21
+
22
+ /** Is this query parameter a credential? */
23
+ export function isSensitiveParam(name) {
24
+ const n = String(name ?? '');
25
+ return SENSITIVE.has(n.toLowerCase()) || SENSITIVE_PATTERN.test(n);
26
+ }
27
+
28
+ /**
29
+ * The URL with credential values replaced by `[redacted]`.
30
+ *
31
+ * Works on the raw query text rather than URLSearchParams so a URL that does
32
+ * not re-encode identically still logs as it was requested: only the value
33
+ * between `=` and the next `&` (or `#`) is replaced.
34
+ *
35
+ * @param {string} url
36
+ * @returns {string}
37
+ */
38
+ export function redactUrl(url) {
39
+ if (typeof url !== 'string') return url;
40
+ const q = url.indexOf('?');
41
+ if (q === -1) return url;
42
+ const hash = url.indexOf('#', q);
43
+ const query = hash === -1 ? url.slice(q + 1) : url.slice(q + 1, hash);
44
+ if (!query) return url;
45
+ const cleaned = query.split('&').map((pair) => {
46
+ if (!pair) return pair;
47
+ const eq = pair.indexOf('=');
48
+ const name = eq === -1 ? pair : pair.slice(0, eq);
49
+ let decoded = name;
50
+ try { decoded = decodeURIComponent(name); } catch { /* keep as sent */ }
51
+ if (!isSensitiveParam(decoded)) return pair;
52
+ return `${name}=${REDACTED}`;
53
+ }).join('&');
54
+ return url.slice(0, q + 1) + cleaned + (hash === -1 ? '' : url.slice(hash));
55
+ }
56
+
57
+ /**
58
+ * Fastify's default `req` serializer (fastify/lib/logger-pino.js) with the
59
+ * url redacted. Field for field the same otherwise.
60
+ */
61
+ export function requestSerializer(req) {
62
+ return {
63
+ method: req.method,
64
+ url: redactUrl(req.url),
65
+ version: req.headers && req.headers['accept-version'],
66
+ host: req.host,
67
+ remoteAddress: req.ip,
68
+ remotePort: req.socket ? req.socket.remotePort : undefined,
69
+ };
70
+ }
@@ -25,7 +25,7 @@
25
25
  <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
26
26
 
27
27
  <!-- Site CSS -->
28
- <link rel="stylesheet" href="/public/css/site.css?v=20261012-carousel-centre">
28
+ <link rel="stylesheet" href="/public/css/site.css?v=20261013-carousel-controls">
29
29
  <link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
30
30
  <!-- Menu item hover / current-page effects. Loaded after site.css so a
31
31
  menu's own highlight wins over the blanket link styling there. -->
@@ -173,7 +173,7 @@
173
173
  </script>
174
174
 
175
175
  <!-- Site initialisation -->
176
- <script src="/public/js/site.js?v=20261012-timeline-icons" type="module"></script>
176
+ <script src="/public/js/site.js?v=20261013-carousel-colours" type="module"></script>
177
177
  <script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
178
178
  {{ctxMenusModule}}
179
179
 
@@ -191,7 +191,7 @@
191
191
  <script src="/public/js/forms.js?v=20261009-form-classes" type="module"></script>
192
192
 
193
193
  <!-- Core effects runtime -->
194
- <script src="/public/js/effects.js?v=20261012-scribe-typography"></script>
194
+ <script src="/public/js/effects.js?v=20261013-scribe-fonts"></script>
195
195
 
196
196
  <!-- Plugin body-end injection -->
197
197
  {{bodyEndInject}}