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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.dcmsplugin` envelope - one file a plugin travels in.
|
|
3
|
+
*
|
|
4
|
+
* A gzipped tar carrying three members at the top level:
|
|
5
|
+
*
|
|
6
|
+
* plugin.tar.gz the plugin directory, the same bytes the catalogue ships
|
|
7
|
+
* plugin.sig detached Ed25519 signature over plugin.tar.gz, base64.
|
|
8
|
+
* OPTIONAL: an export from a running CMS cannot be signed,
|
|
9
|
+
* because the marketplace key is offline and never on a
|
|
10
|
+
* customer's host.
|
|
11
|
+
* entitlement.json OPTIONAL: present on a paid download, absent on an export
|
|
12
|
+
*
|
|
13
|
+
* One format for all three delivery paths - catalogue bundle, paid download, and
|
|
14
|
+
* a plain export between two installations - so every one of them enters through
|
|
15
|
+
* the same installer and gets the same traversal guard, manifest validation and
|
|
16
|
+
* cleanup on failure. A second "just unzip it" path would be the one that skips a
|
|
17
|
+
* check.
|
|
18
|
+
*
|
|
19
|
+
* Tar is written and read here rather than shelled out to, because a CMS is not
|
|
20
|
+
* guaranteed to have the binary and because `extractTar` in pluginInstaller.js
|
|
21
|
+
* deliberately strips the leading path component - correct for a plugin tarball,
|
|
22
|
+
* wrong for an envelope whose members sit at the root. The two readers answer
|
|
23
|
+
* different questions and are kept apart on purpose.
|
|
24
|
+
*
|
|
25
|
+
* @module server/services/pluginEnvelope
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'fs/promises';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import zlib from 'zlib';
|
|
30
|
+
|
|
31
|
+
const BLOCK = 512;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A ustar header for one member.
|
|
35
|
+
*
|
|
36
|
+
* Names are capped at 100 bytes, which is the format's own limit and not worth
|
|
37
|
+
* the GNU long-name extension for three fixed filenames.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @param {number} size
|
|
41
|
+
* @returns {Buffer}
|
|
42
|
+
*/
|
|
43
|
+
function header(name, size) {
|
|
44
|
+
if (Buffer.byteLength(name) > 100) throw new Error(`envelope member name too long: ${name}`);
|
|
45
|
+
const h = Buffer.alloc(BLOCK);
|
|
46
|
+
h.write(name, 0, 100, 'utf8');
|
|
47
|
+
h.write('0000644\0', 100); // mode
|
|
48
|
+
h.write('0000000\0', 108); // uid
|
|
49
|
+
h.write('0000000\0', 116); // gid
|
|
50
|
+
h.write(size.toString(8).padStart(11, '0') + '\0', 124);
|
|
51
|
+
// Fixed mtime: the envelope's bytes should depend on its contents and
|
|
52
|
+
// nothing else, so the same plugin exported twice compares equal.
|
|
53
|
+
h.write('00000000000\0', 136);
|
|
54
|
+
h.write(' ', 148); // checksum placeholder
|
|
55
|
+
h.write('0', 156); // type: regular file
|
|
56
|
+
h.write('ustar\0', 257);
|
|
57
|
+
h.write('00', 263);
|
|
58
|
+
|
|
59
|
+
let sum = 0;
|
|
60
|
+
for (const b of h) sum += b;
|
|
61
|
+
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
|
62
|
+
return h;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pad a buffer up to the next 512-byte boundary. */
|
|
66
|
+
function pad(buf) {
|
|
67
|
+
const over = buf.length % BLOCK;
|
|
68
|
+
return over === 0 ? buf : Buffer.concat([buf, Buffer.alloc(BLOCK - over)]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What never travels in an export.
|
|
73
|
+
*
|
|
74
|
+
* `data/` is the site's runtime state, not the plugin's - the same carve-out the
|
|
75
|
+
* npm package and the catalogue build both make. `CLAUDE.md` is working notes.
|
|
76
|
+
*/
|
|
77
|
+
export const EXPORT_EXCLUDES = new Set(['data', 'backups', 'node_modules', 'CLAUDE.md', '.DS_Store']);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Tar a plugin directory into the shape `pluginInstaller.extractTar()` expects:
|
|
81
|
+
* every entry under a single leading component, which that parser strips.
|
|
82
|
+
*
|
|
83
|
+
* Sorted, with a fixed mtime, so the same plugin exported twice produces
|
|
84
|
+
* identical bytes - which is what lets two exports be compared at all.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} dir - absolute plugin directory
|
|
87
|
+
* @param {string} prefix - the leading component, normally the slug
|
|
88
|
+
* @param {{includeData?: boolean}} [opts]
|
|
89
|
+
* @returns {Promise<Buffer>} gzipped tar
|
|
90
|
+
*/
|
|
91
|
+
export async function tarDirectory(dir, prefix, {includeData = false} = {}) {
|
|
92
|
+
const files = [];
|
|
93
|
+
async function walk(abs, rel) {
|
|
94
|
+
const entries = await fs.readdir(abs, {withFileTypes: true});
|
|
95
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
96
|
+
const skip = EXPORT_EXCLUDES.has(e.name) && !(includeData && e.name === 'data');
|
|
97
|
+
if (skip) continue;
|
|
98
|
+
const next = rel ? `${rel}/${e.name}` : e.name;
|
|
99
|
+
// Symlinks are neither followed nor recorded: extractTar ignores them
|
|
100
|
+
// on the way in, so carrying one would only ever be a surprise.
|
|
101
|
+
if (e.isSymbolicLink()) continue;
|
|
102
|
+
if (e.isDirectory()) await walk(path.join(abs, e.name), next);
|
|
103
|
+
else if (e.isFile()) files.push([next, await fs.readFile(path.join(abs, e.name))]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await walk(dir, '');
|
|
107
|
+
|
|
108
|
+
const chunks = [];
|
|
109
|
+
for (const [rel, body] of files) {
|
|
110
|
+
chunks.push(header(`${prefix}/${rel}`, body.length), pad(body));
|
|
111
|
+
}
|
|
112
|
+
chunks.push(Buffer.alloc(BLOCK * 2));
|
|
113
|
+
return zlib.gzipSync(Buffer.concat(chunks), {level: 9});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build an envelope.
|
|
118
|
+
*
|
|
119
|
+
* @param {{tarball: Buffer, signature?: string|null, entitlement?: object|null}} parts
|
|
120
|
+
* @returns {Buffer} gzipped tar
|
|
121
|
+
*/
|
|
122
|
+
export function packEnvelope({tarball, signature = null, entitlement = null}) {
|
|
123
|
+
if (!Buffer.isBuffer(tarball) || !tarball.length) {
|
|
124
|
+
throw new Error('packEnvelope: tarball is required');
|
|
125
|
+
}
|
|
126
|
+
const members = [['plugin.tar.gz', tarball]];
|
|
127
|
+
if (signature) members.push(['plugin.sig', Buffer.from(String(signature), 'utf8')]);
|
|
128
|
+
if (entitlement) {
|
|
129
|
+
members.push(['entitlement.json', Buffer.from(JSON.stringify(entitlement, null, 2) + '\n', 'utf8')]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const chunks = [];
|
|
133
|
+
for (const [name, body] of members) {
|
|
134
|
+
chunks.push(header(name, body.length), pad(body));
|
|
135
|
+
}
|
|
136
|
+
chunks.push(Buffer.alloc(BLOCK * 2)); // end-of-archive
|
|
137
|
+
return zlib.gzipSync(Buffer.concat(chunks), {level: 9});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read the manifest out of a plugin tarball, without extracting it.
|
|
142
|
+
*
|
|
143
|
+
* The slug an upload installs under comes from here, never from the uploaded
|
|
144
|
+
* filename: a file can be renamed by anyone, a manifest is the thing the
|
|
145
|
+
* installer then validates against what was extracted.
|
|
146
|
+
*
|
|
147
|
+
* @param {Buffer} tarball - gzipped tar with a single leading component
|
|
148
|
+
* @returns {object|null} the parsed manifest, or null if there is not one
|
|
149
|
+
*/
|
|
150
|
+
export function readManifestFromTarball(tarball) {
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = zlib.gunzipSync(tarball);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
let offset = 0;
|
|
158
|
+
let zeros = 0;
|
|
159
|
+
while (offset + BLOCK <= raw.length) {
|
|
160
|
+
const h = raw.subarray(offset, offset + BLOCK);
|
|
161
|
+
if (h.every(b => b === 0)) {
|
|
162
|
+
if (++zeros >= 2) break;
|
|
163
|
+
offset += BLOCK;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
zeros = 0;
|
|
167
|
+
offset += BLOCK;
|
|
168
|
+
const nul = h.indexOf(0);
|
|
169
|
+
const name = h.subarray(0, nul === -1 ? 100 : Math.min(nul, 100)).toString('utf8');
|
|
170
|
+
const size = parseInt(h.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim(), 8) || 0;
|
|
171
|
+
// `<slug>/plugin.json` and nothing deeper - a plugin.json further down
|
|
172
|
+
// the tree belongs to something else.
|
|
173
|
+
if (/^[^/]+\/plugin\.json$/.test(name)) {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(raw.subarray(offset, offset + size).toString('utf8'));
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read an envelope.
|
|
187
|
+
*
|
|
188
|
+
* Refuses anything it does not recognise rather than guessing: an envelope is
|
|
189
|
+
* our own format, so a member we did not write is a sign the file is not what
|
|
190
|
+
* the caller thinks it is.
|
|
191
|
+
*
|
|
192
|
+
* @param {Buffer} buffer - the .dcmsplugin file
|
|
193
|
+
* @returns {{tarball: Buffer, signature: string|null, entitlement: object|null}}
|
|
194
|
+
*/
|
|
195
|
+
export function readEnvelope(buffer) {
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = zlib.gunzipSync(buffer);
|
|
199
|
+
} catch {
|
|
200
|
+
throw new Error('Not a .dcmsplugin file: it is not gzipped');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const found = new Map();
|
|
204
|
+
let offset = 0;
|
|
205
|
+
let zeros = 0;
|
|
206
|
+
while (offset + BLOCK <= raw.length) {
|
|
207
|
+
const h = raw.subarray(offset, offset + BLOCK);
|
|
208
|
+
if (h.every(b => b === 0)) {
|
|
209
|
+
if (++zeros >= 2) break;
|
|
210
|
+
offset += BLOCK;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
zeros = 0;
|
|
214
|
+
offset += BLOCK;
|
|
215
|
+
|
|
216
|
+
const nul = h.indexOf(0);
|
|
217
|
+
const name = h.subarray(0, nul === -1 ? 100 : Math.min(nul, 100)).toString('utf8');
|
|
218
|
+
const size = parseInt(h.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim(), 8) || 0;
|
|
219
|
+
// Members sit at the root - no directories, no nesting. Anything else is
|
|
220
|
+
// not an envelope we wrote.
|
|
221
|
+
if (name && !name.includes('/')) found.set(name, raw.subarray(offset, offset + size));
|
|
222
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const tarball = found.get('plugin.tar.gz');
|
|
226
|
+
if (!tarball) throw new Error('Not a .dcmsplugin file: no plugin.tar.gz inside');
|
|
227
|
+
|
|
228
|
+
let entitlement = null;
|
|
229
|
+
if (found.has('entitlement.json')) {
|
|
230
|
+
try {
|
|
231
|
+
entitlement = JSON.parse(found.get('entitlement.json').toString('utf8'));
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Error('The entitlement inside this file is not readable JSON');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
tarball: Buffer.from(tarball),
|
|
239
|
+
signature: found.has('plugin.sig') ? found.get('plugin.sig').toString('utf8').trim() : null,
|
|
240
|
+
entitlement
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -15,7 +15,9 @@ import fs from 'fs/promises';
|
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import zlib from 'zlib';
|
|
17
17
|
import {fileURLToPath} from 'url';
|
|
18
|
-
import {
|
|
18
|
+
import {getConfig} from '../config.js';
|
|
19
|
+
import {discoverPlugins, validatePluginDir} from './plugins.js';
|
|
20
|
+
import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Paths
|
|
@@ -27,26 +29,100 @@ const CONFIG_PATH = path.join(ROOT, 'config', 'plugins.json');
|
|
|
27
29
|
const AUDIT_LOG = path.join(ROOT, 'content', 'instances_log.json');
|
|
28
30
|
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
30
|
-
// Trusted public keys
|
|
31
|
-
//
|
|
32
|
+
// Trusted public keys
|
|
33
|
+
//
|
|
34
|
+
// Ed25519. Rotation is pinned by CMS version: a release trusts the keys listed
|
|
35
|
+
// here and nothing else, so a bundle signed by a key this version has never
|
|
36
|
+
// heard of is refused by keyId before its signature is even checked.
|
|
37
|
+
//
|
|
38
|
+
// `manager-2026` was REMOVED rather than retained. Its private half could not be
|
|
39
|
+
// found, and a key we cannot find is not one we know to be destroyed - leaving
|
|
40
|
+
// its public half here would mean anyone who does have it can sign a bundle
|
|
41
|
+
// every CMS accepts as ours, permanently. Nothing had ever been signed with it,
|
|
42
|
+
// so there was no artifact to stay compatible with.
|
|
43
|
+
//
|
|
44
|
+
// Private counterpart: generated 2026-09-22, held off-repo. See the marketplace
|
|
45
|
+
// design's Signing keys section for custody.
|
|
32
46
|
// ---------------------------------------------------------------------------
|
|
33
47
|
|
|
34
48
|
const TRUSTED_KEYS = {
|
|
35
|
-
'
|
|
36
|
-
|
|
37
|
-
zSJRXIvRe8LDNzHvoez9gOu0SYUd2O8nsDOiLohsPxhH5+c8eXdBOkH+1y1O5QaN
|
|
38
|
-
TmZTRGrRR0NrxhPVTT7trtJIiXH4IwgwWgzYVmCKOdKqX4wj2JwGbiPE57ubOBKE
|
|
39
|
-
4T66OUIZmezSYYkhPmWAUCWmCX9KxL6czbMtNZYIn9U2O04imD/VtBieB4xCZy0P
|
|
40
|
-
U9vE3Uz4bcjLjjo70+oWXtAuudlLUiMREJKcT6J72CZSW6rzuR1+NDJsh4q5C6jd
|
|
41
|
-
e+B83PU+WiFxNwoHHJ91myEY4vDDWDDYV8G+sS/4QzBOgL9StwwvQzBuriWKjl8J
|
|
42
|
-
eQIDAQAB
|
|
49
|
+
'marketplace-1': `-----BEGIN PUBLIC KEY-----
|
|
50
|
+
MCowBQYDK2VwAyEAkgYWv0IckLy2lCeNoaNot++KMJLpJa6FTd6RW0sH2zg=
|
|
43
51
|
-----END PUBLIC KEY-----`
|
|
44
52
|
};
|
|
45
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Does this site refuse anything the marketplace has not signed?
|
|
56
|
+
*
|
|
57
|
+
* Default false, because a CMS whose own scaffolder produces unsigned plugins
|
|
58
|
+
* cannot refuse unsigned plugins without refusing its user's own work. A fleet
|
|
59
|
+
* site should set it true: everything it receives arrives by push-copy, and
|
|
60
|
+
* nothing legitimate is ever uploaded there.
|
|
61
|
+
*
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
function requireSignedPlugins() {
|
|
65
|
+
try {
|
|
66
|
+
return getConfig('server')?.requireSignedPlugins === true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One trusted public key by id, or null.
|
|
74
|
+
*
|
|
75
|
+
* Entitlement verification needs the same keys bundle installation does, and a
|
|
76
|
+
* second copy of them would be a second thing to rotate.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} keyId
|
|
79
|
+
* @returns {string|null} PEM
|
|
80
|
+
*/
|
|
81
|
+
export function trustedKey(keyId) {
|
|
82
|
+
return TRUSTED_KEYS[keyId] ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The keyIds this CMS version trusts.
|
|
87
|
+
*
|
|
88
|
+
* Exported so the contract is testable without exposing key material: a release
|
|
89
|
+
* trusting a key it should not, or having lost the one it should, is the kind of
|
|
90
|
+
* mistake that is invisible until a bundle is refused in the field.
|
|
91
|
+
*
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function trustedKeyIds() {
|
|
95
|
+
return Object.keys(TRUSTED_KEYS);
|
|
96
|
+
}
|
|
97
|
+
|
|
46
98
|
// ---------------------------------------------------------------------------
|
|
47
99
|
// Internal helpers
|
|
48
100
|
// ---------------------------------------------------------------------------
|
|
49
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Compare two dotted version strings. -1, 0 or 1.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately not semver-complete: it compares numeric segments and ignores
|
|
106
|
+
* pre-release tags. Plugin versions here are plain x.y.z, and pulling in a
|
|
107
|
+
* semver dependency to compare three integers would be the worse trade. A
|
|
108
|
+
* segment that is not a number sorts as 0, so a nonsense version loses to a
|
|
109
|
+
* real one rather than throwing.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} a
|
|
112
|
+
* @param {string} b
|
|
113
|
+
* @returns {number}
|
|
114
|
+
*/
|
|
115
|
+
export function compareVersions(a, b) {
|
|
116
|
+
const parse = v => String(v ?? '').split('.').map(x => parseInt(x, 10) || 0);
|
|
117
|
+
const av = parse(a);
|
|
118
|
+
const bv = parse(b);
|
|
119
|
+
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
|
|
120
|
+
const d = (av[i] ?? 0) - (bv[i] ?? 0);
|
|
121
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
50
126
|
/**
|
|
51
127
|
* Append a single record to the audit log, creating the file if absent.
|
|
52
128
|
*
|
|
@@ -167,33 +243,61 @@ async function extractTar(tarBuffer, slug, destDir) {
|
|
|
167
243
|
* 6. Run onEnable lifecycle hook (warn-only on failure)
|
|
168
244
|
* 7. Append install record to audit log
|
|
169
245
|
*
|
|
246
|
+
* A bundle from the marketplace is signed. One exported from a running CMS
|
|
247
|
+
* cannot be - the signing key is offline and never on a customer's host - so an
|
|
248
|
+
* UNSIGNED bundle is accepted only when the caller has explicitly said so and
|
|
249
|
+
* the site has not set `requireSignedPlugins`. A bundle carrying a signature
|
|
250
|
+
* that does not verify is always refused: a broken signature means tampering,
|
|
251
|
+
* never "unsigned".
|
|
252
|
+
*
|
|
170
253
|
* @param {string} slug
|
|
171
|
-
* @param {{ tarball: Buffer, signature
|
|
254
|
+
* @param {{ tarball: Buffer, signature?: string|null, publicKeyId?: string|null,
|
|
255
|
+
* allowUnsigned?: boolean, entitlement?: object|null,
|
|
256
|
+
* allowUpgrade?: boolean, allowDowngrade?: boolean }} options
|
|
172
257
|
* @returns {Promise<void>}
|
|
258
|
+
* @returns {Promise<{slug: string, action: string, from: string|null, version: string}>}
|
|
173
259
|
* @throws {Error} If signature is invalid, slug is already installed, or manifest validation fails
|
|
174
260
|
*/
|
|
175
|
-
|
|
261
|
+
/**
|
|
262
|
+
* A distributed plugin is ignored by the site's .gitignore, but one that keeps
|
|
263
|
+
* what users AUTHOR inside its own folder (plugin.json `contentPaths`) must
|
|
264
|
+
* have those folders tracked, or the site repo silently loses that work.
|
|
265
|
+
*/
|
|
266
|
+
function trackPluginContent(slug, manifest) {
|
|
267
|
+
const rules = manifestContentPaths(manifest).flatMap(rel => pluginContentRules(slug, rel));
|
|
268
|
+
if (rules.length) allowInGitignore(ROOT, rules);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function installPlugin(slug, { tarball, signature, publicKeyId, allowUnsigned = false, entitlement = null, allowUpgrade = false, allowDowngrade = false }) {
|
|
176
272
|
if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
|
|
177
273
|
throw new Error(`Invalid plugin slug: ${slug}`);
|
|
178
274
|
}
|
|
179
275
|
|
|
180
|
-
// Step 1 - Verify signature
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
276
|
+
// Step 1 - Verify signature, or establish that there is deliberately none.
|
|
277
|
+
const unsigned = !signature;
|
|
278
|
+
if (unsigned) {
|
|
279
|
+
if (!allowUnsigned) {
|
|
280
|
+
throw new Error('This plugin is unsigned. Installing it runs its code on this server, so it has to be confirmed.');
|
|
281
|
+
}
|
|
282
|
+
if (requireSignedPlugins()) {
|
|
283
|
+
throw new Error('This site accepts signed plugins only (server.json: requireSignedPlugins).');
|
|
284
|
+
}
|
|
184
285
|
}
|
|
185
286
|
|
|
186
|
-
|
|
187
|
-
|
|
287
|
+
const pubKeyPem = unsigned ? null : TRUSTED_KEYS[publicKeyId];
|
|
288
|
+
if (!unsigned && !pubKeyPem) {
|
|
289
|
+
throw new Error(`Unknown public key: ${publicKeyId}`);
|
|
188
290
|
}
|
|
189
291
|
|
|
190
|
-
const signatureBuffer = Buffer.from(signature, 'base64');
|
|
191
|
-
|
|
192
|
-
|
|
292
|
+
const signatureBuffer = unsigned ? null : Buffer.from(signature, 'base64');
|
|
293
|
+
// Ed25519 takes no digest argument - the algorithm is implied by the key.
|
|
294
|
+
// Passing one throws ERR_OSSL_INVALID_DIGEST rather than degrading quietly,
|
|
295
|
+
// which is the property that makes a half-finished edit here fail loudly.
|
|
296
|
+
if (!unsigned && !crypto.verify(null, tarball, pubKeyPem, signatureBuffer)) {
|
|
193
297
|
throw new Error('Plugin signature invalid - refusing to install');
|
|
194
298
|
}
|
|
195
299
|
|
|
196
|
-
// Step 2 -
|
|
300
|
+
// Step 2 - Already installed? Either refuse, or upgrade in place.
|
|
197
301
|
const destDir = path.join(PLUGINS_DIR, slug);
|
|
198
302
|
let alreadyExists = false;
|
|
199
303
|
try {
|
|
@@ -202,9 +306,12 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
|
|
|
202
306
|
} catch {
|
|
203
307
|
// Not present - proceed
|
|
204
308
|
}
|
|
205
|
-
if (alreadyExists) {
|
|
309
|
+
if (alreadyExists && !allowUpgrade) {
|
|
206
310
|
throw new Error(`Plugin already installed: ${slug}`);
|
|
207
311
|
}
|
|
312
|
+
if (alreadyExists) {
|
|
313
|
+
return upgradePlugin(slug, {tarball, unsigned, entitlement, allowDowngrade});
|
|
314
|
+
}
|
|
208
315
|
|
|
209
316
|
// Step 3 - Decompress + extract
|
|
210
317
|
await fs.mkdir(destDir, { recursive: true });
|
|
@@ -243,13 +350,126 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
|
|
|
243
350
|
console.warn(`[pluginInstaller] onEnable hook for "${slug}" failed: ${err.message}`);
|
|
244
351
|
}
|
|
245
352
|
|
|
246
|
-
// Step 7 -
|
|
353
|
+
// Step 7 - Entitlement, if the envelope carried one. Written beside the
|
|
354
|
+
// plugin so it travels with it and a renewal is a file drop.
|
|
355
|
+
if (entitlement) {
|
|
356
|
+
await fs.writeFile(path.join(destDir, '.entitlement.json'),
|
|
357
|
+
JSON.stringify(entitlement, null, 2) + '\n', 'utf8');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Step 8 - Audit log. An unsigned install is recorded as such: it is the one
|
|
361
|
+
// that was a judgement call rather than a verified fact.
|
|
247
362
|
await writeAuditLog({
|
|
248
|
-
action: 'install',
|
|
363
|
+
action: unsigned ? 'install:unsigned' : 'install',
|
|
249
364
|
slug,
|
|
250
365
|
version: manifest.version,
|
|
251
366
|
timestamp: new Date().toISOString()
|
|
252
367
|
});
|
|
368
|
+
|
|
369
|
+
trackPluginContent(slug, manifest);
|
|
370
|
+
|
|
371
|
+
return {slug, action: 'install', from: null, version: manifest.version};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Upgrade a plugin already installed.
|
|
376
|
+
*
|
|
377
|
+
* The order is the whole point. Extract and validate the NEW version before the
|
|
378
|
+
* old one is disturbed, so a broken upgrade is refused with the working
|
|
379
|
+
* installation still in place. Only then swap, and only then delete the backup.
|
|
380
|
+
*
|
|
381
|
+
* Runtime `data/` moves across rather than being replaced: it belongs to the
|
|
382
|
+
* site, not to the version. A plugin that kept its notes in data/ and lost them
|
|
383
|
+
* on every update would be worse than one that could not update at all.
|
|
384
|
+
*
|
|
385
|
+
* On any failure after the swap begins, the previous directory goes back. The
|
|
386
|
+
* one state this must never produce is "neither version installed".
|
|
387
|
+
*
|
|
388
|
+
* @param {string} slug
|
|
389
|
+
* @param {{tarball: Buffer, unsigned: boolean, entitlement: object|null, allowDowngrade: boolean}} opts
|
|
390
|
+
* @returns {Promise<{slug: string, action: string, from: string|null, version: string}>}
|
|
391
|
+
*/
|
|
392
|
+
async function upgradePlugin(slug, {tarball, unsigned, entitlement, allowDowngrade}) {
|
|
393
|
+
const destDir = path.join(PLUGINS_DIR, slug);
|
|
394
|
+
const incoming = path.join(PLUGINS_DIR, `.${slug}.incoming`);
|
|
395
|
+
const backup = path.join(PLUGINS_DIR, `.${slug}.backup`);
|
|
396
|
+
|
|
397
|
+
const current = await validatePluginDir(destDir, slug);
|
|
398
|
+
|
|
399
|
+
// 1. Extract the new version somewhere harmless.
|
|
400
|
+
await fs.rm(incoming, {recursive: true, force: true});
|
|
401
|
+
await fs.mkdir(incoming, {recursive: true});
|
|
402
|
+
try {
|
|
403
|
+
await extractTar(zlib.gunzipSync(tarball), slug, incoming);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
await fs.rm(incoming, {recursive: true, force: true});
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 2. Validate it BEFORE anything is disturbed.
|
|
410
|
+
const next = await validatePluginDir(incoming, slug);
|
|
411
|
+
if (!next.ok) {
|
|
412
|
+
await fs.rm(incoming, {recursive: true, force: true});
|
|
413
|
+
throw new Error(`Refusing to upgrade ${slug}: the new version is ${next.reason}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const from = current.manifest?.version;
|
|
417
|
+
const to = next.manifest.version;
|
|
418
|
+
if (current.ok && compareVersions(to, from) < 0 && !allowDowngrade) {
|
|
419
|
+
await fs.rm(incoming, {recursive: true, force: true});
|
|
420
|
+
throw new Error(`Refusing to downgrade ${slug} from ${from} to ${to}.`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 3. Carry the site's data across.
|
|
424
|
+
const dataDir = path.join(destDir, 'data');
|
|
425
|
+
let hasData = false;
|
|
426
|
+
try {
|
|
427
|
+
await fs.access(dataDir);
|
|
428
|
+
hasData = true;
|
|
429
|
+
} catch { /* nothing to carry */ }
|
|
430
|
+
if (hasData) {
|
|
431
|
+
await fs.rm(path.join(incoming, 'data'), {recursive: true, force: true});
|
|
432
|
+
await fs.cp(dataDir, path.join(incoming, 'data'), {recursive: true});
|
|
433
|
+
}
|
|
434
|
+
// The entitlement belongs to the installation, not the version - a renewal
|
|
435
|
+
// dropped in yesterday must survive today's update.
|
|
436
|
+
const entFile = path.join(destDir, '.entitlement.json');
|
|
437
|
+
let carriedEntitlement = null;
|
|
438
|
+
try {
|
|
439
|
+
carriedEntitlement = await fs.readFile(entFile, 'utf8');
|
|
440
|
+
} catch { /* none */ }
|
|
441
|
+
|
|
442
|
+
// 4. Swap. From here a failure must put the old version back.
|
|
443
|
+
await fs.rm(backup, {recursive: true, force: true});
|
|
444
|
+
await fs.rename(destDir, backup);
|
|
445
|
+
try {
|
|
446
|
+
await fs.rename(incoming, destDir);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
await fs.rename(backup, destDir).catch(() => {});
|
|
449
|
+
await fs.rm(incoming, {recursive: true, force: true});
|
|
450
|
+
throw new Error(`Upgrade of ${slug} failed and the previous version was restored: ${err.message}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (entitlement) {
|
|
454
|
+
await fs.writeFile(path.join(destDir, '.entitlement.json'),
|
|
455
|
+
JSON.stringify(entitlement, null, 2) + '\n', 'utf8');
|
|
456
|
+
} else if (carriedEntitlement !== null) {
|
|
457
|
+
await fs.writeFile(path.join(destDir, '.entitlement.json'), carriedEntitlement, 'utf8');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
await fs.rm(backup, {recursive: true, force: true});
|
|
461
|
+
|
|
462
|
+
await writeAuditLog({
|
|
463
|
+
action: unsigned ? 'upgrade:unsigned' : 'upgrade',
|
|
464
|
+
slug,
|
|
465
|
+
from: from ?? null,
|
|
466
|
+
version: to,
|
|
467
|
+
timestamp: new Date().toISOString()
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
trackPluginContent(slug, next.manifest);
|
|
471
|
+
|
|
472
|
+
return {slug, action: 'upgrade', from: from ?? null, version: to};
|
|
253
473
|
}
|
|
254
474
|
|
|
255
475
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import fs from 'fs/promises';
|
|
13
13
|
import path from 'path';
|
|
14
|
+
import {allowInGitignore, sitePluginRules} from './siteGitignore.js';
|
|
14
15
|
|
|
15
16
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
16
17
|
const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
|
|
@@ -132,5 +133,9 @@ export async function scaffoldPlugin(input) {
|
|
|
132
133
|
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
// A plugin written here is the site's own code, not the updater's, so it
|
|
137
|
+
// belongs in the site repo. The allowlist ignores plugins/* by default.
|
|
138
|
+
allowInGitignore(path.dirname(PLUGINS_DIR), sitePluginRules(slug));
|
|
139
|
+
|
|
135
140
|
return { name: slug, files };
|
|
136
141
|
}
|