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.
- 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 +94 -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 +310 -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,93 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client for outbound calls from domma-cms → domma-cms-manager.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* A MANAGED site is one the manager forked, and the manager tells it so in its
|
|
5
|
+
* environment: `MANAGER_URL` (where), `SITE_SLUG` (who) and `FLEET_TOKEN` (the
|
|
6
|
+
* proof, minted per fork and never written down). An unmanaged site has none of
|
|
7
|
+
* these, every call here returns null/[], and the Marketplace screen points at
|
|
8
|
+
* Install from File instead - which is the whole of that customer's procedure.
|
|
9
|
+
*
|
|
10
|
+
* There used to be a `registerInstance()` handshake: HMAC a fingerprint with a
|
|
11
|
+
* shared INSTANCE_SECRET, get a licence token back. It never had a caller, and
|
|
12
|
+
* it was solving a problem this fleet does not have - the manager starts every
|
|
13
|
+
* child itself, so it already knows which site is on the other end. Replacing
|
|
14
|
+
* it with a fork-time token removed a shared secret, a distribution problem and
|
|
15
|
+
* a round trip.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here throws. A manager that is down, slow or mid-restart must leave
|
|
18
|
+
* the site serving pages, so every failure is a warning and an empty answer.
|
|
17
19
|
*/
|
|
18
|
-
async function getCmsVersion() {
|
|
19
|
-
try {
|
|
20
|
-
const pkgPath = join(__dirname, '../../package.json');
|
|
21
|
-
const raw = await readFile(pkgPath, 'utf-8');
|
|
22
|
-
return JSON.parse(raw).version ?? '0.0.0';
|
|
23
|
-
} catch {
|
|
24
|
-
return '0.0.0';
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
20
|
|
|
28
21
|
/**
|
|
29
|
-
*
|
|
22
|
+
* True when this site was started by a manager and can talk to it.
|
|
30
23
|
*
|
|
31
|
-
* @
|
|
32
|
-
* @returns {Promise<{licenseToken: string, instanceId: string}|null>}
|
|
24
|
+
* @returns {boolean}
|
|
33
25
|
*/
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!MANAGER_URL) return null;
|
|
39
|
-
if (!INSTANCE_SECRET) {
|
|
40
|
-
console.warn('[managerClient] INSTANCE_SECRET not set - skipping registerInstance');
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
const cmsVersion = await getCmsVersion();
|
|
46
|
-
const hostname = process.env.HOSTNAME ?? 'unknown';
|
|
47
|
-
|
|
48
|
-
const token = createHmac('sha256', INSTANCE_SECRET)
|
|
49
|
-
.update(fingerprint)
|
|
50
|
-
.digest('hex');
|
|
51
|
-
|
|
52
|
-
const res = await fetch(`${MANAGER_URL}/api/instances/register`, {
|
|
53
|
-
signal: AbortSignal.timeout(10_000),
|
|
54
|
-
method: 'POST',
|
|
55
|
-
headers: {
|
|
56
|
-
'Content-Type': 'application/json',
|
|
57
|
-
'X-Instance-Token': token,
|
|
58
|
-
},
|
|
59
|
-
body: JSON.stringify({ fingerprint, cmsVersion, hostname }),
|
|
60
|
-
});
|
|
26
|
+
export function isManaged() {
|
|
27
|
+
return Boolean(process.env.MANAGER_URL && process.env.FLEET_TOKEN);
|
|
28
|
+
}
|
|
61
29
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
30
|
+
/** The slug this site is known by in its fleet, or null when unmanaged. */
|
|
31
|
+
export function siteSlug() {
|
|
32
|
+
return process.env.SITE_SLUG || null;
|
|
33
|
+
}
|
|
66
34
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
35
|
+
/** Bearer the fork-time token. Read per call: a restart mints a new one. */
|
|
36
|
+
function fleetHeaders() {
|
|
37
|
+
return {Authorization: `Bearer ${process.env.FLEET_TOKEN}`};
|
|
72
38
|
}
|
|
73
39
|
|
|
74
40
|
/**
|
|
75
41
|
* Fetch the plugin catalogue from domma-cms-manager.
|
|
76
42
|
*
|
|
77
|
-
* @
|
|
78
|
-
*
|
|
43
|
+
* @returns {Promise<Array<{slug: string, version: string, displayName: string,
|
|
44
|
+
* description: string, price: number|null, entitled: boolean, licenceStatus: string|null}>>}
|
|
79
45
|
*/
|
|
80
|
-
export async function fetchCatalogue(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (!MANAGER_URL) return [];
|
|
46
|
+
export async function fetchCatalogue() {
|
|
47
|
+
if (!isManaged()) return [];
|
|
84
48
|
|
|
85
49
|
try {
|
|
86
|
-
const res = await fetch(`${MANAGER_URL}/api/plugins/catalogue`, {
|
|
50
|
+
const res = await fetch(`${process.env.MANAGER_URL}/api/plugins/catalogue`, {
|
|
87
51
|
signal: AbortSignal.timeout(10_000),
|
|
88
|
-
headers:
|
|
89
|
-
Authorization: `Bearer ${licenseToken}`,
|
|
90
|
-
},
|
|
52
|
+
headers: fleetHeaders(),
|
|
91
53
|
});
|
|
92
54
|
|
|
93
55
|
if (!res.ok) {
|
|
@@ -103,80 +65,54 @@ export async function fetchCatalogue(licenseToken) {
|
|
|
103
65
|
}
|
|
104
66
|
|
|
105
67
|
/**
|
|
106
|
-
* Fetch a plugin bundle
|
|
68
|
+
* Fetch a plugin bundle from domma-cms-manager.
|
|
69
|
+
*
|
|
70
|
+
* Returns the triple `installPlugin()` takes, plus the entitlement the manager
|
|
71
|
+
* minted for THIS site. The entitlement travels with the bytes it licenses
|
|
72
|
+
* rather than arriving separately, because a paid plugin installed without one
|
|
73
|
+
* is a plugin that will not load - a failure that looks like a broken install
|
|
74
|
+
* rather than a licensing answer.
|
|
107
75
|
*
|
|
108
|
-
* @param
|
|
109
|
-
* @param
|
|
110
|
-
* @
|
|
111
|
-
*
|
|
76
|
+
* @param {string} slug
|
|
77
|
+
* @param {string} version
|
|
78
|
+
* @returns {Promise<{tarball: Buffer, signature: string, publicKeyId: string,
|
|
79
|
+
* entitlement: object|null, version: string}|null>} null when unmanaged or unreachable
|
|
80
|
+
* @throws {Error} when the manager refuses - an unlicensed plugin is an answer
|
|
81
|
+
* worth showing, not a silent empty result
|
|
112
82
|
*/
|
|
113
|
-
export async function fetchPluginBundle(slug, version
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (!MANAGER_URL) return null;
|
|
83
|
+
export async function fetchPluginBundle(slug, version) {
|
|
84
|
+
if (!isManaged()) return null;
|
|
117
85
|
|
|
86
|
+
let res;
|
|
118
87
|
try {
|
|
119
|
-
|
|
120
|
-
signal: AbortSignal.timeout(
|
|
121
|
-
headers:
|
|
122
|
-
Authorization: `Bearer ${licenseToken}`,
|
|
123
|
-
},
|
|
88
|
+
res = await fetch(`${process.env.MANAGER_URL}/api/plugins/install/${slug}/${version}`, {
|
|
89
|
+
signal: AbortSignal.timeout(30_000),
|
|
90
|
+
headers: fleetHeaders(),
|
|
124
91
|
});
|
|
125
|
-
|
|
126
|
-
if (!res.ok) {
|
|
127
|
-
console.warn(`[managerClient] fetchPluginBundle failed: ${res.status} ${res.statusText}`);
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const json = await res.json();
|
|
132
|
-
|
|
133
|
-
if (typeof json.tarball !== 'string' || json.tarball.length > 100 * 1024 * 1024) {
|
|
134
|
-
console.warn('[managerClient] fetchPluginBundle: tarball too large or invalid, refusing decode');
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
tarball: Buffer.from(json.tarball, 'base64'),
|
|
140
|
-
signature: json.signature,
|
|
141
|
-
publicKeyId: json.publicKeyId,
|
|
142
|
-
};
|
|
143
92
|
} catch (err) {
|
|
144
93
|
console.warn('[managerClient] fetchPluginBundle error:', err.message);
|
|
145
94
|
return null;
|
|
146
95
|
}
|
|
147
|
-
}
|
|
148
96
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
export async function heartbeat(licenseToken, installedSlugs) {
|
|
157
|
-
const MANAGER_URL = process.env.MANAGER_URL;
|
|
158
|
-
|
|
159
|
-
if (!MANAGER_URL) return false;
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
const res = await fetch(`${MANAGER_URL}/api/instances/heartbeat`, {
|
|
163
|
-
signal: AbortSignal.timeout(10_000),
|
|
164
|
-
method: 'POST',
|
|
165
|
-
headers: {
|
|
166
|
-
'Content-Type': 'application/json',
|
|
167
|
-
Authorization: `Bearer ${licenseToken}`,
|
|
168
|
-
},
|
|
169
|
-
body: JSON.stringify({ installedSlugs, versions: {} }),
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
if (!res.ok) {
|
|
173
|
-
console.warn(`[managerClient] heartbeat failed: ${res.status} ${res.statusText}`);
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
// 403 is "you have no licence for this", 409 "your catalogue is stale".
|
|
99
|
+
// Both are things the admin can act on, so they are raised rather than
|
|
100
|
+
// logged and swallowed the way an unreachable manager is.
|
|
101
|
+
const body = await res.json().catch(() => ({}));
|
|
102
|
+
throw new Error(body.error || `The manager refused that download (${res.status}).`);
|
|
103
|
+
}
|
|
176
104
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
console.warn('[managerClient]
|
|
180
|
-
return
|
|
105
|
+
const json = await res.json();
|
|
106
|
+
if (typeof json.tarball !== 'string' || json.tarball.length > 100 * 1024 * 1024) {
|
|
107
|
+
console.warn('[managerClient] fetchPluginBundle: tarball too large or invalid, refusing decode');
|
|
108
|
+
return null;
|
|
181
109
|
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
tarball: Buffer.from(json.tarball, 'base64'),
|
|
113
|
+
signature: json.signature,
|
|
114
|
+
publicKeyId: json.publicKeyId,
|
|
115
|
+
entitlement: json.entitlement ?? null,
|
|
116
|
+
version: json.version ?? version,
|
|
117
|
+
};
|
|
182
118
|
}
|
|
@@ -28,6 +28,7 @@ const BUILTIN_SHORTCODES = new Set([
|
|
|
28
28
|
'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
|
|
29
29
|
'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
|
|
30
30
|
'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient', 'list-group', 'menu',
|
|
31
|
+
'demo',
|
|
31
32
|
]);
|
|
32
33
|
|
|
33
34
|
// Configure marked for safe output
|
|
@@ -3423,6 +3424,51 @@ function processIconBlocks(markdown) {
|
|
|
3423
3424
|
));
|
|
3424
3425
|
}
|
|
3425
3426
|
|
|
3427
|
+
// ─── [demo] - embed a local demo page ──────────────────────────────────────
|
|
3428
|
+
|
|
3429
|
+
/** Where [demo src="..."] looks for its pages, unless the tag says otherwise. */
|
|
3430
|
+
const DEMO_BASE_PATH = '/public/demos';
|
|
3431
|
+
const DEMO_DEFAULT_HEIGHT = '500px';
|
|
3432
|
+
|
|
3433
|
+
/**
|
|
3434
|
+
* [demo src="elements/accordion" /]
|
|
3435
|
+
* [demo src="effects/ambient" height="600px" title="Ambient Effect" base="/public/x" /]
|
|
3436
|
+
*
|
|
3437
|
+
* Was the `demo-viewer` plugin until 2026-09-22. It is a shortcode and nothing
|
|
3438
|
+
* else - no admin screen, no routes, no data - so it belongs here rather than
|
|
3439
|
+
* behind an install.
|
|
3440
|
+
*
|
|
3441
|
+
* The src is a SAME-ORIGIN path, which is why `allowIframeRelativeUrls` matters
|
|
3442
|
+
* in the sanitizer below. `base` exists because the plugin had a configurable
|
|
3443
|
+
* basePath; an attribute serves the one site that set it without inventing a
|
|
3444
|
+
* config key nobody else would ever touch.
|
|
3445
|
+
*
|
|
3446
|
+
* @param {string} markdown
|
|
3447
|
+
* @returns {string}
|
|
3448
|
+
*/
|
|
3449
|
+
function processDemoBlocks(markdown) {
|
|
3450
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
3451
|
+
return restore(scrubbed.replace(
|
|
3452
|
+
/\[demo([^\]]*?)\/\]/gi,
|
|
3453
|
+
(_, attrStr) => {
|
|
3454
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
3455
|
+
const src = typeof attrs.src === 'string' ? attrs.src.trim() : '';
|
|
3456
|
+
if (!src) return '';
|
|
3457
|
+
// A src is a path fragment under base, never a URL of its own: anything
|
|
3458
|
+
// carrying a scheme or a protocol-relative prefix would escape the site.
|
|
3459
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('//')) return '';
|
|
3460
|
+
const base = (typeof attrs.base === 'string' && attrs.base.startsWith('/'))
|
|
3461
|
+
? attrs.base.replace(/\/+$/, '')
|
|
3462
|
+
: DEMO_BASE_PATH;
|
|
3463
|
+
const height = attrs.height || DEMO_DEFAULT_HEIGHT;
|
|
3464
|
+
const title = attrs.title || '';
|
|
3465
|
+
const iframeSrc = `${base}/${src.replace(/^\/+/, '')}.html`;
|
|
3466
|
+
const titleBar = title ? `<div class="demo-viewer-title">${escapeAttr(title)}</div>` : '';
|
|
3467
|
+
return `<div class="demo-viewer">${titleBar}<iframe src="${escapeAttr(iframeSrc)}" style="width:100%;height:${escapeAttr(height)};border:none;border-radius:var(--dm-radius, 8px);background:var(--dm-surface, #1a1a2e);" loading="lazy" allowfullscreen></iframe></div>`;
|
|
3468
|
+
}
|
|
3469
|
+
));
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3426
3472
|
// ─── [embed] - video / iframe embeds ───────────────────────────────────────
|
|
3427
3473
|
|
|
3428
3474
|
/**
|
|
@@ -4135,7 +4181,8 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
4135
4181
|
const withSpacer = processSpacerBlocks(withListGroup);
|
|
4136
4182
|
const withCenter = processCenterBlocks(withSpacer);
|
|
4137
4183
|
const withIcon = processIconBlocks(withCenter);
|
|
4138
|
-
const
|
|
4184
|
+
const withDemo = processDemoBlocks(withIcon);
|
|
4185
|
+
const withEmbed = processEmbedBlocks(withDemo);
|
|
4139
4186
|
const withForm = await processFormBlocks(withEmbed, tagSet);
|
|
4140
4187
|
const withHero = processHeroBlocks(withForm);
|
|
4141
4188
|
const withTable = processTableBlocks(withHero);
|
|
@@ -4163,6 +4210,12 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
4163
4210
|
'player.vimeo.com',
|
|
4164
4211
|
...getEmbedAllowedHosts(),
|
|
4165
4212
|
],
|
|
4213
|
+
// sanitize-html drops a RELATIVE iframe src whenever allowedIframeHostnames
|
|
4214
|
+
// is set, because it cannot match a hostname it has not been given. That
|
|
4215
|
+
// silently emptied every [demo] iframe. A relative src is same-origin by
|
|
4216
|
+
// definition, so the hostname gate has nothing to say about it: the gate
|
|
4217
|
+
// exists to stop embedding somebody else's site, not your own page.
|
|
4218
|
+
allowIframeRelativeUrls: true,
|
|
4166
4219
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
|
4167
4220
|
'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
4168
4221
|
'form', 'input', 'textarea', 'select', 'option', 'optgroup',
|
|
@@ -202,6 +202,56 @@ export const REGISTRY = [
|
|
|
202
202
|
{key: 'delete', label: 'Delete', description: 'Remove settings'}
|
|
203
203
|
]
|
|
204
204
|
},
|
|
205
|
+
{
|
|
206
|
+
key: 'contacts',
|
|
207
|
+
label: 'Contacts',
|
|
208
|
+
description: 'Address book with groups, favourites and import/export. Each user sees only their own.',
|
|
209
|
+
icon: 'users',
|
|
210
|
+
group: 'Tools',
|
|
211
|
+
actions: [
|
|
212
|
+
{key: 'read', label: 'View', description: 'View own contacts'},
|
|
213
|
+
{key: 'create', label: 'Create', description: 'Add contacts and groups'},
|
|
214
|
+
{key: 'update', label: 'Edit', description: 'Edit own contacts'},
|
|
215
|
+
{key: 'delete', label: 'Delete', description: 'Delete own contacts'}
|
|
216
|
+
]
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
key: 'analytics',
|
|
220
|
+
label: 'Analytics',
|
|
221
|
+
description: 'Page-view analytics: trends, top pages, journeys and export.',
|
|
222
|
+
icon: 'chart-bar',
|
|
223
|
+
group: 'Tools',
|
|
224
|
+
actions: [
|
|
225
|
+
{key: 'read', label: 'View', description: 'View analytics'},
|
|
226
|
+
{key: 'delete', label: 'Reset', description: 'Reset all counters'}
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
key: 'notes',
|
|
231
|
+
label: 'Notes',
|
|
232
|
+
description: 'Personal notes. Each user sees only their own.',
|
|
233
|
+
icon: 'edit-3',
|
|
234
|
+
group: 'Tools',
|
|
235
|
+
actions: [
|
|
236
|
+
{key: 'read', label: 'View', description: 'View own notes'},
|
|
237
|
+
{key: 'create', label: 'Create', description: 'Write a note'},
|
|
238
|
+
{key: 'update', label: 'Edit', description: 'Edit own notes'},
|
|
239
|
+
{key: 'delete', label: 'Delete', description: 'Delete own notes'}
|
|
240
|
+
]
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
key: 'todo',
|
|
244
|
+
label: 'Todo',
|
|
245
|
+
description: 'Personal task list. Each user sees only their own.',
|
|
246
|
+
icon: 'check-square',
|
|
247
|
+
group: 'Tools',
|
|
248
|
+
actions: [
|
|
249
|
+
{key: 'read', label: 'View', description: 'View own tasks'},
|
|
250
|
+
{key: 'create', label: 'Create', description: 'Add a task'},
|
|
251
|
+
{key: 'update', label: 'Edit', description: 'Edit own tasks'},
|
|
252
|
+
{key: 'delete', label: 'Delete', description: 'Delete own tasks'}
|
|
253
|
+
]
|
|
254
|
+
},
|
|
205
255
|
{
|
|
206
256
|
key: 'plugins',
|
|
207
257
|
label: 'Plugins',
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlement verification - may this plugin run here?
|
|
3
|
+
*
|
|
4
|
+
* Only asked of a plugin declaring `closedSource: true`. A plugin the user wrote
|
|
5
|
+
* has no entitlement, needs none, and must never be asked for one: the gate keys
|
|
6
|
+
* on a flag WE set on OUR paid plugins, so the free builder is untouched by all
|
|
7
|
+
* of this.
|
|
8
|
+
*
|
|
9
|
+
* The two commercial models expire in completely different ways, and getting it
|
|
10
|
+
* backwards is the mistake with real consequences:
|
|
11
|
+
*
|
|
12
|
+
* rental a managed site pays monthly. Past its term plus grace, it stops.
|
|
13
|
+
* perpetual bought outright. It NEVER stops. What lapses is support and
|
|
14
|
+
* updates - so a customer who paid a large sum once and declined
|
|
15
|
+
* the renewal keeps what they bought, forever, offline.
|
|
16
|
+
*
|
|
17
|
+
* A wildcard entitlement (`slug: "*"`) satisfies any slug. That is how an owner
|
|
18
|
+
* instance works: the manager is where plugins are written, so a plugin being
|
|
19
|
+
* built today has no slug in any ledger and a per-slug entitlement could not
|
|
20
|
+
* name it.
|
|
21
|
+
*
|
|
22
|
+
* @module server/services/pluginEntitlement
|
|
23
|
+
*/
|
|
24
|
+
import {createPublicKey, verify as cryptoVerify} from 'node:crypto';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import {trustedKey} from './pluginInstaller.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Canonical JSON: keys sorted at every depth, no whitespace, undefined dropped.
|
|
31
|
+
*
|
|
32
|
+
* A deliberate verbatim copy of the signer's, in dcms-marketplace's
|
|
33
|
+
* scripts/entitlement.js. The signature is over bytes, and a verifier that
|
|
34
|
+
* disagrees by one space rejects everything for a reason nobody can see. It is
|
|
35
|
+
* duplicated rather than shared because the signer is not, and must not be, a
|
|
36
|
+
* dependency of the CMS.
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function canonicalise(value) {
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return '[' + value.map(canonicalise).join(',') + ']';
|
|
44
|
+
}
|
|
45
|
+
if (value && typeof value === 'object') {
|
|
46
|
+
const keys = Object.keys(value).filter(k => value[k] !== undefined).sort();
|
|
47
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalise(value[k])).join(',') + '}';
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does this entitlement cover the named plugin? */
|
|
53
|
+
export function coversSlug(entitlement, slug) {
|
|
54
|
+
return entitlement?.slug === '*' || entitlement?.slug === slug;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find the entitlement covering a plugin.
|
|
59
|
+
*
|
|
60
|
+
* Two places, in order:
|
|
61
|
+
*
|
|
62
|
+
* 1. `plugins/<slug>/.entitlement.json` - what an envelope carried in. One
|
|
63
|
+
* plugin, one licence, sitting beside the thing it licenses.
|
|
64
|
+
* 2. `config/entitlements/*.json` - INSTANCE level. This is where an owner
|
|
65
|
+
* wildcard lives, and it has to be here rather than in a plugin directory:
|
|
66
|
+
* a wildcard filed under one plugin would cover that plugin only, which
|
|
67
|
+
* defeats the whole point of it.
|
|
68
|
+
*
|
|
69
|
+
* A per-plugin file wins. It is the more specific claim, and it is the one a
|
|
70
|
+
* renewal writes.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} slug
|
|
73
|
+
* @returns {object|null}
|
|
74
|
+
*/
|
|
75
|
+
export function readEntitlement(slug) {
|
|
76
|
+
const own = path.join(path.resolve('plugins'), slug, '.entitlement.json');
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(own, 'utf8'));
|
|
79
|
+
} catch { /* fall through to the instance level */ }
|
|
80
|
+
|
|
81
|
+
const dir = path.resolve('config', 'entitlements');
|
|
82
|
+
let names;
|
|
83
|
+
try {
|
|
84
|
+
names = fs.readdirSync(dir).filter(n => n.endsWith('.json')).sort();
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
try {
|
|
90
|
+
const e = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
91
|
+
if (coversSlug(e, slug)) return e;
|
|
92
|
+
} catch { /* a malformed file is not an entitlement */ }
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Classify a plugin's entitlement.
|
|
99
|
+
*
|
|
100
|
+
* Returns one of:
|
|
101
|
+
* 'valid' load it
|
|
102
|
+
* 'grace' load it, and say so - a rental just past its term
|
|
103
|
+
* 'support-ended' load it - a perpetual licence whose updates have stopped
|
|
104
|
+
* 'expired' do NOT load - a rental past its grace period
|
|
105
|
+
* 'unlicensed' do NOT load - absent, malformed, or not signed by us
|
|
106
|
+
*
|
|
107
|
+
* @param {string} slug
|
|
108
|
+
* @param {object|null} [entitlement] - injectable for testing
|
|
109
|
+
* @param {Date} [now]
|
|
110
|
+
* @returns {{state: string, reason: string, entitlement: object|null}}
|
|
111
|
+
*/
|
|
112
|
+
export function classifyEntitlement(slug, entitlement = readEntitlement(slug), now = new Date()) {
|
|
113
|
+
if (!entitlement) {
|
|
114
|
+
return {state: 'unlicensed', reason: 'no entitlement file', entitlement: null};
|
|
115
|
+
}
|
|
116
|
+
if (!coversSlug(entitlement, slug)) {
|
|
117
|
+
return {state: 'unlicensed', reason: `entitlement is for "${entitlement.slug}"`, entitlement};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pem = trustedKey(entitlement.keyId);
|
|
121
|
+
if (!pem) {
|
|
122
|
+
return {state: 'unlicensed', reason: `unknown signing key "${entitlement.keyId}"`, entitlement};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const {signature, ...rest} = entitlement;
|
|
126
|
+
let ok = false;
|
|
127
|
+
try {
|
|
128
|
+
ok = cryptoVerify(null, Buffer.from(canonicalise(rest), 'utf8'),
|
|
129
|
+
createPublicKey(pem), Buffer.from(String(signature), 'base64'));
|
|
130
|
+
} catch {
|
|
131
|
+
ok = false;
|
|
132
|
+
}
|
|
133
|
+
if (!ok) return {state: 'unlicensed', reason: 'signature does not verify', entitlement};
|
|
134
|
+
|
|
135
|
+
const ms = now.getTime();
|
|
136
|
+
const graceMs = (Number(entitlement.gracePeriodDays) || 0) * 86_400_000;
|
|
137
|
+
|
|
138
|
+
if (entitlement.model === 'rental') {
|
|
139
|
+
const end = Date.parse(entitlement.expiresAt);
|
|
140
|
+
if (!Number.isFinite(end)) {
|
|
141
|
+
return {state: 'unlicensed', reason: 'rental with no expiry', entitlement};
|
|
142
|
+
}
|
|
143
|
+
if (ms <= end) return {state: 'valid', reason: '', entitlement};
|
|
144
|
+
if (ms <= end + graceMs) return {state: 'grace', reason: 'rental term ended', entitlement};
|
|
145
|
+
return {state: 'expired', reason: 'rental term and grace period ended', entitlement};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Perpetual. Support may have lapsed; the licence has not, and never will.
|
|
149
|
+
const support = Date.parse(entitlement.supportExpiresAt);
|
|
150
|
+
if (Number.isFinite(support) && ms > support) {
|
|
151
|
+
return {state: 'support-ended', reason: 'support and updates ended', entitlement};
|
|
152
|
+
}
|
|
153
|
+
return {state: 'valid', reason: '', entitlement};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The states that let a plugin load. */
|
|
157
|
+
const LOADABLE = new Set(['valid', 'grace', 'support-ended']);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* May this plugin load?
|
|
161
|
+
*
|
|
162
|
+
* @param {object} manifest
|
|
163
|
+
* @returns {{allowed: boolean, state: string, reason: string}}
|
|
164
|
+
*/
|
|
165
|
+
export function mayLoad(manifest) {
|
|
166
|
+
if (manifest?.closedSource !== true) {
|
|
167
|
+
return {allowed: true, state: 'not-required', reason: ''};
|
|
168
|
+
}
|
|
169
|
+
const {state, reason} = classifyEntitlement(manifest.name);
|
|
170
|
+
return {allowed: LOADABLE.has(state), state, reason};
|
|
171
|
+
}
|