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.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -0
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +15 -6
- 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/compose.js +7 -2
- package/plugins/_lib/mail/defaults.js +12 -0
- package/plugins/_lib/mail/hostGuard.js +86 -0
- package/plugins/_lib/mail/message-action.js +56 -0
- package/plugins/_lib/mail/readRoutes.js +20 -1
- package/plugins/_lib/mail/scheduler.js +2 -0
- package/plugins/_lib/mail/send.js +12 -3
- package/plugins/_lib/mail/store.js +16 -6
- package/plugins/_lib/outreach/index.js +1 -1
- package/plugins/_lib/outreach/ledger.js +10 -3
- package/plugins/_lib/outreach/sending.js +9 -3
- package/plugins/_lib/outreach/suppression.js +14 -3
- package/plugins/_lib/outreach/unsubscribe.js +13 -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 +42 -41
- 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 +17 -1
- package/plugins/shopping-cart/docs/guide.md +1 -1
- package/plugins/shopping-cart/lib/accounts.js +8 -3
- package/plugins/shopping-cart/lib/payments.js +7 -4
- package/plugins/shopping-cart/lib/shop.js +8 -3
- package/plugins/shopping-cart/plugin.js +77 -41
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/public/account.js +2 -2
- package/plugins/shopping-cart/tests/accounts.test.js +10 -1
- package/plugins/shopping-cart/tests/api.test.js +23 -0
- package/plugins/shopping-cart/tests/pure.test.js +16 -1
- 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/server/routes/api/analytics.js +32 -0
- package/server/routes/api/collections.js +148 -15
- package/server/routes/outreach-public.js +54 -10
- package/server/server.js +5 -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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
|
473
|
-
if (!existing) return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
648
|
-
if (!entry) return
|
|
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
|
-
|
|
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
|
|
2
|
+
* The unsubscribe page every outreach email links to:
|
|
3
3
|
*
|
|
4
|
-
* GET
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
56
|
-
|
|
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(),
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|