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.
Files changed (211) hide show
  1. package/CLAUDE.md +130 -2
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. 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
- let redirect = settings.successRedirect || null;
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 { authenticate, requirePermission } from '../../middleware/auth.js';
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
- export async function notificationsRoutes(fastify) {
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.data;
73
- // Exclude expired
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
- .sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))
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.data;
95
- if (d.expiresAt && d.expiresAt < now) return false;
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
- if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
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(entry.data.readBy) ? entry.data.readBy : [];
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
- ...entry.data,
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
- if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
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(entry.data.dismissedBy) ? entry.data.dismissedBy : [];
133
- const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
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
- ...entry.data,
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
- enabled: !!(states[manifest.name]?.enabled),
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
+ }