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.
- package/admin/js/api.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/scribe-model.js +1 -1
- package/admin/js/lib/shortcode-preview.js +1 -1
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/admin/js/templates/docs/usage-site-settings.html +2 -1
- package/admin/js/templates/page-editor.html +2 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/theme.html +1 -1
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/theme.js +5 -5
- package/package.json +1 -1
- package/plugins/_lib/call/ice.js +5 -2
- package/plugins/_lib/call/rooms.js +9 -3
- package/plugins/_lib/call/routes.js +7 -4
- package/plugins/_lib/mail/defaults.js +12 -0
- package/plugins/_lib/mail/hostGuard.js +86 -0
- package/plugins/_lib/mail/readRoutes.js +20 -1
- package/plugins/_lib/outreach/audience.js +76 -0
- package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
- package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
- package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
- package/plugins/_lib/outreach/expiry.js +46 -0
- package/plugins/_lib/outreach/index.js +15 -0
- package/plugins/_lib/outreach/ledger.js +127 -0
- package/plugins/_lib/outreach/sending.js +121 -0
- package/plugins/_lib/outreach/suppression.js +62 -0
- package/plugins/_lib/outreach/tokens.js +35 -0
- package/plugins/_lib/outreach/unsubscribe.js +46 -0
- package/plugins/_lib/public/call/call.js +3 -1
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/docs/guide.md +1 -1
- package/plugins/blog/lib/page.js +32 -0
- package/plugins/blog/plugin.js +58 -16
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +5 -4
- package/plugins/blog/tests/public.test.js +30 -1
- package/plugins/booking/CLAUDE.md +4 -1
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +16 -21
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/api.test.js +26 -1
- package/plugins/feedback/CLAUDE.md +7 -2
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/lib/receiver.js +1 -1
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/free-tier.lock.json +63 -51
- package/plugins/mail-reader/CLAUDE.md +6 -0
- package/plugins/mail-reader/docs/guide.md +1 -0
- package/plugins/mail-reader/plugin.json +2 -2
- package/plugins/shopping-cart/CLAUDE.md +62 -1
- package/plugins/shopping-cart/admin/css/index.css +16 -0
- package/plugins/shopping-cart/admin/lib/chart.js +64 -0
- package/plugins/shopping-cart/admin/lib/kit.js +15 -0
- package/plugins/shopping-cart/admin/views/orders.js +1 -0
- package/plugins/shopping-cart/admin/views/overview.js +4 -53
- package/plugins/shopping-cart/admin/views/sales.js +133 -0
- package/plugins/shopping-cart/admin/views/settings.js +3 -1
- package/plugins/shopping-cart/admin/views/shop.js +2 -1
- package/plugins/shopping-cart/config.js +1 -0
- package/plugins/shopping-cart/docs/guide.md +26 -1
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +185 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/payments.js +7 -4
- package/plugins/shopping-cart/lib/render.js +13 -1
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +21 -3
- package/plugins/shopping-cart/plugin.js +180 -35
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/plugin.public.js +17 -1
- package/plugins/shopping-cart/public/account.js +277 -0
- package/plugins/shopping-cart/public/cart.js +16 -0
- package/plugins/shopping-cart/public/lib/session.js +82 -0
- package/plugins/shopping-cart/public/shop.css +27 -0
- package/plugins/shopping-cart/roles/shop-customer.json +8 -0
- package/plugins/shopping-cart/tests/accounts.test.js +136 -0
- package/plugins/shopping-cart/tests/api.test.js +34 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/pure.test.js +16 -1
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/plugins/support/CLAUDE.md +6 -0
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/docs/guide.md +90 -0
- package/plugins/support/lib/receiver.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/scribe-style.mjs +1 -1
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/analytics.js +32 -0
- package/server/routes/api/collections.js +148 -15
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/server.js +10 -1
- package/server/services/authChecks.js +8 -0
- package/server/services/collectionAccess.js +191 -0
- package/server/services/collections.js +8 -2
- package/server/services/markdown.js +7 -2
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/services/request-log.js +70 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
194
|
+
<script src="/public/js/effects.js?v=20261013-scribe-fonts"></script>
|
|
195
195
|
|
|
196
196
|
<!-- Plugin body-end injection -->
|
|
197
197
|
{{bodyEndInject}}
|