domma-cms 0.55.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -359,6 +359,40 @@ export async function collectionsRoutes(fastify) {
|
|
|
359
359
|
}
|
|
360
360
|
});
|
|
361
361
|
|
|
362
|
+
/*
|
|
363
|
+
* Flag one entry as spam without disturbing the rest of it.
|
|
364
|
+
*
|
|
365
|
+
* `updateEntry` REPLACES `data` wholesale, so the obvious client-side PUT
|
|
366
|
+
* of {spam: true} would wipe every other field on the record. The merge
|
|
367
|
+
* has to happen server-side, which is the whole reason this endpoint
|
|
368
|
+
* exists rather than the context menu calling the generic update route.
|
|
369
|
+
*
|
|
370
|
+
* The field defaults to `spam` and need not be declared in the schema -
|
|
371
|
+
* validateEntryData only checks required fields, so an undeclared key is
|
|
372
|
+
* carried through untouched. Guarded by the same collections.update
|
|
373
|
+
* permission as any other write, so a public visitor cannot reach it.
|
|
374
|
+
*/
|
|
375
|
+
fastify.post('/collections/:slug/entries/:id/spam', canUpdate, async (request, reply) => {
|
|
376
|
+
const {slug, id} = request.params;
|
|
377
|
+
const field = typeof request.body?.field === 'string' && request.body.field.trim()
|
|
378
|
+
? request.body.field.trim()
|
|
379
|
+
: 'spam';
|
|
380
|
+
const value = request.body?.spam === false ? false : true;
|
|
381
|
+
try {
|
|
382
|
+
const existing = await getEntry(slug, id);
|
|
383
|
+
if (!existing) return reply.code(404).send({error: 'Entry not found'});
|
|
384
|
+
|
|
385
|
+
const entry = await updateEntry(slug, id, {...(existing.data || {}), [field]: value});
|
|
386
|
+
hooks.emit('collection:entryUpdated', {
|
|
387
|
+
slug, entryId: entry.id, data: entry.data, userId: request.user?.id || null
|
|
388
|
+
});
|
|
389
|
+
return {entry, field, spam: value};
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const code = /not found/i.test(err.message) ? 404 : 400;
|
|
392
|
+
return reply.code(code).send({error: err.message});
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
362
396
|
fastify.put('/collections/:slug/entries/:id', canUpdate, async (request, reply) => {
|
|
363
397
|
try {
|
|
364
398
|
const entry = await updateEntry(request.params.slug, request.params.id, request.body?.data || {});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context menus API
|
|
3
|
+
*
|
|
4
|
+
* GET /api/context-menus - list (metadata only)
|
|
5
|
+
* GET /api/context-menus/:slug - full menu
|
|
6
|
+
* POST /api/context-menus - create
|
|
7
|
+
* PUT /api/context-menus/:slug - replace
|
|
8
|
+
* DELETE /api/context-menus/:slug - delete
|
|
9
|
+
* POST /api/context-menus/:slug/duplicate - copy as <slug>-copy
|
|
10
|
+
*
|
|
11
|
+
* Auth middlewares are accepted as options so tests can supply no-ops without
|
|
12
|
+
* needing @fastify/jwt and a seeded roles cache - the same DI pattern as
|
|
13
|
+
* menus.js and projects.js.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
authenticate as defaultAuthenticate,
|
|
17
|
+
requirePermission as defaultRequirePermission
|
|
18
|
+
} from '../../middleware/auth.js';
|
|
19
|
+
import {
|
|
20
|
+
ACTION_TYPES,
|
|
21
|
+
createContextMenu,
|
|
22
|
+
deleteContextMenu,
|
|
23
|
+
duplicateContextMenu,
|
|
24
|
+
getContextMenu,
|
|
25
|
+
listContextMenus,
|
|
26
|
+
updateContextMenu
|
|
27
|
+
} from '../../services/contextMenus.js';
|
|
28
|
+
|
|
29
|
+
export async function contextMenusRoutes(fastify, opts = {}) {
|
|
30
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
31
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
32
|
+
|
|
33
|
+
const canRead = {preHandler: [authenticate, requirePermission('context-menus', 'read')]};
|
|
34
|
+
const canCreate = {preHandler: [authenticate, requirePermission('context-menus', 'create')]};
|
|
35
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('context-menus', 'update')]};
|
|
36
|
+
const canDelete = {preHandler: [authenticate, requirePermission('context-menus', 'delete')]};
|
|
37
|
+
|
|
38
|
+
// The editor builds its action picker from this rather than hardcoding a
|
|
39
|
+
// list that would drift from the server's validation.
|
|
40
|
+
fastify.get('/context-menus/action-types', canRead, async () => [...ACTION_TYPES]);
|
|
41
|
+
|
|
42
|
+
fastify.get('/context-menus', canRead, async (request) => {
|
|
43
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
44
|
+
const all = await listContextMenus();
|
|
45
|
+
return all.filter((m) => canSeeArtefact(request.user, m));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
fastify.get('/context-menus/:slug', canRead, async (request, reply) => {
|
|
49
|
+
const menu = await getContextMenu(request.params.slug);
|
|
50
|
+
if (!menu) return reply.code(404).send({error: 'Context menu not found'});
|
|
51
|
+
|
|
52
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
53
|
+
if (!canSeeArtefact(request.user, menu)) {
|
|
54
|
+
return reply.code(403).send({error: 'Outside your project scope'});
|
|
55
|
+
}
|
|
56
|
+
return menu;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
fastify.post('/context-menus', canCreate, async (request, reply) => {
|
|
60
|
+
try {
|
|
61
|
+
return await createContextMenu(request.body || {});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Validation and collision are both the caller's fault, and the
|
|
64
|
+
// message already names the field - passing it through is what
|
|
65
|
+
// makes the editor able to show it.
|
|
66
|
+
return reply.code(400).send({error: err.message});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
fastify.put('/context-menus/:slug', canUpdate, async (request, reply) => {
|
|
71
|
+
const existing = await getContextMenu(request.params.slug);
|
|
72
|
+
if (!existing) return reply.code(404).send({error: 'Context menu not found'});
|
|
73
|
+
|
|
74
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
75
|
+
if (!canSeeArtefact(request.user, existing)) {
|
|
76
|
+
return reply.code(403).send({error: 'Outside your project scope'});
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return await updateContextMenu(request.params.slug, request.body || {});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return reply.code(400).send({error: err.message});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
fastify.delete('/context-menus/:slug', canDelete, async (request, reply) => {
|
|
86
|
+
const existing = await getContextMenu(request.params.slug);
|
|
87
|
+
if (!existing) return reply.code(404).send({error: 'Context menu not found'});
|
|
88
|
+
|
|
89
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
90
|
+
if (!canSeeArtefact(request.user, existing)) {
|
|
91
|
+
return reply.code(403).send({error: 'Outside your project scope'});
|
|
92
|
+
}
|
|
93
|
+
await deleteContextMenu(request.params.slug);
|
|
94
|
+
return {deleted: true};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
fastify.post('/context-menus/:slug/duplicate', canCreate, async (request, reply) => {
|
|
98
|
+
try {
|
|
99
|
+
return await duplicateContextMenu(request.params.slug);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return reply.code(400).send({error: err.message});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -578,6 +578,24 @@ export async function formsRoutes(fastify) {
|
|
|
578
578
|
formValues[field.name] = val !== undefined ? (typeof val === 'string' ? val.trim() : val) : '';
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
// Triggers, from the same engine the browser ran. A block-submit that
|
|
582
|
+
// only existed in the browser would be a suggestion: anyone can re-enable
|
|
583
|
+
// a button. Resolved BEFORE the field pass because a trigger decides
|
|
584
|
+
// which fields are in play at all.
|
|
585
|
+
const triggerState = FormLogicEngine.resolveTriggerState(form.fields || [], formValues);
|
|
586
|
+
|
|
587
|
+
if (triggerState.blocks.length) {
|
|
588
|
+
return reply.status(400).send({
|
|
589
|
+
error: triggerState.blocks.map(b => b.message).join(' ')
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Hidden and disabled are the same thing to a submission: the visitor
|
|
594
|
+
// was given no way to answer, so the answer is not theirs and is not
|
|
595
|
+
// required. A value posted for one anyway is dropped below.
|
|
596
|
+
const triggerOff = new Set([...triggerState.hidden, ...triggerState.disabled]);
|
|
597
|
+
const triggerRequired = new Set(triggerState.required);
|
|
598
|
+
|
|
581
599
|
// Evaluate conditional logic
|
|
582
600
|
const missingFields = [];
|
|
583
601
|
const validationErrors = [];
|
|
@@ -587,11 +605,13 @@ export async function formsRoutes(fastify) {
|
|
|
587
605
|
if (field.type === 'page-break' || field.type === 'spacer') continue;
|
|
588
606
|
const vis = FormLogicEngine.evaluateFieldVisibility(field, formValues);
|
|
589
607
|
if (vis === 'hidden') continue;
|
|
608
|
+
if (triggerOff.has(field.name)) continue;
|
|
590
609
|
visibleFieldNames.add(field.name);
|
|
591
610
|
|
|
592
611
|
const value = formValues[field.name];
|
|
593
612
|
const isEmpty = !value?.toString().trim();
|
|
594
|
-
const required = FormLogicEngine.evaluateFieldRequirement(field, formValues)
|
|
613
|
+
const required = FormLogicEngine.evaluateFieldRequirement(field, formValues)
|
|
614
|
+
|| triggerRequired.has(field.name);
|
|
595
615
|
if (required && isEmpty) {
|
|
596
616
|
missingFields.push(field.label || field.name);
|
|
597
617
|
}
|
|
@@ -719,10 +739,29 @@ export async function formsRoutes(fastify) {
|
|
|
719
739
|
}
|
|
720
740
|
}
|
|
721
741
|
|
|
742
|
+
// Trigger events the server owns. Fired after the entry is stored, for
|
|
743
|
+
// the same reason the other actions are: a failed side effect must not
|
|
744
|
+
// cost the visitor their submission.
|
|
745
|
+
let triggerRedirect = null;
|
|
746
|
+
for (const event of triggerState.events) {
|
|
747
|
+
if (event.type === 'run-action' && event.action.slug && entry) {
|
|
748
|
+
try {
|
|
749
|
+
await executeAction(event.action.slug, entry.id, {user: submittingUser});
|
|
750
|
+
} catch (err) {
|
|
751
|
+
fastify.log.warn(`[forms] Trigger action "${event.action.slug}" failed for form "${slug}": ${err.message}`);
|
|
752
|
+
}
|
|
753
|
+
} else if (event.type === 'redirect' && event.action.url && !triggerRedirect) {
|
|
754
|
+
// First one wins - two triggers redirecting at once is an
|
|
755
|
+
// authoring mistake, not a case to resolve cleverly.
|
|
756
|
+
triggerRedirect = String(event.action.url);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
722
760
|
hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
|
|
723
761
|
|
|
724
|
-
// Template interpolation for successRedirect
|
|
725
|
-
|
|
762
|
+
// Template interpolation for successRedirect. A trigger's redirect is
|
|
763
|
+
// the more specific instruction, so it wins over the form's default.
|
|
764
|
+
let redirect = triggerRedirect || settings.successRedirect || null;
|
|
726
765
|
if (redirect && entry?.id) {
|
|
727
766
|
redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
|
|
728
767
|
}
|
|
@@ -7,13 +7,63 @@
|
|
|
7
7
|
* POST /api/system/notifications/:id/dismiss - Mark dismissed
|
|
8
8
|
* DELETE /api/system/notifications/:id - Hard delete (admin only)
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
authenticate as defaultAuthenticate,
|
|
12
|
+
requirePermission as defaultRequirePermission
|
|
13
|
+
} from '../../middleware/auth.js';
|
|
11
14
|
import { requireManager } from '../../middleware/managerAuth.js';
|
|
12
15
|
import { getAdapter } from '../../services/adapterRegistry.js';
|
|
13
16
|
|
|
14
17
|
const SLUG = 'notifications';
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
/** Ids already warned about, so one bad record does not fill the log. */
|
|
20
|
+
const warnedMalformed = new Set();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A notification's payload, or null when the record is not a notification.
|
|
24
|
+
*
|
|
25
|
+
* Entries are `{id, data, meta}`, but this collection has been written to
|
|
26
|
+
* directly before now - a form once pointed its submissions at `notifications`
|
|
27
|
+
* and left flat records behind with no `data` at all. Reading `entry.data.x`
|
|
28
|
+
* off one of those threw, and because the bell polls on every admin page load,
|
|
29
|
+
* a single stray record turned into a 500 on every screen in the admin. One
|
|
30
|
+
* unreadable row is skipped; it is not the rest of the inbox's problem.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} entry
|
|
33
|
+
* @param {object} [log] - Fastify logger, when there is one to warn through.
|
|
34
|
+
* @returns {object|null}
|
|
35
|
+
*/
|
|
36
|
+
function notificationData(entry, log) {
|
|
37
|
+
const data = entry && entry.data;
|
|
38
|
+
if (data && typeof data === 'object') return data;
|
|
39
|
+
const id = entry?.id || '(no id)';
|
|
40
|
+
if (log && !warnedMalformed.has(id)) {
|
|
41
|
+
warnedMalformed.add(id);
|
|
42
|
+
log.warn(`[notifications] Skipping malformed entry "${id}" - no data object. `
|
|
43
|
+
+ 'Something wrote to the notifications collection directly.');
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Is this notification live for this user right now: not expired, not dismissed.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} data - A notification payload.
|
|
52
|
+
* @param {string} userId
|
|
53
|
+
* @param {string} now - ISO timestamp.
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isActiveFor(data, userId, now) {
|
|
57
|
+
if (data.expiresAt && data.expiresAt < now) return false;
|
|
58
|
+
return !(Array.isArray(data.dismissedBy) && data.dismissedBy.includes(userId));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function notificationsRoutes(fastify, opts = {}) {
|
|
62
|
+
// Injectable so tests can supply no-ops rather than a seeded roles cache -
|
|
63
|
+
// the same DI pattern as menus.js, projects.js and context-menus.js.
|
|
64
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
65
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
66
|
+
|
|
17
67
|
const canRead = { preHandler: [authenticate, requirePermission('notifications', 'read')] };
|
|
18
68
|
const canDelete = { preHandler: [authenticate, requirePermission('notifications', 'delete')] };
|
|
19
69
|
|
|
@@ -69,14 +119,12 @@ export async function notificationsRoutes(fastify) {
|
|
|
69
119
|
|
|
70
120
|
return all
|
|
71
121
|
.filter(entry => {
|
|
72
|
-
const d = entry.
|
|
73
|
-
|
|
74
|
-
if (d.expiresAt && d.expiresAt < now) return false;
|
|
75
|
-
// Exclude dismissed by this user
|
|
76
|
-
if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
|
|
77
|
-
return true;
|
|
122
|
+
const d = notificationData(entry, fastify.log);
|
|
123
|
+
return !!d && isActiveFor(d, userId, now);
|
|
78
124
|
})
|
|
79
|
-
|
|
125
|
+
// A record with no createdAt sorts last rather than taking the
|
|
126
|
+
// whole list down with it.
|
|
127
|
+
.sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || '')))
|
|
80
128
|
.map(entry => ({
|
|
81
129
|
...entry,
|
|
82
130
|
unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)
|
|
@@ -91,9 +139,8 @@ export async function notificationsRoutes(fastify) {
|
|
|
91
139
|
const all = await adapter.all(SLUG);
|
|
92
140
|
|
|
93
141
|
const count = all.filter(entry => {
|
|
94
|
-
const d = entry.
|
|
95
|
-
if (d
|
|
96
|
-
if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
|
|
142
|
+
const d = notificationData(entry, fastify.log);
|
|
143
|
+
if (!d || !isActiveFor(d, userId, now)) return false;
|
|
97
144
|
return !Array.isArray(d.readBy) || !d.readBy.includes(userId);
|
|
98
145
|
}).length;
|
|
99
146
|
|
|
@@ -107,13 +154,15 @@ export async function notificationsRoutes(fastify) {
|
|
|
107
154
|
const adapter = await getAdapter(SLUG);
|
|
108
155
|
const entry = await adapter.get(SLUG, id);
|
|
109
156
|
|
|
110
|
-
|
|
157
|
+
const data = notificationData(entry, fastify.log);
|
|
158
|
+
// A record the list never showed is not one this route can mark read.
|
|
159
|
+
if (!data) return reply.code(404).send({ error: 'Notification not found.' });
|
|
111
160
|
|
|
112
|
-
const readBy = Array.isArray(
|
|
161
|
+
const readBy = Array.isArray(data.readBy) ? data.readBy : [];
|
|
113
162
|
if (readBy.includes(userId)) return { ok: true }; // already read - idempotent
|
|
114
163
|
|
|
115
164
|
const updated = await adapter.update(SLUG, id, {
|
|
116
|
-
...
|
|
165
|
+
...data,
|
|
117
166
|
readBy: [...readBy, userId]
|
|
118
167
|
});
|
|
119
168
|
|
|
@@ -127,13 +176,14 @@ export async function notificationsRoutes(fastify) {
|
|
|
127
176
|
const adapter = await getAdapter(SLUG);
|
|
128
177
|
const entry = await adapter.get(SLUG, id);
|
|
129
178
|
|
|
130
|
-
|
|
179
|
+
const data = notificationData(entry, fastify.log);
|
|
180
|
+
if (!data) return reply.code(404).send({ error: 'Notification not found.' });
|
|
131
181
|
|
|
132
|
-
const dismissedBy = Array.isArray(
|
|
133
|
-
const readBy = Array.isArray(
|
|
182
|
+
const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
|
|
183
|
+
const readBy = Array.isArray(data.readBy) ? data.readBy : [];
|
|
134
184
|
|
|
135
185
|
const updated = await adapter.update(SLUG, id, {
|
|
136
|
-
...
|
|
186
|
+
...data,
|
|
137
187
|
dismissedBy: dismissedBy.includes(userId) ? dismissedBy : [...dismissedBy, userId],
|
|
138
188
|
readBy: readBy.includes(userId) ? readBy : [...readBy, userId] // dismissing also marks read
|
|
139
189
|
});
|
|
@@ -12,10 +12,14 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
discoverPlugins,
|
|
14
14
|
getAdminPluginConfig,
|
|
15
|
+
getLoadedPlugins,
|
|
15
16
|
getPluginStates,
|
|
17
|
+
isCorePlugin,
|
|
18
|
+
pluginLicence,
|
|
16
19
|
runLifecycleHook,
|
|
17
20
|
savePluginState
|
|
18
21
|
} from '../../services/plugins.js';
|
|
22
|
+
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
19
23
|
import {scaffoldPlugin} from '../../services/pluginScaffold.js';
|
|
20
24
|
import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
|
|
21
25
|
import {getConfig} from '../../config.js';
|
|
@@ -78,9 +82,10 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
78
82
|
const canDevelop = { preHandler: [authenticate, requirePermission('plugins', 'develop')] };
|
|
79
83
|
|
|
80
84
|
// List all plugins with their current state
|
|
81
|
-
fastify.get('/plugins', canRead, async () => {
|
|
85
|
+
fastify.get('/plugins', canRead, async (request) => {
|
|
82
86
|
const manifests = await discoverPlugins();
|
|
83
87
|
const states = getPluginStates();
|
|
88
|
+
const loadedPlugins = getLoadedPlugins();
|
|
84
89
|
|
|
85
90
|
return manifests.map(manifest => ({
|
|
86
91
|
name: manifest.name,
|
|
@@ -90,8 +95,25 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
90
95
|
author: manifest.author || '',
|
|
91
96
|
date: manifest.date || '',
|
|
92
97
|
icon: manifest.icon || 'package',
|
|
93
|
-
|
|
98
|
+
// A core plugin runs whatever plugins.json says, so reporting its
|
|
99
|
+
// stored flag would show "Disabled" next to a feature that is
|
|
100
|
+
// plainly running. It is on because it is built in.
|
|
101
|
+
core: isCorePlugin(manifest.name),
|
|
102
|
+
enabled: isCorePlugin(manifest.name) || !!(states[manifest.name]?.enabled),
|
|
94
103
|
bundled: !!(states[manifest.name]?.bundled),
|
|
104
|
+
// A licensed plugin is distributed to run, not to be read: the
|
|
105
|
+
// source endpoints refuse it (pluginFiles.isClosedSource). Sent so
|
|
106
|
+
// the screen can leave the Code button off rather than offer one
|
|
107
|
+
// that answers 403 - the refusal is the guard, not the UI.
|
|
108
|
+
closedSource: manifest.closedSource === true,
|
|
109
|
+
licence: pluginLicence(manifest),
|
|
110
|
+
// Whether the CODE button belongs on this viewer's card. Decided
|
|
111
|
+
// here rather than in the browser: it is the same answer the file
|
|
112
|
+
// endpoints will give, from the same two facts.
|
|
113
|
+
sourceEditable: manifest.closedSource !== true || mayEditLicensed(request),
|
|
114
|
+
// Enabled but displaced by another plugin that replaces it. Shown
|
|
115
|
+
// so the screen can explain the inactivity instead of looking broken.
|
|
116
|
+
supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
|
|
95
117
|
settings: states[manifest.name]?.settings || {}
|
|
96
118
|
}));
|
|
97
119
|
});
|
|
@@ -119,6 +141,16 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
119
141
|
const manifest = manifests.find(m => m.name === name);
|
|
120
142
|
if (!manifest) return reply.status(404).send({ error: 'Plugin not found' });
|
|
121
143
|
|
|
144
|
+
// A core plugin is a built-in feature, not an add-on. Refusing here
|
|
145
|
+
// rather than writing the flag and ignoring it keeps the stored state
|
|
146
|
+
// honest - otherwise plugins.json says disabled while the feature runs.
|
|
147
|
+
// Settings are still editable; only the on/off switch is not.
|
|
148
|
+
if (isCorePlugin(name) && enabled === false) {
|
|
149
|
+
return reply.status(409).send({
|
|
150
|
+
error: `${manifest.displayName || name} is a built-in feature and cannot be disabled.`
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
122
154
|
const prevState = getPluginStates()[name] || {};
|
|
123
155
|
const update = {};
|
|
124
156
|
if (enabled !== undefined) update.enabled = !!enabled;
|
|
@@ -158,17 +190,29 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
158
190
|
|
|
159
191
|
const mapErr = (reply, err) => reply.status(err.statusCode || 500).send({ error: err.message });
|
|
160
192
|
|
|
193
|
+
/*
|
|
194
|
+
* May this request read a LICENSED plugin's source?
|
|
195
|
+
*
|
|
196
|
+
* Only a super-admin (level 0). On the fleet that role exists on the
|
|
197
|
+
* manager and nowhere else - every customer-site user is an admin, level 1
|
|
198
|
+
* - so this opens the source to the people who publish the paid plugins
|
|
199
|
+
* without opening it on the sites they are distributed to. Level rather
|
|
200
|
+
* than role name, the same way requireAdmin and canManageUser decide.
|
|
201
|
+
*/
|
|
202
|
+
const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0;
|
|
203
|
+
const licensed = (request) => ({allowLicensed: mayEditLicensed(request)});
|
|
204
|
+
|
|
161
205
|
// List a plugin's editable files
|
|
162
206
|
fastify.get('/plugins/:name/files', canDevelop, async (request, reply) => {
|
|
163
207
|
try {
|
|
164
|
-
return { files: await listPluginFiles(request.params.name) };
|
|
208
|
+
return { files: await listPluginFiles(request.params.name, licensed(request)) };
|
|
165
209
|
} catch (err) { return mapErr(reply, err); }
|
|
166
210
|
});
|
|
167
211
|
|
|
168
212
|
// Read one file (?path=admin/views/index.js)
|
|
169
213
|
fastify.get('/plugins/:name/file', canDevelop, async (request, reply) => {
|
|
170
214
|
try {
|
|
171
|
-
return await readPluginFile(request.params.name, request.query.path);
|
|
215
|
+
return await readPluginFile(request.params.name, request.query.path, licensed(request));
|
|
172
216
|
} catch (err) { return mapErr(reply, err); }
|
|
173
217
|
});
|
|
174
218
|
|
|
@@ -176,7 +220,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
176
220
|
fastify.put('/plugins/:name/file', canDevelop, async (request, reply) => {
|
|
177
221
|
const { path: relPath, content } = request.body || {};
|
|
178
222
|
try {
|
|
179
|
-
const result = await writePluginFile(request.params.name, relPath, content);
|
|
223
|
+
const result = await writePluginFile(request.params.name, relPath, content, licensed(request));
|
|
180
224
|
return { success: true, ...result };
|
|
181
225
|
} catch (err) { return mapErr(reply, err); }
|
|
182
226
|
});
|
|
@@ -184,7 +228,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
184
228
|
// Delete one file (?path=admin/views/extra.js)
|
|
185
229
|
fastify.delete('/plugins/:name/file', canDevelop, async (request, reply) => {
|
|
186
230
|
try {
|
|
187
|
-
const result = await deletePluginFile(request.params.name, request.query.path);
|
|
231
|
+
const result = await deletePluginFile(request.params.name, request.query.path, licensed(request));
|
|
188
232
|
return { success: true, ...result };
|
|
189
233
|
} catch (err) { return mapErr(reply, err); }
|
|
190
234
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search API Routes
|
|
3
|
+
*
|
|
4
|
+
* GET /api/search?q=… - public: full-text search across published pages
|
|
5
|
+
* GET /api/search/settings - public: the search UI's own configuration
|
|
6
|
+
* PUT /api/search/settings - admin-only: save it
|
|
7
|
+
*
|
|
8
|
+
* The public settings endpoint is kept for anything already calling it (and
|
|
9
|
+
* for a page served from a cache older than the feature), but it is no longer
|
|
10
|
+
* on the critical path: the renderer embeds the same object in the page as
|
|
11
|
+
* `window.__CMS_SEARCH__`, so the overlay can draw itself without a round trip.
|
|
12
|
+
*/
|
|
13
|
+
import {authenticate, requireAdmin} from '../../middleware/auth.js';
|
|
14
|
+
import {listPages} from '../../services/content.js';
|
|
15
|
+
import {getSearchSettings, saveSearchSettings, searchPages} from '../../services/search.js';
|
|
16
|
+
|
|
17
|
+
export async function searchRoutes(fastify) {
|
|
18
|
+
fastify.get('/search', async (request, reply) => {
|
|
19
|
+
const settings = getSearchSettings();
|
|
20
|
+
const q = (request.query.q || '').trim();
|
|
21
|
+
|
|
22
|
+
if (q.length < settings.minQueryLength) {
|
|
23
|
+
return reply.status(400).send({
|
|
24
|
+
error: `Query must be at least ${settings.minQueryLength} characters`
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pages = await listPages();
|
|
29
|
+
const results = searchPages(pages, q, {
|
|
30
|
+
maxResults: settings.maxResults,
|
|
31
|
+
onError: (err, page) =>
|
|
32
|
+
request.log.warn({err, page: page.urlPath}, '[search] skipped an unreadable page')
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return {results};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
fastify.get('/search/settings', async () => getSearchSettings());
|
|
39
|
+
|
|
40
|
+
fastify.put('/search/settings', {preHandler: [authenticate, requireAdmin]}, async (request) => {
|
|
41
|
+
return {ok: true, settings: saveSearchSettings(request.body || {})};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme API
|
|
3
|
+
*
|
|
4
|
+
* GET /api/theme - the theme config (themes, fonts, overrides)
|
|
5
|
+
* PUT /api/theme - replace it
|
|
6
|
+
* GET /api/theme/catalog - theme ids and --dm-* token names the shipped
|
|
7
|
+
* stylesheet actually defines
|
|
8
|
+
* POST /api/theme/compile - compile a candidate config to CSS without saving,
|
|
9
|
+
* so the admin previews exactly what the page gets
|
|
10
|
+
*
|
|
11
|
+
* Auth middlewares are injectable so tests can pass no-ops, the same DI pattern
|
|
12
|
+
* as menus.js and context-menus.js.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
authenticate as defaultAuthenticate,
|
|
16
|
+
requirePermission as defaultRequirePermission
|
|
17
|
+
} from '../../middleware/auth.js';
|
|
18
|
+
import {
|
|
19
|
+
compileOverrideCss,
|
|
20
|
+
getThemeTokenMap,
|
|
21
|
+
listThemeIds,
|
|
22
|
+
listThemeTokens,
|
|
23
|
+
loadThemeConfig,
|
|
24
|
+
saveThemeConfig,
|
|
25
|
+
validateThemeConfig
|
|
26
|
+
} from '../../services/themeSettings.js';
|
|
27
|
+
|
|
28
|
+
export async function themeRoutes(fastify, opts = {}) {
|
|
29
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
30
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
31
|
+
|
|
32
|
+
const canRead = {preHandler: [authenticate, requirePermission('theme', 'read')]};
|
|
33
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('theme', 'update')]};
|
|
34
|
+
|
|
35
|
+
fastify.get('/theme', canRead, async () => loadThemeConfig());
|
|
36
|
+
|
|
37
|
+
// The editor's theme picker and token picker build themselves from this
|
|
38
|
+
// rather than from a hardcoded list that would drift from the CSS.
|
|
39
|
+
fastify.get('/theme/catalog', canRead, async () => ({
|
|
40
|
+
themes: listThemeIds(),
|
|
41
|
+
tokens: listThemeTokens()
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* One theme's token values. Fetched on demand rather than folded into
|
|
46
|
+
* /catalog: 32 themes x ~245 tokens is a payload nobody needs on a page
|
|
47
|
+
* load, and the admin only wants the one being considered.
|
|
48
|
+
*/
|
|
49
|
+
fastify.get('/theme/themes/:id', canRead, async (request, reply) => {
|
|
50
|
+
const tokens = getThemeTokenMap(request.params.id);
|
|
51
|
+
if (!tokens) return reply.code(404).send({error: 'Unknown theme'});
|
|
52
|
+
return {id: request.params.id, tokens};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
fastify.put('/theme', canUpdate, async (request, reply) => {
|
|
56
|
+
try {
|
|
57
|
+
return await saveThemeConfig(request.body || {});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return reply.code(400).send({error: err.message, validation: err.validation || null});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
fastify.post('/theme/compile', canRead, async (request, reply) => {
|
|
64
|
+
const candidate = request.body || {};
|
|
65
|
+
const errors = validateThemeConfig(candidate);
|
|
66
|
+
if (errors.length) return reply.code(400).send({error: errors.join('; '), validation: errors});
|
|
67
|
+
return {css: compileOverrideCss(candidate)};
|
|
68
|
+
});
|
|
69
|
+
}
|