domma-cms 0.8.6 → 0.8.10
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/README.md +31 -9
- package/admin/js/templates/effects.html +147 -72
- package/admin/js/views/effects.js +1 -1
- package/admin/js/views/page-editor.js +36 -30
- package/package.json +1 -1
- package/server/routes/api/blocks.js +18 -42
- package/server/routes/api/forms.js +8 -1
- package/server/services/blocks.js +107 -5
- package/server/services/forms.js +77 -0
- package/server/services/plugins.js +32 -2
package/package.json
CHANGED
|
@@ -6,20 +6,8 @@
|
|
|
6
6
|
* PUT /api/blocks/:name - create or update block
|
|
7
7
|
* DELETE /api/blocks/:name - delete block
|
|
8
8
|
*/
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import fs from 'fs/promises';
|
|
11
|
-
import {fileURLToPath} from 'url';
|
|
12
9
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
13
|
-
|
|
14
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const ROOT = path.resolve(__dirname, '../../..');
|
|
16
|
-
const BLOCKS_DIR = path.join(ROOT, 'content', 'blocks');
|
|
17
|
-
|
|
18
|
-
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
|
-
|
|
20
|
-
function blockPath(name) {
|
|
21
|
-
return path.join(BLOCKS_DIR, `${name}.html`);
|
|
22
|
-
}
|
|
10
|
+
import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
|
|
23
11
|
|
|
24
12
|
export async function blocksRoutes(fastify) {
|
|
25
13
|
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
@@ -28,57 +16,45 @@ export async function blocksRoutes(fastify) {
|
|
|
28
16
|
|
|
29
17
|
// List all blocks
|
|
30
18
|
fastify.get('/blocks', canRead, async () => {
|
|
31
|
-
|
|
32
|
-
const files = await fs.readdir(BLOCKS_DIR);
|
|
33
|
-
const blocks = [];
|
|
34
|
-
for (const file of files.filter(f => f.endsWith('.html'))) {
|
|
35
|
-
const name = file.slice(0, -5);
|
|
36
|
-
const stat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
|
|
37
|
-
blocks.push({
|
|
38
|
-
name,
|
|
39
|
-
size: stat?.size ?? 0,
|
|
40
|
-
updatedAt: stat?.mtime?.toISOString() ?? null
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
return blocks.sort((a, b) => a.name.localeCompare(b.name));
|
|
19
|
+
return listBlocks();
|
|
44
20
|
});
|
|
45
21
|
|
|
46
22
|
// Get single block
|
|
47
23
|
fastify.get('/blocks/:name', canRead, async (request, reply) => {
|
|
48
24
|
const {name} = request.params;
|
|
49
|
-
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
|
|
50
|
-
|
|
51
25
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return reply.status(404).send({error: 'Block not found'});
|
|
26
|
+
return await getBlock(name);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
29
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Block not found'});
|
|
30
|
+
throw err;
|
|
56
31
|
}
|
|
57
32
|
});
|
|
58
33
|
|
|
59
34
|
// Create or update block
|
|
60
35
|
fastify.put('/blocks/:name', canUpdate, async (request, reply) => {
|
|
61
36
|
const {name} = request.params;
|
|
62
|
-
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name. Use lowercase letters, digits, and hyphens only.'});
|
|
63
|
-
|
|
64
37
|
const {content} = request.body || {};
|
|
65
38
|
if (typeof content !== 'string') return reply.status(400).send({error: 'content (string) is required'});
|
|
66
39
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
try {
|
|
41
|
+
return await saveBlock(name, content);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
70
46
|
});
|
|
71
47
|
|
|
72
48
|
// Delete block
|
|
73
49
|
fastify.delete('/blocks/:name', canDelete, async (request, reply) => {
|
|
74
50
|
const {name} = request.params;
|
|
75
|
-
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
|
|
76
|
-
|
|
77
51
|
try {
|
|
78
|
-
await
|
|
52
|
+
await deleteBlock(name);
|
|
79
53
|
return reply.status(204).send();
|
|
80
|
-
} catch {
|
|
81
|
-
return reply.status(
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
56
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Block not found'});
|
|
57
|
+
throw err;
|
|
82
58
|
}
|
|
83
59
|
});
|
|
84
60
|
}
|
|
@@ -456,10 +456,17 @@ export async function formsRoutes(fastify) {
|
|
|
456
456
|
|
|
457
457
|
hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
|
|
458
458
|
|
|
459
|
+
// Template interpolation for successRedirect
|
|
460
|
+
let redirect = settings.successRedirect || null;
|
|
461
|
+
if (redirect && entry?.id) {
|
|
462
|
+
redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
|
|
463
|
+
}
|
|
464
|
+
|
|
459
465
|
return {
|
|
460
466
|
ok: true,
|
|
467
|
+
entryId: entry?.id || null,
|
|
461
468
|
message: settings.successMessage || 'Thank you for your submission.',
|
|
462
|
-
redirect:
|
|
469
|
+
redirect: redirect
|
|
463
470
|
};
|
|
464
471
|
});
|
|
465
472
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Blocks Service
|
|
3
|
-
*
|
|
4
|
-
* Never overwrites existing files — user customisations are preserved.
|
|
3
|
+
* CRUD operations and seeding for reusable HTML block templates in content/blocks/.
|
|
4
|
+
* Never overwrites existing files during seeding — user customisations are preserved.
|
|
5
5
|
*/
|
|
6
|
-
import
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {fileURLToPath} from 'url';
|
|
9
9
|
|
|
@@ -19,10 +19,10 @@ const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
|
|
|
19
19
|
async function seedBlock(name, content) {
|
|
20
20
|
const filePath = path.join(BLOCKS_DIR, `${name}.html`);
|
|
21
21
|
try {
|
|
22
|
-
await access(filePath);
|
|
22
|
+
await fs.access(filePath);
|
|
23
23
|
// File exists — leave it alone
|
|
24
24
|
} catch {
|
|
25
|
-
await writeFile(filePath, content.trim() + '\n', 'utf8');
|
|
25
|
+
await fs.writeFile(filePath, content.trim() + '\n', 'utf8');
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -145,6 +145,108 @@ const BLOCKS = {
|
|
|
145
145
|
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Validation
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/** Block names must be lowercase alphanumeric + hyphens, no path traversal. */
|
|
153
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
154
|
+
|
|
155
|
+
function assertValidName(name) {
|
|
156
|
+
if (!NAME_RE.test(name)) {
|
|
157
|
+
const err = new Error('Invalid block name. Use lowercase letters, digits, and hyphens only.');
|
|
158
|
+
err.code = 'INVALID_NAME';
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function blockFilePath(name) {
|
|
164
|
+
return path.join(BLOCKS_DIR, `${name}.html`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// CRUD service functions
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* List all blocks in the blocks directory.
|
|
173
|
+
*
|
|
174
|
+
* @returns {Promise<Array<{name: string, size: number, updatedAt: string|null}>>}
|
|
175
|
+
*/
|
|
176
|
+
export async function listBlocks() {
|
|
177
|
+
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
178
|
+
const files = await fs.readdir(BLOCKS_DIR);
|
|
179
|
+
const blocks = [];
|
|
180
|
+
for (const file of files.filter(f => f.endsWith('.html'))) {
|
|
181
|
+
const name = file.slice(0, -5);
|
|
182
|
+
const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
|
|
183
|
+
blocks.push({
|
|
184
|
+
name,
|
|
185
|
+
size: fileStat?.size ?? 0,
|
|
186
|
+
updatedAt: fileStat?.mtime?.toISOString() ?? null,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return blocks.sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read a single block's content by name.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} name - Block name (without .html extension)
|
|
196
|
+
* @returns {Promise<{name: string, content: string}>}
|
|
197
|
+
* @throws {Error} With code INVALID_NAME or ENOENT when not found
|
|
198
|
+
*/
|
|
199
|
+
export async function getBlock(name) {
|
|
200
|
+
assertValidName(name);
|
|
201
|
+
try {
|
|
202
|
+
const content = await fs.readFile(blockFilePath(name), 'utf8');
|
|
203
|
+
return {name, content};
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err.code === 'ENOENT') {
|
|
206
|
+
const notFound = new Error('Block not found');
|
|
207
|
+
notFound.code = 'ENOENT';
|
|
208
|
+
throw notFound;
|
|
209
|
+
}
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create or update a block file (upsert).
|
|
216
|
+
*
|
|
217
|
+
* @param {string} name - Block name (without .html extension)
|
|
218
|
+
* @param {string} content - HTML template content
|
|
219
|
+
* @returns {Promise<{success: boolean, name: string}>}
|
|
220
|
+
* @throws {Error} With code INVALID_NAME on bad name
|
|
221
|
+
*/
|
|
222
|
+
export async function saveBlock(name, content) {
|
|
223
|
+
assertValidName(name);
|
|
224
|
+
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
225
|
+
await fs.writeFile(blockFilePath(name), content, 'utf8');
|
|
226
|
+
return {success: true, name};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Delete a block file.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} name - Block name (without .html extension)
|
|
233
|
+
* @returns {Promise<void>}
|
|
234
|
+
* @throws {Error} With code INVALID_NAME or ENOENT when not found
|
|
235
|
+
*/
|
|
236
|
+
export async function deleteBlock(name) {
|
|
237
|
+
assertValidName(name);
|
|
238
|
+
try {
|
|
239
|
+
await fs.unlink(blockFilePath(name));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err.code === 'ENOENT') {
|
|
242
|
+
const notFound = new Error('Block not found');
|
|
243
|
+
notFound.code = 'ENOENT';
|
|
244
|
+
throw notFound;
|
|
245
|
+
}
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
148
250
|
// ---------------------------------------------------------------------------
|
|
149
251
|
// Public API
|
|
150
252
|
// ---------------------------------------------------------------------------
|
package/server/services/forms.js
CHANGED
|
@@ -90,6 +90,83 @@ export function slugify(str) {
|
|
|
90
90
|
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Get a single form definition by slug (alias for readForm).
|
|
95
|
+
*
|
|
96
|
+
* @param {string} slug
|
|
97
|
+
* @returns {Promise<object>}
|
|
98
|
+
* @throws {Error} If the form file does not exist or cannot be parsed.
|
|
99
|
+
*/
|
|
100
|
+
export async function getForm(slug) {
|
|
101
|
+
return readForm(slug);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create a new form definition and write it to disk.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} data - Form definition data
|
|
108
|
+
* @param {string} data.title - Form title (required if no slug)
|
|
109
|
+
* @param {string} [data.slug] - Explicit slug (derived from title if omitted)
|
|
110
|
+
* @param {string} [data.description]
|
|
111
|
+
* @param {Array} [data.fields]
|
|
112
|
+
* @param {object} [data.settings]
|
|
113
|
+
* @param {object} [data.actions]
|
|
114
|
+
* @returns {Promise<object>} The created form object
|
|
115
|
+
* @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
|
|
116
|
+
*/
|
|
117
|
+
export async function createForm(data) {
|
|
118
|
+
const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {} } = data || {};
|
|
119
|
+
|
|
120
|
+
if (!title?.trim() && !rawSlug?.trim()) {
|
|
121
|
+
throw new Error('A title or slug is required to create a form.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const slug = rawSlug ? slugify(rawSlug) : slugify(title);
|
|
125
|
+
if (!slug) {
|
|
126
|
+
throw new Error('Could not derive a valid slug from the provided title or slug.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Throw if a form with this slug already exists
|
|
130
|
+
try {
|
|
131
|
+
await readForm(slug);
|
|
132
|
+
const err = new Error(`Form with slug "${slug}" already exists`);
|
|
133
|
+
err.code = 'FORM_ALREADY_EXISTS';
|
|
134
|
+
throw err;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.code === 'FORM_ALREADY_EXISTS') throw err;
|
|
137
|
+
// File not found — safe to proceed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const now = new Date().toISOString();
|
|
141
|
+
const trimmedTitle = title ? title.trim() : slug;
|
|
142
|
+
|
|
143
|
+
const form = {
|
|
144
|
+
slug,
|
|
145
|
+
title: trimmedTitle,
|
|
146
|
+
description,
|
|
147
|
+
fields: Array.isArray(fields) ? fields : [],
|
|
148
|
+
settings: {
|
|
149
|
+
submitText: 'Submit',
|
|
150
|
+
successMessage: 'Thank you for your submission.',
|
|
151
|
+
layout: 'stacked',
|
|
152
|
+
honeypot: true,
|
|
153
|
+
rateLimitPerMinute: 3,
|
|
154
|
+
...settings
|
|
155
|
+
},
|
|
156
|
+
actions: {
|
|
157
|
+
email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
|
|
158
|
+
webhook: { enabled: false, url: '', method: 'POST' },
|
|
159
|
+
collection: { enabled: true, slug },
|
|
160
|
+
...actions
|
|
161
|
+
},
|
|
162
|
+
createdAt: now,
|
|
163
|
+
updatedAt: now
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await writeForm(slug, form);
|
|
167
|
+
return form;
|
|
168
|
+
}
|
|
169
|
+
|
|
93
170
|
/** System collections that should never have an auto-generated public form. */
|
|
94
171
|
const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
|
|
95
172
|
|
|
@@ -250,12 +250,42 @@ export async function runLifecycleHook(name, hook, fastify) {
|
|
|
250
250
|
const mod = await import(pluginJsPath);
|
|
251
251
|
if (typeof mod[hook] !== 'function') return;
|
|
252
252
|
|
|
253
|
-
|
|
253
|
+
// Use Promise.allSettled to prevent total failure if any service import rejects
|
|
254
|
+
// (e.g. MongoDB-dependent modules like actions.js or views.js)
|
|
255
|
+
const results = await Promise.allSettled([
|
|
254
256
|
import(path.resolve('server/services/collections.js')),
|
|
257
|
+
import(path.resolve('server/services/forms.js')),
|
|
258
|
+
import(path.resolve('server/services/blocks.js')),
|
|
259
|
+
import(path.resolve('server/services/content.js')),
|
|
260
|
+
import(path.resolve('server/config.js')),
|
|
255
261
|
import(path.resolve('server/services/roles.js')),
|
|
262
|
+
import(path.resolve('server/services/users.js')),
|
|
263
|
+
import(path.resolve('server/services/actions.js')),
|
|
264
|
+
import(path.resolve('server/services/views.js')),
|
|
256
265
|
]);
|
|
257
266
|
|
|
258
|
-
|
|
267
|
+
const [collectionsResult, formsResult, blocksResult, contentResult, configResult, rolesResult, usersResult, actionsResult, viewsResult] = results;
|
|
268
|
+
|
|
269
|
+
const services = {
|
|
270
|
+
collections: collectionsResult.status === 'fulfilled' ? collectionsResult.value : null,
|
|
271
|
+
forms: formsResult.status === 'fulfilled' ? formsResult.value : null,
|
|
272
|
+
blocks: blocksResult.status === 'fulfilled' ? blocksResult.value : null,
|
|
273
|
+
content: contentResult.status === 'fulfilled' ? contentResult.value : null,
|
|
274
|
+
config: configResult.status === 'fulfilled' ? configResult.value : null,
|
|
275
|
+
roles: rolesResult.status === 'fulfilled' ? rolesResult.value : null,
|
|
276
|
+
users: usersResult.status === 'fulfilled' ? usersResult.value : null,
|
|
277
|
+
actions: actionsResult.status === 'fulfilled' ? actionsResult.value : null,
|
|
278
|
+
views: viewsResult.status === 'fulfilled' ? viewsResult.value : null,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Log any import failures as warnings (not errors)
|
|
282
|
+
results.forEach((result, i) => {
|
|
283
|
+
if (result.status === 'rejected') {
|
|
284
|
+
fastify.log.warn({ err: result.reason }, `[plugins] Failed to load service ${i} for lifecycle hook`);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await mod[hook]({ fastify, services });
|
|
259
289
|
} catch (err) {
|
|
260
290
|
fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
|
|
261
291
|
}
|