domma-cms 0.10.0 → 0.13.0
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 +248 -159
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +7 -3
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/http-interceptor.js +1 -0
- package/admin/js/lib/safe-html.js +1 -0
- package/admin/js/templates/documentation.html +611 -2
- package/admin/js/templates/layouts.html +5 -4
- package/admin/js/templates/notifications.html +14 -0
- package/admin/js/templates/plugin-marketplace.html +16 -0
- package/admin/js/templates/plugins.html +17 -5
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/layouts.js +1 -16
- package/admin/js/views/notifications.js +1 -0
- package/admin/js/views/plugin-marketplace.js +1 -0
- package/admin/js/views/plugins.js +16 -16
- package/config/navigation.json +5 -72
- package/config/plugins.json +10 -14
- package/config/presets.json +50 -13
- package/config/site.json +11 -63
- package/package.json +2 -1
- package/plugins/_template/admin/templates/index.html +17 -0
- package/plugins/_template/admin/views/index.js +19 -0
- package/plugins/_template/config.js +8 -0
- package/plugins/_template/plugin.js +23 -0
- package/plugins/_template/plugin.json +34 -0
- package/plugins/analytics/plugin.json +41 -31
- package/plugins/blog/admin/templates/blog.html +22 -0
- package/plugins/blog/admin/templates/categories.html +7 -0
- package/plugins/blog/admin/templates/comments.html +11 -0
- package/plugins/blog/admin/templates/post-editor.html +97 -0
- package/plugins/blog/admin/templates/settings.html +11 -0
- package/plugins/blog/admin/views/blog.js +183 -0
- package/plugins/blog/admin/views/categories.js +235 -0
- package/plugins/blog/admin/views/comments.js +187 -0
- package/plugins/blog/admin/views/post-editor.js +291 -0
- package/plugins/blog/admin/views/settings.js +100 -0
- package/plugins/blog/collections/categories/schema.json +12 -0
- package/plugins/blog/collections/comments/schema.json +16 -0
- package/plugins/blog/collections/posts/schema.json +19 -0
- package/plugins/blog/config.js +8 -0
- package/plugins/blog/plugin.js +352 -0
- package/plugins/blog/plugin.json +96 -0
- package/plugins/blog/roles/blog-author.json +10 -0
- package/plugins/blog/roles/blog-editor.json +12 -0
- package/plugins/blog/templates/author.html +9 -0
- package/plugins/blog/templates/category.html +9 -0
- package/plugins/blog/templates/index.html +9 -0
- package/plugins/blog/templates/post.html +17 -0
- package/plugins/blog/templates/tag.html +9 -0
- package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
- package/plugins/contacts/collections/user-contacts/schema.json +1 -1
- package/plugins/contacts/plugin.js +4 -10
- package/plugins/contacts/plugin.json +13 -3
- package/plugins/notes/collections/user-notes/schema.json +1 -1
- package/plugins/notes/plugin.js +3 -9
- package/plugins/notes/plugin.json +13 -3
- package/plugins/site-search/plugin.json +5 -2
- package/plugins/theme-switcher/plugin.json +1 -1
- package/plugins/todo/collections/todos/schema.json +1 -1
- package/plugins/todo/plugin.js +3 -9
- package/plugins/todo/plugin.json +13 -3
- package/public/css/site.css +1 -1
- package/scripts/build.js +48 -0
- package/scripts/create-plugin.js +113 -0
- package/scripts/fresh.js +6 -7
- package/scripts/gen-instance-secret.js +46 -0
- package/scripts/reset.js +3 -3
- package/scripts/setup.js +31 -13
- package/server/middleware/auth.js +48 -0
- package/server/middleware/managerAuth.js +36 -0
- package/server/routes/api/actions.js +1 -1
- package/server/routes/api/auth.js +4 -3
- package/server/routes/api/layouts.js +173 -49
- package/server/routes/api/notifications.js +155 -0
- package/server/routes/api/plugin-marketplace.js +75 -0
- package/server/routes/api/users.js +1 -1
- package/server/routes/api/views.js +1 -1
- package/server/routes/public.js +4 -9
- package/server/server.js +32 -3
- package/server/services/actions.js +1 -1
- package/server/services/managerClient.js +182 -0
- package/server/services/markdown.js +52 -14
- package/server/services/permissionRegistry.js +245 -173
- package/server/services/pluginInstaller.js +301 -0
- package/server/services/plugins.js +117 -10
- package/server/services/presetCollections.js +66 -251
- package/server/services/renderer.js +99 -0
- package/server/services/roles.js +191 -39
- package/server/services/users.js +1 -1
- package/server/services/views.js +1 -1
- package/server/templates/page.html +2 -2
- package/plugins/docs/admin/templates/docs.html +0 -69
- package/plugins/docs/admin/views/docs.js +0 -276
- package/plugins/docs/config.js +0 -8
- package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
- package/plugins/docs/data/folders.json +0 -9
- package/plugins/docs/data/templates.json +0 -1
- package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
- package/plugins/docs/plugin.js +0 -375
- package/plugins/docs/plugin.json +0 -23
- package/plugins/form-builder/data/forms/contacts.json +0 -66
- package/plugins/form-builder/data/forms/enquiries.json +0 -103
- package/plugins/form-builder/data/forms/feedback.json +0 -131
- package/plugins/form-builder/data/forms/notes.json +0 -79
- package/plugins/form-builder/data/forms/to-do.json +0 -100
- package/plugins/form-builder/data/submissions/contacts.json +0 -1
- package/plugins/form-builder/data/submissions/enquiries.json +0 -1
- package/plugins/form-builder/data/submissions/feedback.json +0 -1
- package/plugins/form-builder/data/submissions/notes.json +0 -1
- package/plugins/form-builder/data/submissions/to-do.json +0 -1
- package/plugins/garage/admin/templates/garage.html +0 -111
- package/plugins/garage/admin/views/garage.js +0 -622
- package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
- package/plugins/garage/config.js +0 -18
- package/plugins/garage/data/vehicles.json +0 -70
- package/plugins/garage/plugin.js +0 -398
- package/plugins/garage/plugin.json +0 -33
- package/scripts/seed.js +0 -1996
- package/server/services/userTypes.js +0 -227
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Installer Service
|
|
3
|
+
* Handles secure installation and uninstallation of CMS plugins from signed tarballs.
|
|
4
|
+
*
|
|
5
|
+
* Security model:
|
|
6
|
+
* - Tarballs must be signed with a key registered in TRUSTED_KEYS
|
|
7
|
+
* - Path traversal is blocked during extraction
|
|
8
|
+
* - Manifest validation is performed post-extract via discoverPlugins()
|
|
9
|
+
*
|
|
10
|
+
* Audit log: content/instances_log.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
import fs from 'fs/promises';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import zlib from 'zlib';
|
|
17
|
+
import {fileURLToPath} from 'url';
|
|
18
|
+
import {discoverPlugins} from './plugins.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Paths
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
25
|
+
const PLUGINS_DIR = path.join(ROOT, 'plugins');
|
|
26
|
+
const CONFIG_PATH = path.join(ROOT, 'config', 'plugins.json');
|
|
27
|
+
const AUDIT_LOG = path.join(ROOT, 'content', 'instances_log.json');
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Trusted public keys — rotate by adding new keyId + bumping CMS version
|
|
31
|
+
// Private key counterpart: domma-cms-manager/config/keys/plugin-signing-2026.pem
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const TRUSTED_KEYS = {
|
|
35
|
+
'manager-2026': `-----BEGIN PUBLIC KEY-----
|
|
36
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArmEogar77p4RwljMgEsi
|
|
37
|
+
zSJRXIvRe8LDNzHvoez9gOu0SYUd2O8nsDOiLohsPxhH5+c8eXdBOkH+1y1O5QaN
|
|
38
|
+
TmZTRGrRR0NrxhPVTT7trtJIiXH4IwgwWgzYVmCKOdKqX4wj2JwGbiPE57ubOBKE
|
|
39
|
+
4T66OUIZmezSYYkhPmWAUCWmCX9KxL6czbMtNZYIn9U2O04imD/VtBieB4xCZy0P
|
|
40
|
+
U9vE3Uz4bcjLjjo70+oWXtAuudlLUiMREJKcT6J72CZSW6rzuR1+NDJsh4q5C6jd
|
|
41
|
+
e+B83PU+WiFxNwoHHJ91myEY4vDDWDDYV8G+sS/4QzBOgL9StwwvQzBuriWKjl8J
|
|
42
|
+
eQIDAQAB
|
|
43
|
+
-----END PUBLIC KEY-----`
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Internal helpers
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Append a single record to the audit log, creating the file if absent.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} record
|
|
54
|
+
* @returns {Promise<void>}
|
|
55
|
+
*/
|
|
56
|
+
async function writeAuditLog(record) {
|
|
57
|
+
await fs.mkdir(path.dirname(AUDIT_LOG), { recursive: true });
|
|
58
|
+
await fs.appendFile(AUDIT_LOG, JSON.stringify(record) + '\n', 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read config/plugins.json, returning {} on missing/malformed file.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Promise<object>}
|
|
65
|
+
*/
|
|
66
|
+
async function readPluginsConfig() {
|
|
67
|
+
try {
|
|
68
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
69
|
+
return JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Write config/plugins.json atomically.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} data
|
|
79
|
+
* @returns {Promise<void>}
|
|
80
|
+
*/
|
|
81
|
+
async function writePluginsConfig(data) {
|
|
82
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse a raw (decompressed) TAR byte buffer and extract entries to destDir.
|
|
87
|
+
* Only handles POSIX ustar / GNU tar formats sufficient for plugin tarballs.
|
|
88
|
+
* Path traversal outside destDir causes an immediate throw.
|
|
89
|
+
*
|
|
90
|
+
* @param {Buffer} tarBuffer - Decompressed tar data
|
|
91
|
+
* @param {string} slug - Plugin slug (used for guard prefix)
|
|
92
|
+
* @param {string} destDir - Absolute destination directory
|
|
93
|
+
* @returns {Promise<void>}
|
|
94
|
+
*/
|
|
95
|
+
async function extractTar(tarBuffer, slug, destDir) {
|
|
96
|
+
const guardPrefix = path.resolve(destDir) + path.sep;
|
|
97
|
+
let offset = 0;
|
|
98
|
+
|
|
99
|
+
let zeroBlockCount = 0;
|
|
100
|
+
while (offset + 512 <= tarBuffer.length) {
|
|
101
|
+
const header = tarBuffer.slice(offset, offset + 512);
|
|
102
|
+
|
|
103
|
+
// All-zero block signals end of archive (two consecutive zero blocks required)
|
|
104
|
+
if (header.every(b => b === 0)) {
|
|
105
|
+
if (++zeroBlockCount >= 2) break;
|
|
106
|
+
offset += 512;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
zeroBlockCount = 0;
|
|
110
|
+
offset += 512;
|
|
111
|
+
|
|
112
|
+
// Filename: bytes 0–99, null-terminated
|
|
113
|
+
const nameEnd = header.indexOf(0, 0);
|
|
114
|
+
const name = header.slice(0, nameEnd === -1 ? 100 : Math.min(nameEnd, 100)).toString('utf8');
|
|
115
|
+
if (!name) break;
|
|
116
|
+
|
|
117
|
+
// File size: bytes 124–135, octal ASCII
|
|
118
|
+
const sizeStr = header.slice(124, 136).toString('utf8').replace(/\0/g, '').trim();
|
|
119
|
+
const size = parseInt(sizeStr, 8) || 0;
|
|
120
|
+
|
|
121
|
+
// Type flag: byte 156
|
|
122
|
+
const typeFlag = String.fromCharCode(header[156]);
|
|
123
|
+
|
|
124
|
+
// Strip leading component (e.g. "my-plugin/foo.js" → "foo.js") so that
|
|
125
|
+
// tarballs created with a top-level directory still land correctly.
|
|
126
|
+
const parts = name.split('/');
|
|
127
|
+
const relativePath = parts.length > 1 ? parts.slice(1).join('/') : name;
|
|
128
|
+
|
|
129
|
+
if (relativePath) {
|
|
130
|
+
const target = path.resolve(destDir, relativePath);
|
|
131
|
+
|
|
132
|
+
// Path traversal guard
|
|
133
|
+
if (!target.startsWith(guardPrefix) && target !== path.resolve(destDir)) {
|
|
134
|
+
throw new Error(`Path traversal detected in tar entry: ${name}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (typeFlag === '5' || name.endsWith('/')) {
|
|
138
|
+
// Directory entry
|
|
139
|
+
await fs.mkdir(target, { recursive: true });
|
|
140
|
+
} else if (typeFlag === '0' || typeFlag === '' || typeFlag === '\0') {
|
|
141
|
+
// Regular file
|
|
142
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
143
|
+
const content = tarBuffer.slice(offset, offset + size);
|
|
144
|
+
await fs.writeFile(target, content);
|
|
145
|
+
}
|
|
146
|
+
// Ignore symlinks, hard links, etc. for security
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Advance past file data (rounded up to 512-byte block boundary)
|
|
150
|
+
offset += Math.ceil(size / 512) * 512;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Public API
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Install a plugin from a signed .tar.gz buffer.
|
|
160
|
+
*
|
|
161
|
+
* Steps:
|
|
162
|
+
* 1. Verify signature against TRUSTED_KEYS[publicKeyId]
|
|
163
|
+
* 2. Reject if plugins/<slug>/ already exists
|
|
164
|
+
* 3. Extract tarball to plugins/<slug>/ with path traversal guard
|
|
165
|
+
* 4. Validate manifest via discoverPlugins()
|
|
166
|
+
* 5. Add { enabled: false, settings: {} } to config/plugins.json
|
|
167
|
+
* 6. Run onEnable lifecycle hook (warn-only on failure)
|
|
168
|
+
* 7. Append install record to audit log
|
|
169
|
+
*
|
|
170
|
+
* @param {string} slug
|
|
171
|
+
* @param {{ tarball: Buffer, signature: string, publicKeyId: string }} options
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
* @throws {Error} If signature is invalid, slug is already installed, or manifest validation fails
|
|
174
|
+
*/
|
|
175
|
+
export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
|
|
176
|
+
if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
|
|
177
|
+
throw new Error(`Invalid plugin slug: ${slug}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Step 1 — Verify signature
|
|
181
|
+
const pubKeyPem = TRUSTED_KEYS[publicKeyId];
|
|
182
|
+
if (!pubKeyPem) {
|
|
183
|
+
throw new Error(`Unknown public key: ${publicKeyId}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (pubKeyPem.includes('PLACEHOLDER')) {
|
|
187
|
+
throw new Error(`pluginInstaller: TRUSTED_KEYS['${publicKeyId}'] is a placeholder — not usable for verification`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const signatureBuffer = Buffer.from(signature, 'base64');
|
|
191
|
+
const valid = crypto.verify('sha256', tarball, pubKeyPem, signatureBuffer);
|
|
192
|
+
if (!valid) {
|
|
193
|
+
throw new Error('Plugin signature invalid — refusing to install');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Step 2 — Reject if already installed
|
|
197
|
+
const destDir = path.join(PLUGINS_DIR, slug);
|
|
198
|
+
let alreadyExists = false;
|
|
199
|
+
try {
|
|
200
|
+
await fs.access(destDir);
|
|
201
|
+
alreadyExists = true;
|
|
202
|
+
} catch {
|
|
203
|
+
// Not present — proceed
|
|
204
|
+
}
|
|
205
|
+
if (alreadyExists) {
|
|
206
|
+
throw new Error(`Plugin already installed: ${slug}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Step 3 — Decompress + extract
|
|
210
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
211
|
+
try {
|
|
212
|
+
const tarBuffer = zlib.gunzipSync(tarball);
|
|
213
|
+
await extractTar(tarBuffer, slug, destDir);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
// Clean up partial extraction before re-throwing
|
|
216
|
+
await fs.rm(destDir, { recursive: true, force: true });
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Step 4 — Validate manifest
|
|
221
|
+
const manifests = await discoverPlugins();
|
|
222
|
+
const manifest = manifests.find(m => m.name === slug);
|
|
223
|
+
if (!manifest) {
|
|
224
|
+
await fs.rm(destDir, { recursive: true, force: true });
|
|
225
|
+
throw new Error(`Plugin manifest validation failed after extraction: ${slug}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Step 5 — Update config/plugins.json
|
|
229
|
+
const cfg = await readPluginsConfig();
|
|
230
|
+
if (!cfg[slug]) {
|
|
231
|
+
cfg[slug] = { enabled: false, settings: {} };
|
|
232
|
+
await writePluginsConfig(cfg);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Step 6 — Run onEnable lifecycle hook (warn on failure, do not throw)
|
|
236
|
+
try {
|
|
237
|
+
const pluginJsPath = path.join(destDir, 'plugin.js');
|
|
238
|
+
const mod = await import(pluginJsPath);
|
|
239
|
+
if (typeof mod.onEnable === 'function') {
|
|
240
|
+
await mod.onEnable(null);
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.warn(`[pluginInstaller] onEnable hook for "${slug}" failed: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Step 7 — Audit log
|
|
247
|
+
await writeAuditLog({
|
|
248
|
+
action: 'install',
|
|
249
|
+
slug,
|
|
250
|
+
version: manifest.version,
|
|
251
|
+
timestamp: new Date().toISOString()
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Uninstall a plugin by slug.
|
|
257
|
+
*
|
|
258
|
+
* Steps:
|
|
259
|
+
* 1. Run onDisable lifecycle hook (warn-only on failure)
|
|
260
|
+
* 2. Remove plugins/<slug>/ directory
|
|
261
|
+
* 3. Remove entry from config/plugins.json
|
|
262
|
+
* 4. Append uninstall record to audit log
|
|
263
|
+
*
|
|
264
|
+
* @param {string} slug
|
|
265
|
+
* @returns {Promise<void>}
|
|
266
|
+
*/
|
|
267
|
+
export async function uninstallPlugin(slug) {
|
|
268
|
+
if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
|
|
269
|
+
throw new Error(`Invalid plugin slug: ${slug}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const destDir = path.join(PLUGINS_DIR, slug);
|
|
273
|
+
|
|
274
|
+
// Step 1 — Run onDisable lifecycle hook (warn on failure, do not throw)
|
|
275
|
+
try {
|
|
276
|
+
const pluginJsPath = path.join(destDir, 'plugin.js');
|
|
277
|
+
const mod = await import(pluginJsPath);
|
|
278
|
+
if (typeof mod.onDisable === 'function') {
|
|
279
|
+
await mod.onDisable(null);
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.warn(`[pluginInstaller] onDisable hook for "${slug}" failed: ${err.message}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Step 2 — Remove plugin directory
|
|
286
|
+
await fs.rm(destDir, { recursive: true, force: true });
|
|
287
|
+
|
|
288
|
+
// Step 3 — Remove from config/plugins.json
|
|
289
|
+
const cfg = await readPluginsConfig();
|
|
290
|
+
if (slug in cfg) {
|
|
291
|
+
delete cfg[slug];
|
|
292
|
+
await writePluginsConfig(cfg);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Step 4 — Audit log
|
|
296
|
+
await writeAuditLog({
|
|
297
|
+
action: 'uninstall',
|
|
298
|
+
slug,
|
|
299
|
+
timestamp: new Date().toISOString()
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -14,13 +14,23 @@ import fs from 'fs/promises';
|
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import matter from 'gray-matter';
|
|
16
16
|
import {getConfig, saveConfig} from '../config.js';
|
|
17
|
-
import {authenticate, requireAdmin, requireRole} from '../middleware/auth.js';
|
|
17
|
+
import {authenticate, requireAdmin, requireRole, requireVisibility} from '../middleware/auth.js';
|
|
18
18
|
import {hooks, registerSanitizeRules, registerShortcode, registerTransform} from './hooks.js';
|
|
19
|
+
import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
|
|
20
|
+
import {createCollection, getCollection} from './collections.js';
|
|
19
21
|
|
|
20
22
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
21
23
|
|
|
22
24
|
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'version', 'description', 'author', 'date', 'icon'];
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* In-memory registry of loaded plugins, populated by registerPlugins().
|
|
28
|
+
* Keyed by plugin name; each entry mirrors the manifest plus runtime metadata.
|
|
29
|
+
*
|
|
30
|
+
* @type {Object.<string, { enabled: boolean, publicEntry: string|null }>}
|
|
31
|
+
*/
|
|
32
|
+
const _loadedPlugins = {};
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* Core plugins — always loaded regardless of plugins.json enabled state.
|
|
26
36
|
* These are considered first-class CMS features, not optional add-ons.
|
|
@@ -43,7 +53,7 @@ export async function discoverPlugins() {
|
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
const plugins = [];
|
|
46
|
-
for (const entry of entries.filter(e => e.isDirectory())) {
|
|
56
|
+
for (const entry of entries.filter(e => e.isDirectory() && !e.name.startsWith('_'))) {
|
|
47
57
|
const dir = entry.name;
|
|
48
58
|
const pluginDir = path.join(PLUGINS_DIR, dir);
|
|
49
59
|
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
@@ -163,11 +173,26 @@ export async function registerPlugins(fastify) {
|
|
|
163
173
|
const prefix = `/api/plugins/${manifest.name}`;
|
|
164
174
|
await fastify.register(plugin, {
|
|
165
175
|
prefix,
|
|
166
|
-
auth: {authenticate, requireRole, requireAdmin},
|
|
176
|
+
auth: {authenticate, requireRole, requireAdmin, requireVisibility},
|
|
167
177
|
hooks: {registerShortcode, registerSanitizeRules, registerTransform, on: hooks.on.bind(hooks)},
|
|
168
|
-
settings
|
|
178
|
+
settings,
|
|
179
|
+
config: {}
|
|
169
180
|
});
|
|
170
181
|
loaded.push(manifest.name);
|
|
182
|
+
|
|
183
|
+
// Ensure plugin collections exist (idempotent — skips if already present)
|
|
184
|
+
await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
|
|
185
|
+
.catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
|
|
186
|
+
|
|
187
|
+
// Detect optional plugin.public.js for root-level route registration
|
|
188
|
+
_loadedPlugins[manifest.name] = { enabled: true, publicEntry: null };
|
|
189
|
+
const publicEntryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.public.js');
|
|
190
|
+
try {
|
|
191
|
+
await fs.access(publicEntryPath);
|
|
192
|
+
_loadedPlugins[manifest.name].publicEntry = publicEntryPath;
|
|
193
|
+
} catch {
|
|
194
|
+
// no public entry — that's fine
|
|
195
|
+
}
|
|
171
196
|
} catch (err) {
|
|
172
197
|
fastify.log.error(`Plugin "${manifest.name}" server failed to load: ${err.message}`);
|
|
173
198
|
}
|
|
@@ -180,6 +205,16 @@ export async function registerPlugins(fastify) {
|
|
|
180
205
|
}
|
|
181
206
|
}
|
|
182
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Return the in-memory registry of plugins loaded by registerPlugins().
|
|
210
|
+
* Each entry includes `enabled` and `publicEntry` (path or null).
|
|
211
|
+
*
|
|
212
|
+
* @returns {Object.<string, { enabled: boolean, publicEntry: string|null }>}
|
|
213
|
+
*/
|
|
214
|
+
export function getLoadedPlugins() {
|
|
215
|
+
return _loadedPlugins;
|
|
216
|
+
}
|
|
217
|
+
|
|
183
218
|
/**
|
|
184
219
|
* Return merged settings for a plugin: config.js defaults + user overrides from config/plugins.json.
|
|
185
220
|
*
|
|
@@ -263,7 +298,7 @@ async function walkMdFiles(dir) {
|
|
|
263
298
|
* @returns {Promise<{ collections: string[], forms: string[], pages: string[] }>}
|
|
264
299
|
*/
|
|
265
300
|
export async function setupPlugin(pluginName, services) {
|
|
266
|
-
const result = {collections: [], forms: [], pages: []};
|
|
301
|
+
const result = {collections: [], forms: [], pages: [], roles: []};
|
|
267
302
|
const pluginDir = path.join(PLUGINS_DIR, pluginName);
|
|
268
303
|
|
|
269
304
|
// Collections
|
|
@@ -341,6 +376,37 @@ export async function setupPlugin(pluginName, services) {
|
|
|
341
376
|
}
|
|
342
377
|
}
|
|
343
378
|
|
|
379
|
+
// Roles — read plugins/<name>/roles/*.json, register resources + persist roles
|
|
380
|
+
if (services.roles) {
|
|
381
|
+
const rolesDir = path.join(pluginDir, 'roles');
|
|
382
|
+
let roleFiles;
|
|
383
|
+
try {
|
|
384
|
+
roleFiles = await fs.readdir(rolesDir);
|
|
385
|
+
} catch {
|
|
386
|
+
roleFiles = [];
|
|
387
|
+
}
|
|
388
|
+
for (const file of roleFiles.filter(f => f.endsWith('.json'))) {
|
|
389
|
+
try {
|
|
390
|
+
const raw = await fs.readFile(path.join(rolesDir, file), 'utf8');
|
|
391
|
+
const def = JSON.parse(raw);
|
|
392
|
+
|
|
393
|
+
// Register any declared resources into the permission overlay
|
|
394
|
+
if (Array.isArray(def.resources)) {
|
|
395
|
+
for (const resource of def.resources) {
|
|
396
|
+
registerPluginResource(resource, pluginName);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Persist the role (idempotent)
|
|
401
|
+
const {resources: _resources, ...roleData} = def;
|
|
402
|
+
await services.roles.createRole({...roleData, plugin: pluginName});
|
|
403
|
+
result.roles.push(def.name);
|
|
404
|
+
} catch {
|
|
405
|
+
// Skip missing or invalid role files
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
344
410
|
return result;
|
|
345
411
|
}
|
|
346
412
|
|
|
@@ -354,7 +420,7 @@ export async function setupPlugin(pluginName, services) {
|
|
|
354
420
|
* @returns {Promise<{ pages: string[], forms: string[] }>}
|
|
355
421
|
*/
|
|
356
422
|
export async function teardownPlugin(pluginName, services, fastify) {
|
|
357
|
-
const result = {pages: [], forms: []};
|
|
423
|
+
const result = {pages: [], forms: [], roles: []};
|
|
358
424
|
|
|
359
425
|
// Pages
|
|
360
426
|
if (services.content) {
|
|
@@ -390,6 +456,22 @@ export async function teardownPlugin(pluginName, services, fastify) {
|
|
|
390
456
|
}
|
|
391
457
|
}
|
|
392
458
|
|
|
459
|
+
// Roles — remove plugin-contributed roles and unregister their resources
|
|
460
|
+
if (services.roles) {
|
|
461
|
+
try {
|
|
462
|
+
await services.roles.removeRolesByPlugin(pluginName);
|
|
463
|
+
unregisterPluginResourcesByPlugin(pluginName);
|
|
464
|
+
result.roles.push(pluginName);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
fastify.log.warn(`[plugins] Could not remove roles for "${pluginName}": ${err.message}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (_loadedPlugins[pluginName]) {
|
|
471
|
+
_loadedPlugins[pluginName].enabled = false;
|
|
472
|
+
_loadedPlugins[pluginName].publicEntry = null;
|
|
473
|
+
}
|
|
474
|
+
|
|
393
475
|
return result;
|
|
394
476
|
}
|
|
395
477
|
|
|
@@ -451,11 +533,13 @@ export async function runLifecycleHook(name, hook, fastify) {
|
|
|
451
533
|
if (setup.collections.length) fastify.log.info(`[plugins] Created collections for "${name}": ${setup.collections.join(', ')}`);
|
|
452
534
|
if (setup.forms.length) fastify.log.info(`[plugins] Created forms for "${name}": ${setup.forms.join(', ')}`);
|
|
453
535
|
if (setup.pages.length) fastify.log.info(`[plugins] Created pages for "${name}": ${setup.pages.join(', ')}`);
|
|
536
|
+
if (setup.roles.length) fastify.log.info(`[plugins] Registered roles for "${name}": ${setup.roles.join(', ')}`);
|
|
454
537
|
}
|
|
455
538
|
if (hook === 'onDisable') {
|
|
456
539
|
const torn = await teardownPlugin(name, services, fastify);
|
|
457
540
|
if (torn.pages.length) fastify.log.info(`[plugins] Removed pages for "${name}": ${torn.pages.join(', ')}`);
|
|
458
541
|
if (torn.forms.length) fastify.log.info(`[plugins] Removed forms for "${name}": ${torn.forms.join(', ')}`);
|
|
542
|
+
if (torn.roles.length) fastify.log.info(`[plugins] Removed roles for "${name}"`);
|
|
459
543
|
}
|
|
460
544
|
|
|
461
545
|
if (typeof mod[hook] === 'function') {
|
|
@@ -467,10 +551,12 @@ export async function runLifecycleHook(name, hook, fastify) {
|
|
|
467
551
|
}
|
|
468
552
|
|
|
469
553
|
/**
|
|
470
|
-
* Return merged sidebar items, routes, and
|
|
554
|
+
* Return merged sidebar items, routes, views, and CSS links from all enabled plugins.
|
|
555
|
+
* View entry paths are automatically stamped with a mtime-based version so manual
|
|
556
|
+
* `?v=N` bumps in plugin.json are never needed again.
|
|
471
557
|
* Used by the frontend to dynamically extend the admin panel.
|
|
472
558
|
*
|
|
473
|
-
* @returns {Promise<{ sidebar: object[], routes: object[], views: object }>}
|
|
559
|
+
* @returns {Promise<{ sidebar: object[], routes: object[], views: object, css: object[] }>}
|
|
474
560
|
*/
|
|
475
561
|
export async function getAdminPluginConfig() {
|
|
476
562
|
const manifests = await discoverPlugins();
|
|
@@ -479,6 +565,7 @@ export async function getAdminPluginConfig() {
|
|
|
479
565
|
const sidebar = [];
|
|
480
566
|
const routes = [];
|
|
481
567
|
const views = {};
|
|
568
|
+
const css = [];
|
|
482
569
|
|
|
483
570
|
for (const manifest of manifests) {
|
|
484
571
|
const state = states[manifest.name] || {};
|
|
@@ -486,8 +573,28 @@ export async function getAdminPluginConfig() {
|
|
|
486
573
|
|
|
487
574
|
if (manifest.admin.sidebar) sidebar.push(...manifest.admin.sidebar);
|
|
488
575
|
if (manifest.admin.routes) routes.push(...manifest.admin.routes);
|
|
489
|
-
|
|
576
|
+
|
|
577
|
+
// Auto-stamp view entries with mtime-based version (replaces manual ?v=N)
|
|
578
|
+
for (const [viewName, viewDef] of Object.entries(manifest.admin.views || {})) {
|
|
579
|
+
const entryBase = viewDef.entry.split('?')[0];
|
|
580
|
+
let stamped = viewDef.entry;
|
|
581
|
+
try {
|
|
582
|
+
const filePath = path.join(PLUGINS_DIR, entryBase);
|
|
583
|
+
const s = await fs.stat(filePath);
|
|
584
|
+
stamped = `${entryBase}?v=${Math.floor(s.mtimeMs)}`;
|
|
585
|
+
} catch { /* keep original entry as-is */ }
|
|
586
|
+
views[viewName] = { ...viewDef, entry: stamped };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Collect admin CSS links declared in plugin.json admin.css[]
|
|
590
|
+
for (let i = 0; i < (manifest.admin.css || []).length; i++) {
|
|
591
|
+
const cssPath = manifest.admin.css[i];
|
|
592
|
+
css.push({
|
|
593
|
+
id: `plugin-${manifest.name}-css${i > 0 ? `-${i}` : ''}`,
|
|
594
|
+
href: `/plugins/${manifest.name}/${cssPath}`
|
|
595
|
+
});
|
|
596
|
+
}
|
|
490
597
|
}
|
|
491
598
|
|
|
492
|
-
return { sidebar, routes, views };
|
|
599
|
+
return { sidebar, routes, views, css };
|
|
493
600
|
}
|