domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -18,6 +18,7 @@ import {getEffectiveRoles} from '../services/userRoles.js';
18
18
  import {hooks} from '../services/hooks.js';
19
19
  import {getConfig} from '../config.js';
20
20
  import * as cache from '../services/cache/index.js';
21
+ import {createHash} from 'crypto';
21
22
 
22
23
  /**
23
24
  * Derive the absolute origin for the current request.
@@ -55,6 +56,40 @@ function escapeHtml(str) {
55
56
  .replace(/'/g, ''');
56
57
  }
57
58
 
59
+ /*
60
+ * Send an HTML page with headers that make a browser CHECK before reusing it.
61
+ *
62
+ * Public pages went out with no Cache-Control, no ETag and no Last-Modified at
63
+ * all. With nothing to go on a browser applies heuristic caching, so an edited
64
+ * page could keep showing the old content on a normal refresh - the server was
65
+ * serving the new one the whole time. That is indistinguishable from a CMS bug
66
+ * and was reported as one.
67
+ *
68
+ * `no-cache` does not mean "do not store": it means store it, then revalidate
69
+ * before every reuse. Paired with an ETag that makes the common case a 304 with
70
+ * no body rather than a full re-download.
71
+ *
72
+ * `private` because these responses are role-dependent - the response cache
73
+ * keys on role for exactly that reason - and a shared proxy must never hand one
74
+ * visitor a page rendered for another's permissions.
75
+ */
76
+ function sendHtml(request, reply, html) {
77
+ const etag = `"${createHash('sha1').update(html).digest('base64')}"`;
78
+ reply.header('cache-control', 'private, no-cache');
79
+ reply.header('etag', etag);
80
+
81
+ // If-None-Match may carry a list, and a weak validator's W/ prefix.
82
+ const seen = String(request.headers['if-none-match'] || '')
83
+ .split(',')
84
+ .map((t) => t.trim().replace(/^W\//, ''));
85
+ if (seen.includes(etag)) return reply.code(304).send();
86
+
87
+ // Deliberately no reply.code() here: the 404 handlers have already called
88
+ // reply.status(404), and forcing 200 would turn every not-found into a
89
+ // soft 404 that search engines index as a real page.
90
+ return reply.type('text/html').send(html);
91
+ }
92
+
58
93
  export async function publicRoutes(fastify) {
59
94
  // Admin panel: serve index.html for all /admin/* paths (SPA fallback)
60
95
  fastify.get('/admin', async (request, reply) => {
@@ -104,13 +139,13 @@ export async function publicRoutes(fastify) {
104
139
  const link = await verifyPreviewToken((request.query || {}).token, fastify.jwt);
105
140
  if (!link) {
106
141
  reply.status(404);
107
- return reply.type('text/html').send(await render404('/_preview'));
142
+ return sendHtml(request, reply, await render404('/_preview'));
108
143
  }
109
144
 
110
145
  const page = await getPage(link.urlPath);
111
146
  if (!page) {
112
147
  reply.status(404);
113
- return reply.type('text/html').send(await render404(link.urlPath));
148
+ return sendHtml(request, reply, await render404(link.urlPath));
114
149
  }
115
150
 
116
151
  // A disabled project stays a hard kill switch. A share link narrows who
@@ -118,7 +153,7 @@ export async function publicRoutes(fastify) {
118
153
  const project = await getProjectForPage(page.urlPath || link.urlPath, page.project);
119
154
  if (!(await isProjectEnabled(project))) {
120
155
  reply.status(404);
121
- return reply.type('text/html').send(await render404(link.urlPath));
156
+ return sendHtml(request, reply, await render404(link.urlPath));
122
157
  }
123
158
 
124
159
  // Rendered as anonymous on purpose: the recipient has no account, so
@@ -161,7 +196,7 @@ export async function publicRoutes(fastify) {
161
196
 
162
197
  if (!page) {
163
198
  reply.status(404);
164
- return reply.type('text/html').send(await render404(urlPath));
199
+ return sendHtml(request, reply, await render404(urlPath));
165
200
  }
166
201
 
167
202
  // Hard kill-switch: a disabled project takes its pages off the public
@@ -169,7 +204,7 @@ export async function publicRoutes(fastify) {
169
204
  const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
170
205
  if (!(await isProjectEnabled(pageProject))) {
171
206
  reply.status(404);
172
- return reply.type('text/html').send(await render404(urlPath));
207
+ return sendHtml(request, reply, await render404(urlPath));
173
208
  }
174
209
 
175
210
  // Draft preview: a page that is not published stays invisible to the
@@ -209,7 +244,7 @@ export async function publicRoutes(fastify) {
209
244
 
210
245
  if (isDraft && !canPreviewDrafts(userObj)) {
211
246
  reply.status(404);
212
- return reply.type('text/html').send(await render404(urlPath));
247
+ return sendHtml(request, reply, await render404(urlPath));
213
248
  }
214
249
 
215
250
  if (!isPublic && !checkVisibility(userObj, vis)) {
@@ -249,7 +284,7 @@ export async function publicRoutes(fastify) {
249
284
  const cacheTags = [`page:${urlPath}`, ...(page.cacheTags || []), 'nav', 'site'];
250
285
  const html = await cache.wrap(cacheKey, render, {tags: cacheTags});
251
286
  hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
252
- return reply.type('text/html').send(html);
287
+ return sendHtml(request, reply, html);
253
288
  });
254
289
  }
255
290
 
package/server/server.js CHANGED
@@ -9,6 +9,7 @@ import Fastify from 'fastify';
9
9
  import jwt from '@fastify/jwt';
10
10
  import staticPlugin from '@fastify/static';
11
11
  import cors from '@fastify/cors';
12
+ import {isPublicAssetRead, normalisePublicPrefixes} from './services/publicCors.js';
12
13
  import multipart from '@fastify/multipart';
13
14
  import rateLimit from '@fastify/rate-limit';
14
15
  import helmet from '@fastify/helmet';
@@ -119,7 +120,24 @@ await app.register(helmet, {
119
120
  });
120
121
 
121
122
  await app.register(jwt, { secret: process.env.JWT_SECRET });
122
- await app.register(cors, serverConfig.cors);
123
+ // publicPrefixes is ours, not @fastify/cors's, so it is stripped before the
124
+ // register call rather than passed through as an unknown option.
125
+ const {publicPrefixes: _cfgPublicPrefixes, ...corsOptions} = serverConfig.cors ?? {};
126
+ await app.register(cors, corsOptions);
127
+
128
+ // Open reads on explicitly listed asset prefixes. @fastify/cors decides per
129
+ // origin and cannot express a path, so a site wanting its own assets
130
+ // hot-linkable (module scripts are fetched in CORS mode) lists them here.
131
+ const publicCorsPrefixes = normalisePublicPrefixes(serverConfig.cors);
132
+ if (publicCorsPrefixes.length) {
133
+ app.addHook('onSend', async (request, reply, payload) => {
134
+ if (isPublicAssetRead(request.method, request.url, publicCorsPrefixes)) {
135
+ reply.header('Access-Control-Allow-Origin', '*');
136
+ }
137
+ return payload;
138
+ });
139
+ app.log.info(`[cors] open reads on ${publicCorsPrefixes.join(', ')}`);
140
+ }
123
141
  await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
124
142
  await app.register(rateLimit, {
125
143
  global: true, // apply default limit to all routes; stricter per-route limits override this
@@ -218,6 +236,42 @@ await refreshComponentTagAllowlist();
218
236
  // Menus - one-shot migration from legacy navigation.json + site footer links
219
237
  // ---------------------------------------------------------------------------
220
238
 
239
+ // ---------------------------------------------------------------------------
240
+ // Theme - one-shot lift of the theme keys out of site.json into theme.json
241
+ // ---------------------------------------------------------------------------
242
+
243
+ try {
244
+ const {migrateThemeConfig} = await import('./services/themeSettings.js');
245
+ if (await migrateThemeConfig()) app.log.info('[theme] Migrated theme settings into config/theme.json');
246
+ } catch (err) {
247
+ app.log.warn(`[theme] Migration skipped: ${err.message}`);
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Plugins promoted into the CMS - one-shot retirement of the directories and
252
+ // plugins.json entries they leave behind. Must run BEFORE registerPlugins(),
253
+ // or the loader registers a second copy of a feature core now has itself.
254
+ // ---------------------------------------------------------------------------
255
+ try {
256
+ const {migratePromotedPlugins} = await import('./services/promoted-plugins-migration.js');
257
+ const promoted = migratePromotedPlugins();
258
+ if (promoted.length) app.log.info(`[plugins] Retired promoted plugins: ${promoted.join('; ')}`);
259
+ } catch (err) {
260
+ app.log.warn(`[plugins] Promoted-plugin migration skipped: ${err.message}`);
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Search - one-shot retirement of the site-search plugin (settings + files)
265
+ // ---------------------------------------------------------------------------
266
+
267
+ try {
268
+ const {migrateSiteSearchPlugin} = await import('./services/search-migration.js');
269
+ const changes = migrateSiteSearchPlugin();
270
+ if (changes.length) app.log.info(`[search] Migrated from the site-search plugin: ${changes.join(', ')}`);
271
+ } catch (err) {
272
+ app.log.warn(`[search] Migration skipped: ${err.message}`);
273
+ }
274
+
221
275
  try {
222
276
  const {runMigration: runMenusMigration} = await import('./services/menus-migration.js');
223
277
  await runMenusMigration();
@@ -237,6 +291,18 @@ try {
237
291
  groupText: 'Data',
238
292
  item: {text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints'}
239
293
  });
294
+ await ensureSidebarItem({
295
+ groupText: 'Content',
296
+ item: {text: 'Context Menus', url: '#/context-menus', icon: 'menu', permission: 'context-menus'}
297
+ });
298
+ await ensureSidebarItem({
299
+ groupText: 'Content',
300
+ item: {text: 'Theme', url: '#/theme', icon: 'droplet', permission: 'theme'}
301
+ });
302
+ await ensureSidebarItem({
303
+ groupText: 'System',
304
+ item: {text: 'Search', url: '#/search', icon: 'search', permission: 'settings'}
305
+ });
240
306
  // Restructure Documentation: Tutorials → submenu, add Components reference set.
241
307
  await ensureDocumentationSubmenus();
242
308
  } catch (err) {
@@ -245,6 +311,39 @@ try {
245
311
 
246
312
  // Initialise response cache (Memory/None/Redis driver per config.cache).
247
313
  await cache.initCache(config.cache);
314
+
315
+ /*
316
+ * An admin change clears the response cache.
317
+ *
318
+ * Every service that mutates something is meant to invalidate the tags its
319
+ * change affects, and mostly they do - but a miss is INVISIBLE. The cache is
320
+ * off in development and on in production, so a missed invalidation works
321
+ * perfectly on the machine it was written on and serves stale HTML live until
322
+ * the TTL expires an hour later. Context menus shipped that way for three
323
+ * releases: the call named a function the cache module does not export AND a
324
+ * tag nothing is ever stored with, and a try/catch ate the evidence.
325
+ *
326
+ * This is the safety net, not the mechanism. The targeted invalidations stay -
327
+ * they are what stops one collection edit clearing every unrelated page - and
328
+ * this catches whatever they missed.
329
+ *
330
+ * Scoped to AUTHENTICATED mutations on purpose. A public form submission is
331
+ * not an admin change, and clearing on each of those would cold-start the
332
+ * cache on a busy site; entry writes already invalidate their own collection
333
+ * tag. Runs on onResponse, after the reply has gone, so it costs the caller
334
+ * nothing - and a memory-driver clear is two Map resets.
335
+ */
336
+ const CACHE_CLEARING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
337
+ app.addHook('onResponse', async (request, reply) => {
338
+ if (!CACHE_CLEARING_METHODS.has(request.method)) return;
339
+ if (reply.statusCode >= 400) return;
340
+ if (!request.user) return;
341
+ try {
342
+ await cache.clear();
343
+ } catch (err) {
344
+ app.log.warn(`[cache] Admin change did not clear the cache: ${err.message}`);
345
+ }
346
+ });
248
347
  console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
249
348
 
250
349
  // Docs are rendered at request time from the shipped admin templates; clear any
@@ -332,9 +431,11 @@ await app.register(authRoutes, { prefix: '/api' });
332
431
 
333
432
  const { pagesRoutes } = await import('./routes/api/pages.js');
334
433
  const { settingsRoutes } = await import('./routes/api/settings.js');
434
+ const { themeRoutes } = await import('./routes/api/theme.js');
335
435
  const { layoutsRoutes } = await import('./routes/api/layouts.js');
336
436
  const { navigationRoutes } = await import('./routes/api/navigation.js');
337
437
  const {menusRoutes} = await import('./routes/api/menus.js');
438
+ const {contextMenusRoutes} = await import('./routes/api/context-menus.js');
338
439
  const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
339
440
  const {projectsRoutes} = await import('./routes/api/projects.js');
340
441
  const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
@@ -344,6 +445,10 @@ const {sidebarRoutes} = await import('./routes/api/sidebar.js');
344
445
  const { mediaRoutes } = await import('./routes/api/media.js');
345
446
  const { usersRoutes } = await import('./routes/api/users.js');
346
447
  const { pluginsRoutes } = await import('./routes/api/plugins.js');
448
+ const { notesRoutes } = await import('./routes/api/notes.js');
449
+ const { todoRoutes } = await import('./routes/api/todo.js');
450
+ const { analyticsRoutes } = await import('./routes/api/analytics.js');
451
+ const { contactsRoutes } = await import('./routes/api/contacts.js');
347
452
  const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
348
453
  const { collectionsRoutes } = await import('./routes/api/collections.js');
349
454
  const { formsRoutes } = await import('./routes/api/forms.js');
@@ -353,6 +458,7 @@ const {blocksRoutes} = await import('./routes/api/blocks.js');
353
458
  const {componentsRoutes} = await import('./routes/api/components.js');
354
459
  const {versionsRoutes} = await import('./routes/api/versions.js');
355
460
  const {effectsRoutes} = await import('./routes/api/effects.js');
461
+ const {searchRoutes} = await import('./routes/api/search.js');
356
462
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
357
463
  const {dashboardRoutes} = await import('./routes/api/dashboard.js');
358
464
  const {cacheRoutes} = await import('./routes/api/cache.js');
@@ -363,6 +469,8 @@ await app.register(settingsRoutes, { prefix: '/api' });
363
469
  await app.register(layoutsRoutes, { prefix: '/api' });
364
470
  await app.register(navigationRoutes, { prefix: '/api' });
365
471
  await app.register(menusRoutes, {prefix: '/api'});
472
+ await app.register(contextMenusRoutes, {prefix: '/api'});
473
+ await app.register(themeRoutes, {prefix: '/api'});
366
474
  await app.register(menuLocationsRoutes, {prefix: '/api'});
367
475
  await app.register(projectsRoutes, {prefix: '/api'});
368
476
  await app.register(apiTokensRoutes, {prefix: '/api'});
@@ -381,15 +489,41 @@ await app.register(blocksRoutes, {prefix: '/api'});
381
489
  await app.register(componentsRoutes, {prefix: '/api'});
382
490
  await app.register(versionsRoutes, {prefix: '/api'});
383
491
  await app.register(effectsRoutes, {prefix: '/api'});
492
+ await app.register(searchRoutes, {prefix: '/api'});
384
493
  await app.register(notificationsRoutes, {prefix: '/api'});
385
494
  await app.register(dashboardRoutes, {prefix: '/api'});
386
495
  await app.register(cacheRoutes, {prefix: '/api'});
387
496
  await app.register(scaffoldRoutes, {prefix: '/api'});
497
+ // Tools promoted out of plugins/ - their own prefixes, not /api/plugins/*.
498
+ await app.register(notesRoutes, {prefix: '/api/notes'});
499
+ await app.register(todoRoutes, {prefix: '/api/todo'});
500
+ await app.register(analyticsRoutes, {prefix: '/api/analytics'});
501
+ await app.register(contactsRoutes, {prefix: '/api/contacts'});
388
502
 
389
503
  // ---------------------------------------------------------------------------
390
504
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
391
505
  // ---------------------------------------------------------------------------
392
506
 
507
+ // ---------------------------------------------------------------------------
508
+ // Core Tools in the sidebar
509
+ // ---------------------------------------------------------------------------
510
+ // Registered through the same channel plugins use, deliberately. The sidebar
511
+ // menu (config/menus/admin-sidebar.json) is seeded once and file-existence
512
+ // guarded, so adding an entry there would reach new sites only - every existing
513
+ // site would simply never see Notes or Todo. Registered items are merged at
514
+ // render time on every site, and groupPluginItems() files them under Tools,
515
+ // which is exactly where a promoted plugin belongs.
516
+ //
517
+ // `permission` is what hides each one from a role that cannot use it; the
518
+ // renderer filters on it.
519
+ const {registerSidebarItem} = await import('./services/hooks.js');
520
+ // `countUrl` is the badge: the sidebar fetches it and shows {count, label, tone}
521
+ // (see fetchCounts in admin/js/lib/sidebar-renderer.js).
522
+ registerSidebarItem({item: {text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'}});
523
+ registerSidebarItem({item: {text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'}});
524
+ registerSidebarItem({item: {text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'}});
525
+ registerSidebarItem({item: {text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}});
526
+
393
527
  await registerPlugins(app);
394
528
 
395
529
  // ---------------------------------------------------------------------------
@@ -86,7 +86,12 @@ export class FileAdapter {
86
86
  entries.sort((a, b) => {
87
87
  const aVal = sort === 'createdAt' ? a.meta?.createdAt : (a.data?.[sort] ?? '');
88
88
  const bVal = sort === 'createdAt' ? b.meta?.createdAt : (b.data?.[sort] ?? '');
89
- const cmp = String(aVal).localeCompare(String(bVal));
89
+ // Numeric collation, because a plain string compare orders a number
90
+ // field 1, 10, 11, 2 - so any collection sorted by `order` scrambles
91
+ // itself the moment it has ten entries. MongoAdapter sorts natively
92
+ // and has always ordered these correctly, so this also stops the two
93
+ // adapters disagreeing about the same data.
94
+ const cmp = String(aVal).localeCompare(String(bVal), undefined, {numeric: true});
90
95
  return order === 'desc' ? -cmp : cmp;
91
96
  });
92
97
 
@@ -131,11 +131,21 @@ export async function createCollection({title, slug, description = '', fields =
131
131
  if (!finalSlug) throw new Error('Could not derive a slug from the title');
132
132
 
133
133
  const dir = collectionDir(finalSlug);
134
+ // A directory with no schema.json is not a collection - getCollection()
135
+ // says so - but refusing to create one there made it impossible to ever
136
+ // have one. That is how dcm's blog lost its collections: three empty
137
+ // directories left behind, and setupPlugin() re-trying and failing on
138
+ // every boot. So such a directory is adopted rather than refused, and any
139
+ // entries it still holds are kept rather than overwritten.
140
+ let orphanHasData = false;
134
141
  try {
135
142
  await fs.mkdir(dir, { recursive: false });
136
143
  } catch (err) {
137
- if (err.code === 'EEXIST') throw new Error(`A collection with slug "${finalSlug}" already exists`);
138
- throw err;
144
+ if (err.code !== 'EEXIST') throw err;
145
+ if (await getCollection(finalSlug)) {
146
+ throw new Error(`A collection with slug "${finalSlug}" already exists`);
147
+ }
148
+ orphanHasData = await fs.access(path.join(dir, 'data.json')).then(() => true, () => false);
139
149
  }
140
150
 
141
151
  const now = new Date().toISOString();
@@ -154,7 +164,7 @@ export async function createCollection({title, slug, description = '', fields =
154
164
  };
155
165
 
156
166
  await writeSchema(schema);
157
- await (await getAdapter(finalSlug)).insertMany(finalSlug, []);
167
+ if (!orphanHasData) await (await getAdapter(finalSlug)).insertMany(finalSlug, []);
158
168
  await cache.invalidateTags([`collection:${finalSlug}`]);
159
169
  return schema;
160
170
  }
@@ -122,6 +122,25 @@ export async function buildPreviewPage(urlPath, frontmatter, body, opts = {}) {
122
122
  * @param {string} body
123
123
  * @returns {Promise<object>}
124
124
  */
125
+ /**
126
+ * Keep a page's tag list to actual tags.
127
+ *
128
+ * An empty tag input has a way of arriving as `[null]` - which is an array,
129
+ * passes every `Array.isArray` guard, and then throws the moment anything
130
+ * calls a string method on the contents. It reached disk on dozens of pages
131
+ * and took the whole of site search down with it, so it is cleaned on the way
132
+ * IN rather than defended against at each of the places that read it.
133
+ *
134
+ * @param {unknown} tags
135
+ * @returns {string[]}
136
+ */
137
+ function cleanTags(tags) {
138
+ if (!Array.isArray(tags)) return [];
139
+ return [...new Set(
140
+ tags.filter(tag => typeof tag === 'string' && tag.trim()).map(tag => tag.trim())
141
+ )];
142
+ }
143
+
125
144
  export async function createPage(urlPath, frontmatter, body) {
126
145
  const filePath = urlPathToFilePath(urlPath);
127
146
  await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -138,6 +157,7 @@ export async function createPage(urlPath, frontmatter, body) {
138
157
  sortOrder: frontmatter.sortOrder ?? defaults.sortOrder,
139
158
  showInNav: frontmatter.showInNav ?? false,
140
159
  sidebar: frontmatter.sidebar ?? false,
160
+ tags: cleanTags(frontmatter.tags),
141
161
  seo: frontmatter.seo || {},
142
162
  dconfig: frontmatter.dconfig || null,
143
163
  createdAt: now,
@@ -172,6 +192,12 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
172
192
  updatedAt: new Date().toISOString()
173
193
  };
174
194
 
195
+ // Cleaned on every save, so an existing page carrying `[null]` is repaired
196
+ // the next time anyone touches it rather than staying broken for ever.
197
+ // Only when tags are part of this save: a caller that sends no tags is not
198
+ // asking for the page's tags to be emptied.
199
+ if ('tags' in meta) meta.tags = cleanTags(meta.tags);
200
+
175
201
  if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
176
202
 
177
203
  // '' is the editor's "inherit by rootUrl" choice - drop the key so the