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
@@ -12,13 +12,43 @@ import {
12
12
  import {
13
13
  discoverPlugins,
14
14
  getAdminPluginConfig,
15
+ getEntitlementStates,
16
+ getLoadedPlugins,
15
17
  getPluginStates,
18
+ isCorePlugin,
19
+ pluginLicence,
16
20
  runLifecycleHook,
17
21
  savePluginState
18
22
  } from '../../services/plugins.js';
23
+ import {getEffectiveLevel} from '../../services/userRoles.js';
19
24
  import {scaffoldPlugin} from '../../services/pluginScaffold.js';
20
25
  import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
21
26
  import {getConfig} from '../../config.js';
27
+ import path from 'path';
28
+ import {existsSync, readFileSync} from 'node:fs';
29
+ import {installPlugin} from '../../services/pluginInstaller.js';
30
+ import {packEnvelope, readEnvelope, readManifestFromTarball, tarDirectory} from '../../services/pluginEnvelope.js';
31
+
32
+ /**
33
+ * The version of a plugin currently on disk, or null if the slug is free.
34
+ *
35
+ * Deliberately a directory check rather than `discoverPlugins()`: a half-broken
36
+ * installation still occupies the slug - that is exactly what the installer
37
+ * refuses to overwrite - and it would be invisible to discovery. An unreadable
38
+ * manifest reports an occupied slug with an unknown version, which is honest.
39
+ *
40
+ * @param {string} slug
41
+ * @returns {string|null|undefined} version, `null` when unreadable, `undefined` when absent
42
+ */
43
+ function installedVersion(slug) {
44
+ const dir = path.join(path.resolve('plugins'), slug);
45
+ if (!existsSync(dir)) return undefined;
46
+ try {
47
+ return JSON.parse(readFileSync(path.join(dir, 'plugin.json'), 'utf8')).version ?? null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
22
52
 
23
53
  /**
24
54
  * True when something will bring the process back after a clean exit.
@@ -78,9 +108,11 @@ export async function pluginsRoutes(fastify, opts = {}) {
78
108
  const canDevelop = { preHandler: [authenticate, requirePermission('plugins', 'develop')] };
79
109
 
80
110
  // List all plugins with their current state
81
- fastify.get('/plugins', canRead, async () => {
111
+ fastify.get('/plugins', canRead, async (request) => {
82
112
  const manifests = await discoverPlugins();
83
113
  const states = getPluginStates();
114
+ const loadedPlugins = getLoadedPlugins();
115
+ const entitlementStates = getEntitlementStates();
84
116
 
85
117
  return manifests.map(manifest => ({
86
118
  name: manifest.name,
@@ -90,8 +122,27 @@ export async function pluginsRoutes(fastify, opts = {}) {
90
122
  author: manifest.author || '',
91
123
  date: manifest.date || '',
92
124
  icon: manifest.icon || 'package',
93
- enabled: !!(states[manifest.name]?.enabled),
94
- bundled: !!(states[manifest.name]?.bundled),
125
+ // A core plugin runs whatever plugins.json says, so reporting its
126
+ // stored flag would show "Disabled" next to a feature that is
127
+ // plainly running. It is on because it is built in.
128
+ core: isCorePlugin(manifest.name),
129
+ enabled: isCorePlugin(manifest.name) || !!(states[manifest.name]?.enabled),
130
+ // A licensed plugin is distributed to run, not to be read: the
131
+ // source endpoints refuse it (pluginFiles.isClosedSource). Sent so
132
+ // the screen can leave the Code button off rather than offer one
133
+ // that answers 403 - the refusal is the guard, not the UI.
134
+ closedSource: manifest.closedSource === true,
135
+ licence: pluginLicence(manifest),
136
+ // Why a licensed plugin is not running, when it is not. Null for
137
+ // anything open-source, which is never asked for an entitlement.
138
+ entitlement: entitlementStates[manifest.name] ?? null,
139
+ // Whether the CODE button belongs on this viewer's card. Decided
140
+ // here rather than in the browser: it is the same answer the file
141
+ // endpoints will give, from the same two facts.
142
+ sourceEditable: manifest.closedSource !== true || mayEditLicensed(request),
143
+ // Enabled but displaced by another plugin that replaces it. Shown
144
+ // so the screen can explain the inactivity instead of looking broken.
145
+ supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
95
146
  settings: states[manifest.name]?.settings || {}
96
147
  }));
97
148
  });
@@ -113,17 +164,26 @@ export async function pluginsRoutes(fastify, opts = {}) {
113
164
  // Enable/disable or update settings for a plugin
114
165
  fastify.put('/plugins/:name', canUpdate, async (request, reply) => {
115
166
  const { name } = request.params;
116
- const {enabled, settings, bundled} = request.body || {};
167
+ const {enabled, settings} = request.body || {};
117
168
 
118
169
  const manifests = await discoverPlugins();
119
170
  const manifest = manifests.find(m => m.name === name);
120
171
  if (!manifest) return reply.status(404).send({ error: 'Plugin not found' });
121
172
 
173
+ // A core plugin is a built-in feature, not an add-on. Refusing here
174
+ // rather than writing the flag and ignoring it keeps the stored state
175
+ // honest - otherwise plugins.json says disabled while the feature runs.
176
+ // Settings are still editable; only the on/off switch is not.
177
+ if (isCorePlugin(name) && enabled === false) {
178
+ return reply.status(409).send({
179
+ error: `${manifest.displayName || name} is a built-in feature and cannot be disabled.`
180
+ });
181
+ }
182
+
122
183
  const prevState = getPluginStates()[name] || {};
123
184
  const update = {};
124
185
  if (enabled !== undefined) update.enabled = !!enabled;
125
186
  if (settings !== undefined) update.settings = settings;
126
- if (bundled !== undefined) update.bundled = !!bundled;
127
187
  savePluginState(name, update);
128
188
 
129
189
  if (!prevState.enabled && !!enabled) {
@@ -158,17 +218,146 @@ export async function pluginsRoutes(fastify, opts = {}) {
158
218
 
159
219
  const mapErr = (reply, err) => reply.status(err.statusCode || 500).send({ error: err.message });
160
220
 
221
+ /*
222
+ * May this request read a LICENSED plugin's source?
223
+ *
224
+ * Only a super-admin (level 0). On the fleet that role exists on the
225
+ * manager and nowhere else - every customer-site user is an admin, level 1
226
+ * - so this opens the source to the people who publish the paid plugins
227
+ * without opening it on the sites they are distributed to. Level rather
228
+ * than role name, the same way requireAdmin and canManageUser decide.
229
+ */
230
+ const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0;
231
+ const licensed = (request) => ({allowLicensed: mayEditLicensed(request)});
232
+
161
233
  // List a plugin's editable files
234
+ // ---- Export: a plugin leaves as one file ----------------------------
235
+ //
236
+ // The same .dcmsplugin envelope a purchase arrives in, so an export can be
237
+ // installed by the ordinary route rather than a second "just unzip it" path
238
+ // that would skip the traversal guard and the manifest check.
239
+ //
240
+ // Unsigned by construction: the marketplace key is offline and never on this
241
+ // host. The importing site decides what to do about that.
242
+ // compress: false - the envelope is ALREADY gzip. @fastify/compress sees the
243
+ // gzip magic and labels it Content-Encoding: gzip, so the browser unzipped
244
+ // it on the way in and saved a bare tar; under brotli the download failed
245
+ // outright ("Decoding failed").
246
+ fastify.get('/plugins/:name/export', {...canDevelop, compress: false}, async (request, reply) => {
247
+ const {name} = request.params;
248
+ if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(name)) {
249
+ return reply.code(400).send({error: 'Invalid plugin name'});
250
+ }
251
+
252
+ const manifests = await discoverPlugins();
253
+ const manifest = manifests.find(m => m.name === name);
254
+ if (!manifest) return reply.code(404).send({error: 'Plugin not found'});
255
+
256
+ // A licensed plugin is distributed to RUN here, not to be re-emitted as
257
+ // a redistributable file. Same flag that refuses the source editor, and
258
+ // the same reason: one click would otherwise turn every customer into a
259
+ // distribution channel.
260
+ if (manifest.closedSource === true) {
261
+ return reply.code(403).send({error: `"${name}" is a licensed plugin and cannot be exported.`});
262
+ }
263
+
264
+ const dir = path.join(path.resolve('plugins'), name);
265
+ const includeData = request.query?.includeData === '1';
266
+ const tarball = await tarDirectory(dir, name, {includeData});
267
+ const envelope = packEnvelope({tarball});
268
+
269
+ return reply
270
+ .header('Content-Type', 'application/octet-stream')
271
+ .header('Content-Disposition',
272
+ `attachment; filename="${name}-${manifest.version}.dcmsplugin"`)
273
+ .send(envelope);
274
+ });
275
+
276
+ // ---- Import: install from an uploaded envelope -----------------------
277
+ fastify.post('/plugins/install-upload', canCreate, async (request, reply) => {
278
+ const upload = await request.file().catch(() => null);
279
+ if (!upload) return reply.code(400).send({error: 'No file uploaded'});
280
+
281
+ // Drain the file first. Multipart is a STREAM: `upload.fields` only
282
+ // holds the parts seen so far, so reading a flag before the file body
283
+ // is consumed silently misses any field the browser put after it.
284
+ let body;
285
+ try {
286
+ body = await upload.toBuffer();
287
+ } catch (err) {
288
+ return reply.code(400).send({error: err.message});
289
+ }
290
+
291
+ // Checkboxes on the form, not defaults. Installing an unsigned plugin
292
+ // runs its code on this server; replacing one that is already here
293
+ // swaps out code that is running. Both should be decisions rather than
294
+ // side effects of dragging a file onto a page.
295
+ const flag = (name) => upload.fields?.[name]?.value === 'true';
296
+ const confirmUnsigned = flag('confirmUnsigned');
297
+ const confirmUpgrade = flag('confirmUpgrade');
298
+ const confirmDowngrade = flag('confirmDowngrade');
299
+
300
+ let parts;
301
+ try {
302
+ parts = readEnvelope(body);
303
+ } catch (err) {
304
+ return reply.code(400).send({error: err.message});
305
+ }
306
+
307
+ // The slug comes from the manifest inside, never from the filename: a
308
+ // file can be renamed by anyone, a manifest is what the installer then
309
+ // validates against what was actually extracted.
310
+ const manifest = readManifestFromTarball(parts.tarball);
311
+ const slug = manifest?.name;
312
+ if (!slug) return reply.code(400).send({error: 'No readable plugin manifest inside that file'});
313
+
314
+ const installed = installedVersion(slug);
315
+ if (installed !== undefined && !confirmUpgrade) {
316
+ return reply.code(409).send({
317
+ error: `${slug} ${installed ?? ''} is already installed.`.replace(' ', ' '),
318
+ code: 'already-installed',
319
+ name: slug,
320
+ installedVersion: installed,
321
+ incomingVersion: manifest.version ?? null
322
+ });
323
+ }
324
+
325
+ let result;
326
+ try {
327
+ result = await installPlugin(slug, {
328
+ tarball: parts.tarball,
329
+ signature: parts.signature,
330
+ publicKeyId: parts.signature ? (request.body?.keyId || 'marketplace-1') : null,
331
+ allowUnsigned: confirmUnsigned,
332
+ entitlement: parts.entitlement,
333
+ allowUpgrade: confirmUpgrade,
334
+ allowDowngrade: confirmDowngrade
335
+ });
336
+ } catch (err) {
337
+ fastify.log.warn({err}, '[plugins] upload install failed');
338
+ return reply.code(400).send({error: err.message});
339
+ }
340
+
341
+ return reply.send({
342
+ success: true,
343
+ name: slug,
344
+ action: result?.action ?? 'install',
345
+ from: result?.from ?? null,
346
+ version: result?.version ?? manifest.version ?? null,
347
+ restartRequired: true
348
+ });
349
+ });
350
+
162
351
  fastify.get('/plugins/:name/files', canDevelop, async (request, reply) => {
163
352
  try {
164
- return { files: await listPluginFiles(request.params.name) };
353
+ return { files: await listPluginFiles(request.params.name, licensed(request)) };
165
354
  } catch (err) { return mapErr(reply, err); }
166
355
  });
167
356
 
168
357
  // Read one file (?path=admin/views/index.js)
169
358
  fastify.get('/plugins/:name/file', canDevelop, async (request, reply) => {
170
359
  try {
171
- return await readPluginFile(request.params.name, request.query.path);
360
+ return await readPluginFile(request.params.name, request.query.path, licensed(request));
172
361
  } catch (err) { return mapErr(reply, err); }
173
362
  });
174
363
 
@@ -176,7 +365,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
176
365
  fastify.put('/plugins/:name/file', canDevelop, async (request, reply) => {
177
366
  const { path: relPath, content } = request.body || {};
178
367
  try {
179
- const result = await writePluginFile(request.params.name, relPath, content);
368
+ const result = await writePluginFile(request.params.name, relPath, content, licensed(request));
180
369
  return { success: true, ...result };
181
370
  } catch (err) { return mapErr(reply, err); }
182
371
  });
@@ -184,7 +373,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
184
373
  // Delete one file (?path=admin/views/extra.js)
185
374
  fastify.delete('/plugins/:name/file', canDevelop, async (request, reply) => {
186
375
  try {
187
- const result = await deletePluginFile(request.params.name, request.query.path);
376
+ const result = await deletePluginFile(request.params.name, request.query.path, licensed(request));
188
377
  return { success: true, ...result };
189
378
  } catch (err) { return mapErr(reply, err); }
190
379
  });
@@ -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
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Todo API - /api/todo
3
+ *
4
+ * Was the `todo` plugin until 0.67.0. The logic is unchanged; what moved is
5
+ * where it lives and how it is gated.
6
+ *
7
+ * **Per-user scope.** Entries carry a `userId` and every read is filtered to the
8
+ * caller's own. The plugin made this configurable (`scope: 'user' | 'site'`) and
9
+ * no install ever set it to anything else, so it is a constant here. Note this
10
+ * is hand-filtering rather than `rowAccess.js`, which is the mechanism core has
11
+ * for exactly this - moving onto it is a behaviour change and belongs in its own
12
+ * task, not smuggled into a relocation.
13
+ *
14
+ * **The collection slug is `todo-items`, not the directory it used to sit in.**
15
+ * `setupPlugin()` read `schema.slug`, so the live collection has always been
16
+ * called this, and existing entries are found only if it stays exactly so.
17
+ */
18
+ import {clip, plural} from '../../services/sidebarBadges.js';
19
+ import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
20
+ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
21
+
22
+ const SLUG = 'todo-items';
23
+
24
+ /** 'YYYY-MM-DD' of a date, in the server's local time. */
25
+ function localDay(d) {
26
+ const pad = (n) => String(n).padStart(2, '0');
27
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
28
+ }
29
+
30
+ /** Flatten a collection entry into the shape the admin view expects. */
31
+ function toTodo(entry) {
32
+ return {
33
+ id: entry.id,
34
+ ...entry.data,
35
+ createdAt: entry.meta.createdAt,
36
+ updatedAt: entry.meta.updatedAt
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Auth is injectable so a test can drive these routes without registering
42
+ * @fastify/jwt or a role store - the same shape dashboard.js and plugins.js use.
43
+ *
44
+ * @param {import('fastify').FastifyInstance} fastify
45
+ * @param {object} [opts]
46
+ */
47
+ export async function todoRoutes(fastify, opts = {}) {
48
+ const authenticate = opts.authenticate || defaultAuthenticate;
49
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
50
+
51
+
52
+ function userId(request) {
53
+ return request.user?.id ?? request.user?.sub ?? null;
54
+ }
55
+
56
+ async function loadAll(uid) {
57
+ const {entries} = await listEntries(SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
58
+ let items = entries.map(toTodo);
59
+ if (uid) items = items.filter(t => t.userId === uid);
60
+ return items;
61
+ }
62
+
63
+ /**
64
+ * GET /api/todo/count - the sidebar badge: open tasks, and how many are
65
+ * overdue. `?today=YYYY-MM-DD` is the BROWSER's calendar day - due dates
66
+ * are local days, and the server's today is not the reader's near midnight.
67
+ * Answers {count, label, tone}; tone is 'danger' while anything is overdue.
68
+ */
69
+ fastify.get('/count', {preHandler: [authenticate, requirePermission('todo', 'read')]}, async (request) => {
70
+ const given = String(request.query?.today ?? '');
71
+ const today = /^\d{4}-\d{2}-\d{2}$/.test(given) ? given : localDay(new Date());
72
+ const open = (await loadAll(userId(request))).filter(t => t.status !== 'completed');
73
+ const dayOf = (t) => /^(\d{4}-\d{2}-\d{2})/.exec(String(t.dueAt ?? ''))?.[1] ?? null;
74
+ const daysBetween = (a, b) => Math.round((Date.parse(a) - Date.parse(b)) / 86_400_000);
75
+ const overdue = open.filter(t => dayOf(t) && dayOf(t) < today);
76
+ const dueToday = open.filter(t => dayOf(t) === today);
77
+ const PRIORITY = {high: 0, medium: 1, low: 2};
78
+
79
+ // Most urgent first: overdue (oldest first), due today, then by due day, then priority.
80
+ const urgent = [...open].sort((a, b) =>
81
+ (dayOf(a) ?? '9999') .localeCompare(dayOf(b) ?? '9999')
82
+ || (PRIORITY[a.priority] ?? 1) - (PRIORITY[b.priority] ?? 1));
83
+ const items = urgent.slice(0, 5).map(t => {
84
+ const day = dayOf(t);
85
+ const d = day ? daysBetween(day, today) : null;
86
+ const when = d === null ? '' : d < 0 ? `${plural(-d, 'day')} overdue` : d === 0 ? 'due today' : d === 1 ? 'due tomorrow' : `due in ${d} days`;
87
+ const meta = [when, t.priority === 'high' ? 'high priority' : ''].filter(Boolean).join(' · ');
88
+ return {text: clip(t.text), meta, tone: d !== null && d < 0 ? 'danger' : undefined};
89
+ });
90
+
91
+ const label = `${plural(open.length, 'open task')}${overdue.length ? `, ${overdue.length} overdue` : ''}`;
92
+ return {
93
+ count: open.length, label, tone: overdue.length ? 'danger' : 'info',
94
+ details: {
95
+ title: 'Todo',
96
+ rows: [
97
+ {label: 'Open', value: String(open.length)},
98
+ {label: 'Overdue', value: String(overdue.length), tone: overdue.length ? 'danger' : undefined},
99
+ {label: 'Due today', value: String(dueToday.length)},
100
+ {label: 'In progress', value: String(open.filter(t => t.status === 'in-progress').length)}
101
+ ],
102
+ items,
103
+ empty: 'Nothing open - all done.'
104
+ }
105
+ };
106
+ });
107
+
108
+ /** GET /api/todo/items */
109
+ fastify.get('/items', {preHandler: [authenticate, requirePermission('todo', 'read')]}, async (request, reply) => {
110
+ const uid = userId(request);
111
+ const { status } = request.query;
112
+ let items = await loadAll(uid);
113
+ if (status) items = items.filter(t => t.status === status);
114
+ return reply.send(items);
115
+ });
116
+
117
+ /** POST /api/todo/items */
118
+ fastify.post('/items', {preHandler: [authenticate, requirePermission('todo', 'create')]}, async (request, reply) => {
119
+ const uid = userId(request);
120
+ const { text, priority, status } = request.body ?? {};
121
+
122
+ if (!text || typeof text !== 'string' || !text.trim()) {
123
+ return reply.code(400).send({ error: 'text is required' });
124
+ }
125
+
126
+ const {dueAt} = request.body ?? {};
127
+ const entry = await createEntry(SLUG, {
128
+ text: text.trim(),
129
+ status: status ?? 'not-started',
130
+ priority: priority ?? 'medium',
131
+ dueAt: dueAt ?? null,
132
+ userId: uid ?? null
133
+ }, {createdBy: uid, source: 'admin'});
134
+
135
+ return reply.code(201).send(toTodo(entry));
136
+ });
137
+
138
+ /** PUT /api/todo/items/:id */
139
+ fastify.put('/items/:id', {preHandler: [authenticate, requirePermission('todo', 'update')]}, async (request, reply) => {
140
+ const uid = userId(request);
141
+ const { id } = request.params;
142
+ const patch = request.body ?? {};
143
+
144
+ const entry = await getEntry(SLUG, id);
145
+ if (!entry) return reply.code(404).send({error: 'Todo not found'});
146
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
147
+
148
+ const merged = {...entry.data};
149
+ for (const key of ['text', 'status', 'priority', 'dueAt']) {
150
+ if (key in patch) merged[key] = key === 'text' ? String(patch[key]).trim() : patch[key];
151
+ }
152
+
153
+ const updated = await updateEntry(SLUG, id, merged);
154
+ return reply.send(toTodo(updated));
155
+ });
156
+
157
+ /** DELETE /api/todo/completed - must be registered before /:id */
158
+ fastify.delete('/completed', {preHandler: [authenticate, requirePermission('todo', 'delete')]}, async (request, reply) => {
159
+ const uid = userId(request);
160
+ const all = await loadAll(uid);
161
+ const completed = all.filter(t => t.status === 'completed');
162
+ await Promise.all(completed.map(t => deleteEntry(SLUG, t.id)));
163
+ return reply.send({success: true, removed: completed.length});
164
+ });
165
+
166
+ /** DELETE /api/todo/items/:id */
167
+ fastify.delete('/items/:id', {preHandler: [authenticate, requirePermission('todo', 'delete')]}, async (request, reply) => {
168
+ const uid = userId(request);
169
+ const { id } = request.params;
170
+
171
+ const entry = await getEntry(SLUG, id);
172
+ if (!entry) return reply.code(404).send({error: 'Todo not found'});
173
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
174
+
175
+ await deleteEntry(SLUG, id);
176
+ return reply.send({ success: true });
177
+ });
178
+ }