domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,32 +1,57 @@
1
1
  /**
2
- * Plugin Marketplace API
3
- * GET /api/plugins/marketplace - fetch catalogue from manager (requires MANAGER_URL)
4
- * POST /api/plugins/marketplace/install - download + install a plugin bundle
5
- * DELETE /api/plugins/marketplace/:slug - uninstall an installed plugin
2
+ * Plugin Marketplace API - the managed (fleet) path.
3
+ *
4
+ * GET /api/plugins/marketplace - the catalogue as this site sees it
5
+ * POST /api/plugins/marketplace/install - fetch a bundle and install it
6
+ * DELETE /api/plugins/marketplace/:slug - uninstall
7
+ *
8
+ * This is delivery path A: a site the manager forked, buying from the fleet's
9
+ * own catalogue over loopback. The standalone path - buy, download one file,
10
+ * Install from File - goes through `POST /plugins/install-upload` instead and
11
+ * shares nothing with this but the installer.
12
+ *
13
+ * An unmanaged site has no MANAGER_URL, every route here answers "not
14
+ * available", and the screen says so in terms that fit that customer.
6
15
  *
7
16
  * All routes require authentication + admin role.
8
17
  */
9
18
  import {authenticate, requireAdmin} from '../../middleware/auth.js';
10
- import {fetchCatalogue, fetchPluginBundle} from '../../services/managerClient.js';
11
- import {installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
19
+ import {fetchCatalogue, fetchPluginBundle, isManaged, siteSlug} from '../../services/managerClient.js';
20
+ import {compareVersions, installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
21
+ import {discoverPlugins} from '../../services/plugins.js';
12
22
 
13
23
  export async function pluginMarketplaceRoutes(fastify) {
14
24
  const adminOnly = { preHandler: [authenticate, requireAdmin] };
15
25
 
16
26
  // GET /plugins/marketplace
17
27
  fastify.get('/plugins/marketplace', adminOnly, async (_request, reply) => {
18
- if (!process.env.MANAGER_URL) {
19
- return reply.send({ available: false, catalogue: [] });
20
- }
28
+ if (!isManaged()) return reply.send({ available: false, catalogue: [] });
29
+
30
+ const catalogue = await fetchCatalogue();
21
31
 
22
- const licenceToken = process.env.LICENCE_TOKEN;
23
- const catalogue = await fetchCatalogue(licenceToken);
24
- return reply.send({ available: true, catalogue });
32
+ // What is on THIS disk is a local fact - the manager's ledger knows
33
+ // what a site is licensed for, never what it actually has. Joined here
34
+ // so the card can offer Update rather than an Install that would come
35
+ // back 409.
36
+ const installed = new Map((await discoverPlugins()).map(m => [m.name, m.version]));
37
+ return reply.send({
38
+ available: true,
39
+ site: siteSlug(),
40
+ catalogue: catalogue.map(entry => {
41
+ const have = installed.get(entry.slug);
42
+ return {
43
+ ...entry,
44
+ installed: have !== undefined,
45
+ installedVersion: have ?? null,
46
+ updatable: have !== undefined && compareVersions(entry.version, have) > 0
47
+ };
48
+ })
49
+ });
25
50
  });
26
51
 
27
52
  // POST /plugins/marketplace/install
28
53
  fastify.post('/plugins/marketplace/install', adminOnly, async (request, reply) => {
29
- const { slug, version } = request.body || {};
54
+ const { slug, version, update } = request.body || {};
30
55
 
31
56
  if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
32
57
  return reply.code(400).send({ error: 'Invalid plugin slug.' });
@@ -34,25 +59,55 @@ export async function pluginMarketplaceRoutes(fastify) {
34
59
  if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
35
60
  return reply.code(400).send({ error: 'Invalid version format. Expected semver (e.g. 1.0.0).' });
36
61
  }
62
+ if (!isManaged()) {
63
+ return reply.code(503).send({ error: 'This site is not managed - install from a file instead.' });
64
+ }
37
65
 
38
- const licenceToken = process.env.LICENCE_TOKEN;
39
- const bundle = await fetchPluginBundle(slug, version, licenceToken);
40
-
66
+ let bundle;
67
+ try {
68
+ bundle = await fetchPluginBundle(slug, version);
69
+ } catch (err) {
70
+ // A refusal from the manager is the answer, not a failure: "this
71
+ // site has no licence for invoice" is what the admin needs to read.
72
+ return reply.code(403).send({ error: err.message });
73
+ }
41
74
  if (!bundle) {
42
- return reply.code(503).send({ error: 'Manager unavailable' });
75
+ return reply.code(503).send({ error: 'The manager is not reachable right now.' });
43
76
  }
44
77
 
45
78
  try {
46
- await installPlugin(slug, bundle);
79
+ const result = await installPlugin(slug, {
80
+ tarball: bundle.tarball,
81
+ signature: bundle.signature,
82
+ publicKeyId: bundle.publicKeyId,
83
+ // Minted for this site by the manager. Without it a paid plugin
84
+ // installs and then refuses to load, which reads as a broken
85
+ // install rather than a licensing answer.
86
+ entitlement: bundle.entitlement,
87
+ // Same plugin already here means update, which the admin asked
88
+ // for by pressing Update rather than Install.
89
+ allowUpgrade: update === true
90
+ });
91
+ return reply.send({
92
+ success: true,
93
+ name: slug,
94
+ action: result?.action ?? 'install',
95
+ from: result?.from ?? null,
96
+ version: result?.version ?? bundle.version,
97
+ restartRequired: true
98
+ });
47
99
  } catch (err) {
48
100
  fastify.log.error({ err }, '[marketplace] install failed');
49
- // Known safe error messages from pluginInstaller (for admin clarity)
50
- const safeMessages = ['Plugin already installed', 'Plugin signature invalid', 'Unknown public key', 'Invalid plugin slug'];
51
- const safe = safeMessages.some(m => err.message.startsWith(m));
101
+ // Messages the installer raises deliberately, all of them things an
102
+ // admin can act on. Anything else is kept vague on purpose - an
103
+ // unexpected error can carry a path from this server.
104
+ const safe = [
105
+ 'Plugin already installed', 'Plugin signature invalid', 'Unknown public key',
106
+ 'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade',
107
+ 'This site accepts signed plugins only'
108
+ ].some(m => err.message.startsWith(m));
52
109
  return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
53
110
  }
54
-
55
- return reply.send({ success: true });
56
111
  });
57
112
 
58
113
  // DELETE /plugins/marketplace/:slug
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  discoverPlugins,
14
14
  getAdminPluginConfig,
15
+ getEntitlementStates,
15
16
  getLoadedPlugins,
16
17
  getPluginStates,
17
18
  isCorePlugin,
@@ -23,6 +24,31 @@ import {getEffectiveLevel} from '../../services/userRoles.js';
23
24
  import {scaffoldPlugin} from '../../services/pluginScaffold.js';
24
25
  import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
25
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
+ }
26
52
 
27
53
  /**
28
54
  * True when something will bring the process back after a clean exit.
@@ -86,6 +112,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
86
112
  const manifests = await discoverPlugins();
87
113
  const states = getPluginStates();
88
114
  const loadedPlugins = getLoadedPlugins();
115
+ const entitlementStates = getEntitlementStates();
89
116
 
90
117
  return manifests.map(manifest => ({
91
118
  name: manifest.name,
@@ -100,13 +127,15 @@ export async function pluginsRoutes(fastify, opts = {}) {
100
127
  // plainly running. It is on because it is built in.
101
128
  core: isCorePlugin(manifest.name),
102
129
  enabled: isCorePlugin(manifest.name) || !!(states[manifest.name]?.enabled),
103
- bundled: !!(states[manifest.name]?.bundled),
104
130
  // A licensed plugin is distributed to run, not to be read: the
105
131
  // source endpoints refuse it (pluginFiles.isClosedSource). Sent so
106
132
  // the screen can leave the Code button off rather than offer one
107
133
  // that answers 403 - the refusal is the guard, not the UI.
108
134
  closedSource: manifest.closedSource === true,
109
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,
110
139
  // Whether the CODE button belongs on this viewer's card. Decided
111
140
  // here rather than in the browser: it is the same answer the file
112
141
  // endpoints will give, from the same two facts.
@@ -135,7 +164,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
135
164
  // Enable/disable or update settings for a plugin
136
165
  fastify.put('/plugins/:name', canUpdate, async (request, reply) => {
137
166
  const { name } = request.params;
138
- const {enabled, settings, bundled} = request.body || {};
167
+ const {enabled, settings} = request.body || {};
139
168
 
140
169
  const manifests = await discoverPlugins();
141
170
  const manifest = manifests.find(m => m.name === name);
@@ -155,7 +184,6 @@ export async function pluginsRoutes(fastify, opts = {}) {
155
184
  const update = {};
156
185
  if (enabled !== undefined) update.enabled = !!enabled;
157
186
  if (settings !== undefined) update.settings = settings;
158
- if (bundled !== undefined) update.bundled = !!bundled;
159
187
  savePluginState(name, update);
160
188
 
161
189
  if (!prevState.enabled && !!enabled) {
@@ -203,6 +231,123 @@ export async function pluginsRoutes(fastify, opts = {}) {
203
231
  const licensed = (request) => ({allowLicensed: mayEditLicensed(request)});
204
232
 
205
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
+
206
351
  fastify.get('/plugins/:name/files', canDevelop, async (request, reply) => {
207
352
  try {
208
353
  return { files: await listPluginFiles(request.params.name, licensed(request)) };
@@ -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
+ }
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
@@ -229,6 +247,19 @@ try {
229
247
  app.log.warn(`[theme] Migration skipped: ${err.message}`);
230
248
  }
231
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
+
232
263
  // ---------------------------------------------------------------------------
233
264
  // Search - one-shot retirement of the site-search plugin (settings + files)
234
265
  // ---------------------------------------------------------------------------
@@ -414,6 +445,10 @@ const {sidebarRoutes} = await import('./routes/api/sidebar.js');
414
445
  const { mediaRoutes } = await import('./routes/api/media.js');
415
446
  const { usersRoutes } = await import('./routes/api/users.js');
416
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');
417
452
  const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
418
453
  const { collectionsRoutes } = await import('./routes/api/collections.js');
419
454
  const { formsRoutes } = await import('./routes/api/forms.js');
@@ -459,11 +494,36 @@ await app.register(notificationsRoutes, {prefix: '/api'});
459
494
  await app.register(dashboardRoutes, {prefix: '/api'});
460
495
  await app.register(cacheRoutes, {prefix: '/api'});
461
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'});
462
502
 
463
503
  // ---------------------------------------------------------------------------
464
504
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
465
505
  // ---------------------------------------------------------------------------
466
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
+
467
527
  await registerPlugins(app);
468
528
 
469
529
  // ---------------------------------------------------------------------------
@@ -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
  }