domma-cms 0.66.0 → 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.
- package/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +92 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
|
@@ -1,32 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin Marketplace API
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 (!
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
if (!isManaged()) return reply.send({ available: false, catalogue: [] });
|
|
29
|
+
|
|
30
|
+
const catalogue = await fetchCatalogue();
|
|
21
31
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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: '
|
|
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,
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
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
|
}
|