domma-cms 1.6.35 → 1.6.37

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 (65) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/compose-window.js +4 -0
  3. package/plugins/_lib/admin/mail/mail.css +1 -1
  4. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  5. package/plugins/_lib/call/ice.js +5 -2
  6. package/plugins/_lib/call/rooms.js +9 -3
  7. package/plugins/_lib/call/routes.js +7 -4
  8. package/plugins/_lib/mail/compose.js +7 -2
  9. package/plugins/_lib/mail/defaults.js +12 -0
  10. package/plugins/_lib/mail/hostGuard.js +86 -0
  11. package/plugins/_lib/mail/message-action.js +56 -0
  12. package/plugins/_lib/mail/readRoutes.js +20 -1
  13. package/plugins/_lib/mail/scheduler.js +2 -0
  14. package/plugins/_lib/mail/send.js +12 -3
  15. package/plugins/_lib/mail/store.js +16 -6
  16. package/plugins/_lib/outreach/index.js +1 -1
  17. package/plugins/_lib/outreach/ledger.js +10 -3
  18. package/plugins/_lib/outreach/sending.js +9 -3
  19. package/plugins/_lib/outreach/suppression.js +14 -3
  20. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  21. package/plugins/_lib/public/call/call.js +3 -1
  22. package/plugins/blog/CLAUDE.md +11 -1
  23. package/plugins/blog/docs/guide.md +1 -1
  24. package/plugins/blog/lib/page.js +32 -0
  25. package/plugins/blog/plugin.js +58 -16
  26. package/plugins/blog/plugin.json +2 -2
  27. package/plugins/blog/plugin.public.js +5 -4
  28. package/plugins/blog/tests/public.test.js +30 -1
  29. package/plugins/booking/CLAUDE.md +4 -1
  30. package/plugins/booking/docs/guide.md +1 -1
  31. package/plugins/booking/plugin.js +16 -21
  32. package/plugins/booking/plugin.json +2 -2
  33. package/plugins/booking/tests/api.test.js +26 -1
  34. package/plugins/feedback/CLAUDE.md +7 -2
  35. package/plugins/feedback/admin/views/feedback.js +1 -1
  36. package/plugins/feedback/lib/receiver.js +1 -1
  37. package/plugins/feedback/plugin.json +3 -3
  38. package/plugins/free-tier.lock.json +42 -41
  39. package/plugins/mail-reader/CLAUDE.md +6 -0
  40. package/plugins/mail-reader/docs/guide.md +1 -0
  41. package/plugins/mail-reader/plugin.json +2 -2
  42. package/plugins/shopping-cart/CLAUDE.md +17 -1
  43. package/plugins/shopping-cart/docs/guide.md +1 -1
  44. package/plugins/shopping-cart/lib/accounts.js +8 -3
  45. package/plugins/shopping-cart/lib/payments.js +7 -4
  46. package/plugins/shopping-cart/lib/shop.js +8 -3
  47. package/plugins/shopping-cart/plugin.js +77 -41
  48. package/plugins/shopping-cart/plugin.json +2 -2
  49. package/plugins/shopping-cart/public/account.js +2 -2
  50. package/plugins/shopping-cart/tests/accounts.test.js +10 -1
  51. package/plugins/shopping-cart/tests/api.test.js +23 -0
  52. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  53. package/plugins/support/CLAUDE.md +6 -0
  54. package/plugins/support/admin/views/support.js +1 -1
  55. package/plugins/support/docs/guide.md +90 -0
  56. package/plugins/support/lib/receiver.js +1 -1
  57. package/plugins/support/plugin.json +3 -3
  58. package/server/routes/api/analytics.js +32 -0
  59. package/server/routes/api/collections.js +148 -15
  60. package/server/routes/outreach-public.js +54 -10
  61. package/server/server.js +5 -1
  62. package/server/services/authChecks.js +8 -0
  63. package/server/services/collectionAccess.js +191 -0
  64. package/server/services/collections.js +8 -2
  65. package/server/services/request-log.js +70 -0
@@ -64,9 +64,73 @@ import {hooks} from '../../services/hooks.js';
64
64
  import {authenticateApiToken, scopeAllows} from '../../services/apiTokens.js';
65
65
  import {isProjectEnabled, resolveArtefactProject} from '../../services/projects.js';
66
66
  import {maskConnections, restoreConnections} from '../../services/connectionSecrets.js';
67
+ import {
68
+ callerId,
69
+ isRoot,
70
+ isUserDataPreset,
71
+ mayReadRow,
72
+ mayWriteRow,
73
+ ownRowsFilter,
74
+ pinOwner,
75
+ pluginDenial
76
+ } from '../../services/collectionAccess.js';
67
77
 
68
78
  const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
69
79
 
80
+ /**
81
+ * The generic routes are open to anyone holding `collections`, which every
82
+ * level-1 admin does. Two kinds of collection need more than that (see
83
+ * services/collectionAccess.js): a plugin's collection needs that plugin's own
84
+ * permission, and the Tools' user-data presets (notes, tasks, contacts) are
85
+ * kept to the caller's own rows. Answers the schema, or sends 404/403 and
86
+ * answers null - the caller returns at once.
87
+ *
88
+ * @param {object} request
89
+ * @param {object} reply
90
+ * @returns {Promise<object|null>}
91
+ */
92
+ async function admitted(request, reply) {
93
+ const schema = await getCollection(request.params.slug);
94
+ if (!schema) {
95
+ reply.status(404).send({ error: 'Collection not found' });
96
+ return null;
97
+ }
98
+ const denial = pluginDenial(schema, request.user);
99
+ if (denial) {
100
+ reply.status(403).send({ error: denial });
101
+ return null;
102
+ }
103
+ return schema;
104
+ }
105
+
106
+ /**
107
+ * One row of a user-data preset the caller may read (or, with `write`,
108
+ * change): the row, or null after a 404 (no such row) or 403 (not theirs).
109
+ * Any other collection's rows pass as they are.
110
+ *
111
+ * @param {object} request
112
+ * @param {object} reply
113
+ * @param {{write?: boolean}} [opts]
114
+ * @returns {Promise<object|null>}
115
+ */
116
+ async function ownRow(request, reply, {write = false} = {}) {
117
+ const {slug, id} = request.params;
118
+ const entry = await getEntry(slug, id);
119
+ if (!entry) {
120
+ reply.status(404).send({ error: 'Entry not found' });
121
+ return null;
122
+ }
123
+ const allowed = write ? mayWriteRow(slug, entry, request.user) : mayReadRow(slug, entry, request.user);
124
+ if (!allowed) {
125
+ reply.status(403).send({ error: NOT_YOUR_ROW });
126
+ return null;
127
+ }
128
+ return entry;
129
+ }
130
+
131
+ const NOT_YOUR_ROW = 'This entry belongs to another user';
132
+ const NOT_YOUR_ROWS = 'Only the site owner can do this to everyone\'s entries - the Tool works on your own';
133
+
70
134
  /**
71
135
  * The sidebar badge on Collections (GET /collections/_count): how many
72
136
  * collections this user sees and how many entries they hold, with where they
@@ -364,6 +428,9 @@ export async function collectionsRoutes(fastify) {
364
428
  });
365
429
 
366
430
  fastify.put('/collections/:slug', canUpdate, async (request, reply) => {
431
+ // A plugin's schema is the plugin's: without this, opening `api.read` to
432
+ // the public is one PUT away from every row the entry routes refuse.
433
+ if (!(await admitted(request, reply))) return;
367
434
  try {
368
435
  return await updateCollection(request.params.slug, request.body || {});
369
436
  } catch (err) {
@@ -375,6 +442,7 @@ export async function collectionsRoutes(fastify) {
375
442
  if (ALL_PRESET_SLUGS.has(request.params.slug)) {
376
443
  return reply.status(403).send({ error: 'Cannot delete a preset collection' });
377
444
  }
445
+ if (!(await admitted(request, reply))) return;
378
446
  try {
379
447
  await deleteCollection(request.params.slug);
380
448
  return { success: true };
@@ -404,10 +472,11 @@ export async function collectionsRoutes(fastify) {
404
472
  * adding the `qs` dependency for one route.
405
473
  */
406
474
  fastify.get('/collections/:slug/entries', canRead, async (request, reply) => {
407
- const schema = await getCollection(request.params.slug);
408
- if (!schema) return reply.status(404).send({ error: 'Collection not found' });
475
+ if (!(await admitted(request, reply))) return;
409
476
  const { page, limit, sort, order, search } = request.query;
410
- const filter = extractBracketed(request.query, 'filter');
477
+ // The caller's own rows of a user-data preset - after their filter, so
478
+ // `filter[userId]=someone-else` cannot widen it.
479
+ const filter = {...extractBracketed(request.query, 'filter'), ...ownRowsFilter(request.params.slug, request.user)};
411
480
  const result = await listEntries(request.params.slug, {
412
481
  page: parseInt(page, 10) || 1,
413
482
  limit: parseInt(limit, 10) || 50,
@@ -423,15 +492,18 @@ export async function collectionsRoutes(fastify) {
423
492
  });
424
493
 
425
494
  fastify.get('/collections/:slug/entries/:id', canRead, async (request, reply) => {
426
- const entry = await getEntry(request.params.slug, request.params.id);
427
- if (!entry) return reply.status(404).send({ error: 'Entry not found' });
495
+ if (!(await admitted(request, reply))) return;
496
+ const entry = await ownRow(request, reply);
497
+ if (!entry) return;
428
498
  return request.params.slug === 'api-tokens' ? redactTokenHash(entry) : entry;
429
499
  });
430
500
 
431
501
  fastify.post('/collections/:slug/entries', canCreate, async (request, reply) => {
502
+ if (!(await admitted(request, reply))) return;
432
503
  const user = request.user;
433
504
  try {
434
- let data = request.body?.data || {};
505
+ // A note, task or contact made here is the caller's, whatever userId was sent.
506
+ let data = pinOwner(request.params.slug, request.body?.data || {}, user);
435
507
  // A role created in the admin belongs to no plugin; see keepRoleSystemFields.
436
508
  if (request.params.slug === 'roles') {
437
509
  data = {...data};
@@ -468,9 +540,10 @@ export async function collectionsRoutes(fastify) {
468
540
  ? request.body.field.trim()
469
541
  : 'spam';
470
542
  const value = request.body?.spam === false ? false : true;
543
+ if (!(await admitted(request, reply))) return;
471
544
  try {
472
- const existing = await getEntry(slug, id);
473
- if (!existing) return reply.code(404).send({error: 'Entry not found'});
545
+ const existing = await ownRow(request, reply, {write: true});
546
+ if (!existing) return;
474
547
 
475
548
  const entry = await updateEntry(slug, id, {...(existing.data || {}), [field]: value});
476
549
  hooks.emit('collection:entryUpdated', {
@@ -484,8 +557,15 @@ export async function collectionsRoutes(fastify) {
484
557
  });
485
558
 
486
559
  fastify.put('/collections/:slug/entries/:id', canUpdate, async (request, reply) => {
560
+ if (!(await admitted(request, reply))) return;
561
+ let existing = null;
562
+ if (isUserDataPreset(request.params.slug)) {
563
+ existing = await ownRow(request, reply, {write: true});
564
+ if (!existing) return;
565
+ }
487
566
  try {
488
- let data = request.body?.data || {};
567
+ // A row of a user-data preset keeps its owner - a save cannot hand it to someone else.
568
+ let data = pinOwner(request.params.slug, request.body?.data || {}, request.user, existing);
489
569
  if (request.params.slug === 'roles') data = await keepRoleSystemFields(request.params.id, data);
490
570
  const entry = await updateEntry(request.params.slug, request.params.id, data);
491
571
  if (request.params.slug === 'roles') await invalidateRoles();
@@ -498,6 +578,8 @@ export async function collectionsRoutes(fastify) {
498
578
  });
499
579
 
500
580
  fastify.delete('/collections/:slug/entries/:id', canDelete, async (request, reply) => {
581
+ if (!(await admitted(request, reply))) return;
582
+ if (isUserDataPreset(request.params.slug) && !(await ownRow(request, reply, {write: true}))) return;
501
583
  if (request.params.slug === 'roles') {
502
584
  const entry = await getEntry('roles', request.params.id);
503
585
  if (entry?.data?.level === 0) {
@@ -525,6 +607,11 @@ export async function collectionsRoutes(fastify) {
525
607
  if (request.params.slug === 'roles') {
526
608
  return reply.status(403).send({ error: 'Roles cannot be cleared - delete them one at a time' });
527
609
  }
610
+ if (!(await admitted(request, reply))) return;
611
+ // Clearing wipes everyone's rows, so for a user-data preset only root may.
612
+ if (isUserDataPreset(request.params.slug) && !isRoot(request.user)) {
613
+ return reply.status(403).send({ error: NOT_YOUR_ROWS });
614
+ }
528
615
  try {
529
616
  await clearEntries(request.params.slug);
530
617
  return { success: true };
@@ -539,6 +626,7 @@ export async function collectionsRoutes(fastify) {
539
626
 
540
627
  // Entries keep their ids and meta dates; see migrateStorage() in services/collections.js.
541
628
  fastify.post('/collections/:slug/migrate-storage', canUpdate, async (request, reply) => {
629
+ if (!(await admitted(request, reply))) return;
542
630
  try {
543
631
  return await migrateStorage(request.params.slug, request.body?.storage);
544
632
  } catch (err) {
@@ -553,9 +641,11 @@ export async function collectionsRoutes(fastify) {
553
641
  // -------------------------------------------------------------------------
554
642
 
555
643
  fastify.get('/collections/:slug/export', canRead, async (request, reply) => {
644
+ if (!(await admitted(request, reply))) return;
556
645
  const format = request.query.format === 'csv' ? 'csv' : 'json';
557
646
  try {
558
- const output = await exportEntries(request.params.slug, format);
647
+ // A user-data preset exports the caller's own rows, as the Tool would.
648
+ const output = await exportEntries(request.params.slug, format, {filter: ownRowsFilter(request.params.slug, request.user)});
559
649
  if (format === 'csv') {
560
650
  reply.header('Content-Type', 'text/csv');
561
651
  reply.header('Content-Disposition', `attachment; filename="${request.params.slug}-entries.csv"`);
@@ -573,8 +663,13 @@ export async function collectionsRoutes(fastify) {
573
663
  const user = request.user;
574
664
  const entries = request.body?.entries;
575
665
  if (!Array.isArray(entries)) return reply.status(400).send({ error: 'entries must be an array' });
666
+ if (!(await admitted(request, reply))) return;
576
667
  try {
577
- const result = await importEntries(request.params.slug, entries, { createdBy: user?.id || null });
668
+ // Imported notes, tasks or contacts are the caller's own, whatever userId the file carries.
669
+ const own = isUserDataPreset(request.params.slug)
670
+ ? entries.map(item => ({data: pinOwner(request.params.slug, (item?.data && typeof item.data === 'object') ? item.data : (item || {}), user)}))
671
+ : entries;
672
+ const result = await importEntries(request.params.slug, own, { createdBy: user?.id || null });
578
673
  return reply.status(201).send(result);
579
674
  } catch (err) {
580
675
  return reply.status(400).send({ error: err.message });
@@ -624,6 +719,7 @@ export async function collectionsRoutes(fastify) {
624
719
  const denied = await checkPublicAccess(schema, 'read', request, reply);
625
720
  if (denied !== undefined) return;
626
721
  }
722
+ if (nobodysRows(request, reply, filter)) return;
627
723
 
628
724
  const result = await listEntries(request.params.slug, {
629
725
  page: parseInt(page, 10) || 1,
@@ -637,15 +733,38 @@ export async function collectionsRoutes(fastify) {
637
733
  return applyReadFieldAllowlist(schema, result);
638
734
  }
639
735
 
736
+ /**
737
+ * The public surface of a user-data preset (its `api` block says admins may
738
+ * read) still shows people only their own rows: the filter joins the
739
+ * caller's, and a caller with no identity - a visitor, or a project token -
740
+ * has no rows here. Sends 403 and answers true when that is the case.
741
+ *
742
+ * @param {object} request
743
+ * @param {object} reply
744
+ * @param {object} [filter] - the list filter to narrow, in place
745
+ * @returns {boolean}
746
+ */
747
+ function nobodysRows(request, reply, filter) {
748
+ const slug = request.params.slug;
749
+ if (!isUserDataPreset(slug) || isRoot(request.user)) return false;
750
+ if (!callerId(request.user)) {
751
+ reply.status(403).send({ error: NOT_YOUR_ROW });
752
+ return true;
753
+ }
754
+ if (filter) Object.assign(filter, ownRowsFilter(slug, request.user));
755
+ return false;
756
+ }
757
+
640
758
  async function publicGetEntry(request, reply) {
641
759
  const schema = await getCollection(request.params.slug);
642
760
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
643
761
 
644
762
  const denied = await checkPublicAccess(schema, 'read', request, reply);
645
763
  if (denied !== undefined) return;
764
+ if (nobodysRows(request, reply)) return;
646
765
 
647
- const entry = await getEntry(request.params.slug, request.params.id);
648
- if (!entry) return reply.status(404).send({ error: 'Entry not found' });
766
+ const entry = await ownRow(request, reply);
767
+ if (!entry) return;
649
768
  return applyReadFieldAllowlist(schema, entry);
650
769
  }
651
770
 
@@ -714,6 +833,10 @@ export async function collectionsRoutes(fastify) {
714
833
 
715
834
  const denied = await checkPublicAccess(schema, 'read', request, reply);
716
835
  if (denied !== undefined) return;
836
+ // A fragment renders every row it is given; a user-data preset has no "everyone's rows" for anyone but root.
837
+ if (isUserDataPreset(attrs.slug) && !isRoot(request.user)) {
838
+ return reply.status(403).send({ error: NOT_YOUR_ROWS });
839
+ }
717
840
 
718
841
  const { renderCollectionFragment } = await import('../../services/markdown.js');
719
842
  const html = await renderCollectionFragment(attrs);
@@ -763,9 +886,10 @@ export async function collectionsRoutes(fastify) {
763
886
 
764
887
  const denied = await checkPublicAccess(schema, 'create', request, reply);
765
888
  if (denied !== undefined) return;
889
+ if (nobodysRows(request, reply)) return;
766
890
 
767
891
  try {
768
- let data = request.body?.data || {};
892
+ let data = pinOwner(request.params.slug, request.body?.data || {}, request.user);
769
893
  // A role created in the admin belongs to no plugin; see keepRoleSystemFields.
770
894
  if (request.params.slug === 'roles') {
771
895
  data = {...data};
@@ -787,9 +911,16 @@ export async function collectionsRoutes(fastify) {
787
911
 
788
912
  const denied = await checkPublicAccess(schema, 'update', request, reply);
789
913
  if (denied !== undefined) return;
914
+ if (nobodysRows(request, reply)) return;
915
+ let existing = null;
916
+ if (isUserDataPreset(request.params.slug)) {
917
+ existing = await ownRow(request, reply, {write: true});
918
+ if (!existing) return;
919
+ }
790
920
 
791
921
  try {
792
- return await updateEntry(request.params.slug, request.params.id, request.body?.data || {});
922
+ const data = pinOwner(request.params.slug, request.body?.data || {}, request.user, existing);
923
+ return await updateEntry(request.params.slug, request.params.id, data);
793
924
  } catch (err) {
794
925
  const status = err.message === 'Entry not found' ? 404 : 400;
795
926
  return reply.status(status).send({ error: err.message });
@@ -802,6 +933,8 @@ export async function collectionsRoutes(fastify) {
802
933
 
803
934
  const denied = await checkPublicAccess(schema, 'delete', request, reply);
804
935
  if (denied !== undefined) return;
936
+ if (nobodysRows(request, reply)) return;
937
+ if (isUserDataPreset(request.params.slug) && !(await ownRow(request, reply, {write: true}))) return;
805
938
 
806
939
  try {
807
940
  await deleteEntry(request.params.slug, request.params.id);
@@ -1,13 +1,22 @@
1
1
  /**
2
- * The one-click unsubscribe page every outreach email links to:
2
+ * The unsubscribe page every outreach email links to:
3
3
  *
4
- * GET /campaign-unsubscribe?e=<base64url address>&k=<HMAC>
4
+ * GET /campaign-unsubscribe?e=<base64url address>&k=<HMAC> a confirm page
5
+ * POST /campaign-unsubscribe (e, k as form fields or query) the opt-out
5
6
  *
6
7
  * Core's, not the sending Tool's, so the link in an email already sent keeps
7
8
  * working when that Tool lapses or is removed (PECR). The address goes on the
8
9
  * shared suppression list (`outreach-unsubscribes`), which every outreach
9
10
  * Tool checks before it sends.
10
11
  *
12
+ * Only the POST changes anything. Mail security scanners (Safe Links,
13
+ * Mimecast, Proofpoint) and mailbox previews GET every link in a message, so a
14
+ * GET that opted out unsubscribed people who never clicked - and silenced
15
+ * Surveys and Onboard for them too, the list being shared. The GET shows one
16
+ * button; the POST is also what a mail client's own Unsubscribe button sends
17
+ * (RFC 8058 one-click: the List-Unsubscribe URL with the body
18
+ * `List-Unsubscribe=One-Click`), so both land on the same handler.
19
+ *
11
20
  * A Campaigns Pro older than this engine registers the same path itself, and
12
21
  * plugins register before core: then core stands aside rather than collide.
13
22
  *
@@ -19,6 +28,7 @@ import {UNSUBSCRIBE_PATH, verifyUnsubscribe} from '../../plugins/_lib/outreach/u
19
28
  import {getUnsubscribeSecret} from '../services/outreachUnsubscribe.js';
20
29
 
21
30
  const SLUG = 'outreach-unsubscribes';
31
+ const FORM_TYPE = 'application/x-www-form-urlencoded';
22
32
 
23
33
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
34
 
@@ -26,6 +36,15 @@ function page(title, bodyHtml) {
26
36
  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
37
  }
28
38
 
39
+ const badLink = () => page('Unsubscribe', '<h1>That link is not right.</h1><p>Use the unsubscribe link from one of our emails.</p>');
40
+
41
+ /** The confirm page: the same e and k, posted back by one button. */
42
+ function confirmPage(email, e, k) {
43
+ return page('Unsubscribe', `<h1>Unsubscribe?</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`
44
+ + `<form method="post" action="${UNSUBSCRIBE_PATH}"><input type="hidden" name="e" value="${esc(e)}"><input type="hidden" name="k" value="${esc(k)}">`
45
+ + `<button type="submit" class="btn btn-primary">Unsubscribe</button></form>`);
46
+ }
47
+
29
48
  /** The suppression list's collection - made if no outreach Tool ever made it. */
30
49
  async function ensureList() {
31
50
  try {
@@ -43,6 +62,14 @@ async function ensureList() {
43
62
  }
44
63
  }
45
64
 
65
+ /** The e and k of a request: the posted form first, else the URL (a one-click POST keeps them there). */
66
+ export function linkParams(req) {
67
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
68
+ const query = req.query || {};
69
+ const pick = (v) => (typeof v === 'string' ? v : '');
70
+ return {e: pick(body.e) || pick(query.e), k: pick(body.k) || pick(query.k)};
71
+ }
72
+
46
73
  /**
47
74
  * @param {import('fastify').FastifyInstance} fastify
48
75
  * @param {{col?: object, secret?: string}} [opts] - tests inject a collections service and a secret
@@ -52,17 +79,34 @@ export async function outreachPublicRoutes(fastify, opts = {}) {
52
79
  const col = opts.col || {createEntry, deleteEntry, listEntries};
53
80
  const suppression = createSuppression(col);
54
81
 
55
- fastify.get(UNSUBSCRIBE_PATH, async (req, reply) => {
56
- reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer');
82
+ // The engine parses JSON only; the confirm form and one-click POSTs are
83
+ // form-encoded. Scoped to these routes, and tiny: two short fields.
84
+ if (!fastify.hasContentTypeParser(FORM_TYPE)) {
85
+ fastify.addContentTypeParser(FORM_TYPE, {parseAs: 'string', bodyLimit: 4096}, (req, body, done) => {
86
+ try { done(null, Object.fromEntries(new URLSearchParams(body))); } catch (err) { done(err); }
87
+ });
88
+ }
89
+
90
+ const verified = (req) => {
91
+ const {e, k} = linkParams(req);
57
92
  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
- }
93
+ try { email = verifyUnsubscribe(opts.secret || getUnsubscribeSecret(), e, k); } catch { email = null; }
94
+ return {email, e, k};
95
+ };
96
+ const html = (reply) => reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer').type('text/html; charset=utf-8');
97
+
98
+ fastify.get(UNSUBSCRIBE_PATH, async (req, reply) => {
99
+ const {email, e, k} = verified(req);
100
+ if (!email) return html(reply).code(400).send(badLink());
101
+ return html(reply).send(confirmPage(email, e, k));
102
+ });
103
+
104
+ fastify.post(UNSUBSCRIBE_PATH, async (req, reply) => {
105
+ const {email} = verified(req);
106
+ if (!email) return html(reply).code(400).send(badLink());
63
107
  if (!opts.col) await ensureList();
64
108
  await suppression.add(email, 'unsubscribe-link');
65
- return reply.type('text/html; charset=utf-8').send(page('Unsubscribed',
109
+ return html(reply).send(page('Unsubscribed',
66
110
  `<h1>You are unsubscribed.</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`));
67
111
  });
68
112
  }
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
@@ -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 = [