domma-cms 0.17.0 → 0.21.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 +39 -3
- package/admin/css/admin.css +1 -1
- package/admin/css/dashboard.css +1 -1
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +6 -6
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard.html +4 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +41 -36
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +88 -7
- package/server/server.js +54 -3
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +23 -8
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +102 -12
- package/server/services/content.js +25 -6
- package/server/services/filterEngine.js +281 -0
- package/server/services/forms.js +3 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +711 -124
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +237 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +19 -4
- package/server/templates/page.html +135 -130
- /package/config/{navigation.json → navigation.json.bak} +0 -0
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from '../../services/collections.js';
|
|
41
41
|
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
42
42
|
import {getRoleLevel, invalidate as invalidateRoles} from '../../services/roles.js';
|
|
43
|
+
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
43
44
|
import {getConfig, saveConfig} from '../../config.js';
|
|
44
45
|
import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
|
|
45
46
|
import {ensureFormForCollection} from '../../services/forms.js';
|
|
@@ -57,6 +58,30 @@ function roleLevel(roleName) {
|
|
|
57
58
|
return getRoleLevel(roleName);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Extract bracket-notation query params (e.g. `filter[location]=London`) into
|
|
63
|
+
* a flat sub-object keyed by the inner name.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* extractBracketed({ 'filter[location]': 'London', 'filter[salary_gte]': '50000' }, 'filter')
|
|
67
|
+
* → { location: 'London', salary_gte: '50000' }
|
|
68
|
+
*
|
|
69
|
+
* @param {Record<string, string>} query
|
|
70
|
+
* @param {string} prefix
|
|
71
|
+
* @returns {Record<string, string>}
|
|
72
|
+
*/
|
|
73
|
+
function extractBracketed(query, prefix) {
|
|
74
|
+
const out = {};
|
|
75
|
+
const head = `${prefix}[`;
|
|
76
|
+
for (const [k, v] of Object.entries(query)) {
|
|
77
|
+
if (k.startsWith(head) && k.endsWith(']')) {
|
|
78
|
+
const inner = k.slice(head.length, -1);
|
|
79
|
+
if (inner) out[inner] = v;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
/**
|
|
61
86
|
* Check public collection API access.
|
|
62
87
|
* Returns an error reply if access is denied, otherwise resolves (returns undefined).
|
|
@@ -84,7 +109,9 @@ async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
84
109
|
|
|
85
110
|
const user = request.user;
|
|
86
111
|
const requiredLevel = roleLevel(access.access);
|
|
87
|
-
|
|
112
|
+
// Effective level — multi-role users get the lowest (most privileged) level
|
|
113
|
+
// across their primary + additional roles for this access decision.
|
|
114
|
+
const userLevel = getEffectiveLevel(user);
|
|
88
115
|
|
|
89
116
|
if (userLevel > requiredLevel) {
|
|
90
117
|
return reply.status(403).send({ error: 'Insufficient permissions' });
|
|
@@ -101,8 +128,16 @@ export async function collectionsRoutes(fastify) {
|
|
|
101
128
|
// Collection CRUD (schema management)
|
|
102
129
|
// -------------------------------------------------------------------------
|
|
103
130
|
|
|
104
|
-
fastify.get('/collections', canRead, async () => {
|
|
105
|
-
|
|
131
|
+
fastify.get('/collections', canRead, async (request) => {
|
|
132
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
133
|
+
const all = await listCollections();
|
|
134
|
+
const filtered = [];
|
|
135
|
+
for (const c of all) {
|
|
136
|
+
// listCollections returns metadata only; getCollection returns full record with meta
|
|
137
|
+
const full = await getCollection(c.slug);
|
|
138
|
+
if (canSeeArtefact(request.user, full)) filtered.push(c);
|
|
139
|
+
}
|
|
140
|
+
return filtered;
|
|
106
141
|
});
|
|
107
142
|
|
|
108
143
|
fastify.get('/collections/pro-status', canRead, async () => {
|
|
@@ -153,6 +188,10 @@ export async function collectionsRoutes(fastify) {
|
|
|
153
188
|
fastify.get('/collections/:slug', canRead, async (request, reply) => {
|
|
154
189
|
const schema = await getCollection(request.params.slug);
|
|
155
190
|
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
191
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
192
|
+
if (!canSeeArtefact(request.user, schema)) {
|
|
193
|
+
return reply.status(403).send({ error: 'Access denied for this project' });
|
|
194
|
+
}
|
|
156
195
|
return schema;
|
|
157
196
|
});
|
|
158
197
|
|
|
@@ -180,16 +219,34 @@ export async function collectionsRoutes(fastify) {
|
|
|
180
219
|
// Entry CRUD
|
|
181
220
|
// -------------------------------------------------------------------------
|
|
182
221
|
|
|
222
|
+
/*
|
|
223
|
+
* GET /api/collections/:slug/entries
|
|
224
|
+
*
|
|
225
|
+
* Query parameters:
|
|
226
|
+
* page, limit, sort, order — pagination/sort (limit=0 means "no limit")
|
|
227
|
+
* search — free-text substring across all field values
|
|
228
|
+
* filter[<field>_<op>]=val — structured filter; see filterEngine.js
|
|
229
|
+
*
|
|
230
|
+
* Example:
|
|
231
|
+
* /api/collections/jobs/entries?filter[location]=London&filter[salary_gte]=50000
|
|
232
|
+
* /api/collections/jobs/entries?filter[tags_in]=remote,hybrid&sort=postedAt
|
|
233
|
+
*
|
|
234
|
+
* Fastify's default querystring parser keeps bracketed keys flat
|
|
235
|
+
* (`'filter[location]': 'London'`), so we expand them ourselves to avoid
|
|
236
|
+
* adding the `qs` dependency for one route.
|
|
237
|
+
*/
|
|
183
238
|
fastify.get('/collections/:slug/entries', canRead, async (request, reply) => {
|
|
184
239
|
const schema = await getCollection(request.params.slug);
|
|
185
240
|
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
186
241
|
const { page, limit, sort, order, search } = request.query;
|
|
242
|
+
const filter = extractBracketed(request.query, 'filter');
|
|
187
243
|
return listEntries(request.params.slug, {
|
|
188
244
|
page: parseInt(page, 10) || 1,
|
|
189
245
|
limit: parseInt(limit, 10) || 50,
|
|
190
246
|
sort: sort || 'createdAt',
|
|
191
247
|
order: order || 'desc',
|
|
192
|
-
search: search || undefined
|
|
248
|
+
search: search || undefined,
|
|
249
|
+
filter: Object.keys(filter).length ? filter : undefined
|
|
193
250
|
});
|
|
194
251
|
});
|
|
195
252
|
|
|
@@ -345,20 +402,54 @@ export async function collectionsRoutes(fastify) {
|
|
|
345
402
|
// Public access endpoints
|
|
346
403
|
// -------------------------------------------------------------------------
|
|
347
404
|
|
|
405
|
+
/*
|
|
406
|
+
* GET /api/collections/:slug/public
|
|
407
|
+
*
|
|
408
|
+
* Public read endpoint — gated by `api.read` in the collection schema.
|
|
409
|
+
* Supports the same pagination/sort/search/filter as the admin endpoint
|
|
410
|
+
* (see GET /collections/:slug/entries above), plus:
|
|
411
|
+
*
|
|
412
|
+
* scope=mine — restrict results to entries created by the current
|
|
413
|
+
* user (`meta.createdBy === user.id`). Requires a valid
|
|
414
|
+
* JWT; returns 401 if unauthenticated. Used by the
|
|
415
|
+
* `[collection scope="mine"]` shortcode for per-user
|
|
416
|
+
* client-side hydration (e.g. "My Applications").
|
|
417
|
+
*/
|
|
348
418
|
fastify.get('/collections/:slug/public', async (request, reply) => {
|
|
349
419
|
const schema = await getCollection(request.params.slug);
|
|
350
420
|
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
351
421
|
|
|
352
|
-
const
|
|
353
|
-
|
|
422
|
+
const { page, limit, sort, order, search, scope, resolveRefs } = request.query;
|
|
423
|
+
const filter = extractBracketed(request.query, 'filter');
|
|
424
|
+
|
|
425
|
+
// scope=mine has its own security model: it requires auth and scopes
|
|
426
|
+
// results to the caller's own entries via `createdBy = user.id`. Bypasses
|
|
427
|
+
// the regular `api.read` gate so users can always view their own data
|
|
428
|
+
// (the typical "My applications", "My bookmarks", "My orders" pattern)
|
|
429
|
+
// without the admin having to expose the whole collection publicly.
|
|
430
|
+
if (scope === 'mine') {
|
|
431
|
+
try {
|
|
432
|
+
const decoded = await request.jwtVerify();
|
|
433
|
+
if (decoded.type !== 'access' || !decoded.id) {
|
|
434
|
+
return reply.status(401).send({ error: 'Authentication required' });
|
|
435
|
+
}
|
|
436
|
+
filter.createdBy = decoded.id;
|
|
437
|
+
} catch {
|
|
438
|
+
return reply.status(401).send({ error: 'Authentication required' });
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
const denied = await checkPublicAccess(schema, 'read', request, reply);
|
|
442
|
+
if (denied !== undefined) return;
|
|
443
|
+
}
|
|
354
444
|
|
|
355
|
-
const { page, limit, sort, order, search } = request.query;
|
|
356
445
|
return listEntries(request.params.slug, {
|
|
357
446
|
page: parseInt(page, 10) || 1,
|
|
358
447
|
limit: parseInt(limit, 10) || 50,
|
|
359
448
|
sort: sort || 'createdAt',
|
|
360
449
|
order: order || 'desc',
|
|
361
|
-
search: search || undefined
|
|
450
|
+
search: search || undefined,
|
|
451
|
+
filter: Object.keys(filter).length ? filter : undefined,
|
|
452
|
+
resolveRefs: resolveRefs === 'true' || resolveRefs === '1'
|
|
362
453
|
});
|
|
363
454
|
});
|
|
364
455
|
|
|
@@ -374,6 +465,108 @@ export async function collectionsRoutes(fastify) {
|
|
|
374
465
|
return entry;
|
|
375
466
|
});
|
|
376
467
|
|
|
468
|
+
/*
|
|
469
|
+
* POST /api/collections/render-scope
|
|
470
|
+
*
|
|
471
|
+
* Renders a `[collection scope="mine"]` shortcode for the current user.
|
|
472
|
+
* Called by site.js for every `.dm-collection-hydrate` placeholder on a
|
|
473
|
+
* public page. The page itself is cached per-role; the per-user data is
|
|
474
|
+
* resolved here and the rendered HTML fragment swapped in client-side.
|
|
475
|
+
*
|
|
476
|
+
* Body: { attrs: <base64 JSON of the original shortcode attributes> }
|
|
477
|
+
* Auth: JWT required — `createdBy = <user.id>` is injected server-side
|
|
478
|
+
* (the client cannot tamper with which user's data they see).
|
|
479
|
+
* Returns: { html: '<div class="dm-collection-…">…</div>' }
|
|
480
|
+
*
|
|
481
|
+
* Security: results are always scoped to `createdBy = <user.id>` —
|
|
482
|
+
* a caller can only ever render their own entries, regardless of the
|
|
483
|
+
* collection's `api.read` setting. This matches the security model
|
|
484
|
+
* of GET /:slug/public?scope=mine.
|
|
485
|
+
*/
|
|
486
|
+
fastify.post('/collections/render-fragment', async (request, reply) => {
|
|
487
|
+
/*
|
|
488
|
+
* POST /api/collections/render-fragment
|
|
489
|
+
*
|
|
490
|
+
* Renders a `[collection]` shortcode fragment server-side for the
|
|
491
|
+
* Collection Browser's `display="block"` mode (and any future display
|
|
492
|
+
* that needs server-rendered HTML per state change). Used because
|
|
493
|
+
* block templates are arbitrary HTML the client can't replicate.
|
|
494
|
+
*
|
|
495
|
+
* Body: {
|
|
496
|
+
* attrs: <base64 JSON of the shortcode attributes>,
|
|
497
|
+
* page: <number, optional — applied via attrs.limit + slicing>
|
|
498
|
+
* }
|
|
499
|
+
*
|
|
500
|
+
* Auth: gated by the target collection's `api.read` setting via
|
|
501
|
+
* checkPublicAccess(). No user identity is injected — this
|
|
502
|
+
* endpoint is for general fragment rendering, not per-user
|
|
503
|
+
* scoping (that's render-scope's job).
|
|
504
|
+
*
|
|
505
|
+
* Returns: { html: '<div class="dm-collection-…">…</div>' }
|
|
506
|
+
*/
|
|
507
|
+
const encoded = request.body?.attrs;
|
|
508
|
+
if (typeof encoded !== 'string' || !encoded) {
|
|
509
|
+
return reply.status(400).send({ error: 'attrs is required' });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let attrs;
|
|
513
|
+
try {
|
|
514
|
+
attrs = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
|
|
515
|
+
} catch {
|
|
516
|
+
return reply.status(400).send({ error: 'Invalid attrs payload' });
|
|
517
|
+
}
|
|
518
|
+
if (!attrs || typeof attrs !== 'object' || typeof attrs.slug !== 'string') {
|
|
519
|
+
return reply.status(400).send({ error: 'Invalid attrs payload' });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const schema = await getCollection(attrs.slug);
|
|
523
|
+
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
524
|
+
|
|
525
|
+
const denied = await checkPublicAccess(schema, 'read', request, reply);
|
|
526
|
+
if (denied !== undefined) return;
|
|
527
|
+
|
|
528
|
+
const { renderCollectionFragment } = await import('../../services/markdown.js');
|
|
529
|
+
const html = await renderCollectionFragment(attrs);
|
|
530
|
+
return { html };
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
fastify.post('/collections/render-scope', async (request, reply) => {
|
|
534
|
+
let decoded;
|
|
535
|
+
try {
|
|
536
|
+
decoded = await request.jwtVerify();
|
|
537
|
+
if (decoded.type !== 'access' || !decoded.id) {
|
|
538
|
+
return reply.status(401).send({ error: 'Authentication required' });
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
return reply.status(401).send({ error: 'Authentication required' });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const encoded = request.body?.attrs;
|
|
545
|
+
if (typeof encoded !== 'string' || !encoded) {
|
|
546
|
+
return reply.status(400).send({ error: 'attrs is required' });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let attrs;
|
|
550
|
+
try {
|
|
551
|
+
attrs = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
|
|
552
|
+
} catch {
|
|
553
|
+
return reply.status(400).send({ error: 'Invalid attrs payload' });
|
|
554
|
+
}
|
|
555
|
+
if (!attrs || typeof attrs !== 'object' || typeof attrs.slug !== 'string') {
|
|
556
|
+
return reply.status(400).send({ error: 'Invalid attrs payload' });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const schema = await getCollection(attrs.slug);
|
|
560
|
+
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
561
|
+
|
|
562
|
+
const { renderCollectionFragment } = await import('../../services/markdown.js');
|
|
563
|
+
const html = await renderCollectionFragment(attrs, {
|
|
564
|
+
extraFilter: { createdBy: decoded.id }
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
return { html };
|
|
568
|
+
});
|
|
569
|
+
|
|
377
570
|
fastify.post('/collections/:slug/public', async (request, reply) => {
|
|
378
571
|
const schema = await getCollection(request.params.slug);
|
|
379
572
|
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
@@ -34,6 +34,161 @@ import {
|
|
|
34
34
|
import {getConfig} from '../../config.js';
|
|
35
35
|
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
36
36
|
import {hooks} from '../../services/hooks.js';
|
|
37
|
+
import {saveMedia} from '../../services/content.js';
|
|
38
|
+
import {v4 as uuidv4} from 'uuid';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect form↔collection mismatches at save time so admins know BEFORE
|
|
42
|
+
* users hit silent rejection. Returns an array of human-readable warning
|
|
43
|
+
* strings — empty when the form lines up cleanly with its target collection.
|
|
44
|
+
*
|
|
45
|
+
* Checks performed:
|
|
46
|
+
* - Target collection exists (otherwise every submission goes nowhere)
|
|
47
|
+
* - Every required collection field has a matching form field
|
|
48
|
+
* - Every form field references a known collection field (typos)
|
|
49
|
+
*
|
|
50
|
+
* @param {object} form - The just-saved form definition
|
|
51
|
+
* @returns {Promise<string[]>}
|
|
52
|
+
*/
|
|
53
|
+
async function detectFormCollectionMismatch(form) {
|
|
54
|
+
const warnings = [];
|
|
55
|
+
const colAction = form?.actions?.collection;
|
|
56
|
+
const targetSlug = (colAction?.enabled && colAction.slug) ? colAction.slug : form?.slug;
|
|
57
|
+
if (!targetSlug) return warnings;
|
|
58
|
+
|
|
59
|
+
let collection;
|
|
60
|
+
try {
|
|
61
|
+
collection = await getCollection(targetSlug);
|
|
62
|
+
} catch {
|
|
63
|
+
return warnings;
|
|
64
|
+
}
|
|
65
|
+
if (!collection) {
|
|
66
|
+
warnings.push(`Target collection "${targetSlug}" does not exist — submissions will fail. Either create the collection or change the form's target.`);
|
|
67
|
+
return warnings;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const formFieldNames = new Set((form.fields || []).map(f => f.name).filter(Boolean));
|
|
71
|
+
const colFieldNames = new Set((collection.fields || []).map(f => f.name));
|
|
72
|
+
|
|
73
|
+
// Missing-required: collection requires X but form doesn't collect X
|
|
74
|
+
const missingRequired = (collection.fields || [])
|
|
75
|
+
.filter(f => f.required)
|
|
76
|
+
.filter(f => !formFieldNames.has(f.name))
|
|
77
|
+
.map(f => f.label || f.name);
|
|
78
|
+
if (missingRequired.length) {
|
|
79
|
+
warnings.push(`The "${targetSlug}" collection requires fields the form does not collect: ${missingRequired.join(', ')}. Submissions will be rejected with "X is required". Either mark these fields as not required on the collection, add them to the form, or set them via an action's createInCollection step.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Unknown fields: form sends X but collection has no such field (typo / drift)
|
|
83
|
+
const unknown = [...formFieldNames].filter(n => !colFieldNames.has(n));
|
|
84
|
+
if (unknown.length) {
|
|
85
|
+
warnings.push(`The form has fields the "${targetSlug}" collection doesn't define: ${unknown.join(', ')}. These values will be stored but won't be validated, indexed, or displayed in [collection] blocks unless you add matching fields to the collection.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return warnings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sanitise a user-supplied filename:
|
|
93
|
+
* - strip path separators and traversal markers
|
|
94
|
+
* - keep only `[a-z0-9._-]`, lower-case
|
|
95
|
+
* - prepend an 8-char uuid prefix so concurrent uploads of the same name
|
|
96
|
+
* don't overwrite each other
|
|
97
|
+
*
|
|
98
|
+
* @param {string} raw
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function safeFilename(raw) {
|
|
102
|
+
const cleaned = String(raw || 'upload')
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/[^\w.-]+/g, '-')
|
|
105
|
+
.replace(/-+/g, '-')
|
|
106
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
107
|
+
.slice(0, 200) || 'upload';
|
|
108
|
+
const prefix = uuidv4().slice(0, 8);
|
|
109
|
+
return `${prefix}-${cleaned}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check a multipart file part against a form's `type: file` field config.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} fieldCfg - The form-field definition
|
|
116
|
+
* @param {object} part - Multipart part (mimetype, filename)
|
|
117
|
+
* @param {number} size - Buffer length in bytes
|
|
118
|
+
* @returns {string|null} - Error message, or null if OK
|
|
119
|
+
*/
|
|
120
|
+
function validateUpload(fieldCfg, part, size) {
|
|
121
|
+
const cfg = fieldCfg?.file || {};
|
|
122
|
+
const max = Number(cfg.maxSize) || 5 * 1024 * 1024; // 5 MB default
|
|
123
|
+
if (size > max) {
|
|
124
|
+
return `"${fieldCfg.label || fieldCfg.name}" file is too large (max ${Math.round(max / 1024)} KB).`;
|
|
125
|
+
}
|
|
126
|
+
const accept = String(cfg.accept || '').trim();
|
|
127
|
+
if (accept) {
|
|
128
|
+
// Comma-separated list of mime types, allowing wildcards like image/*
|
|
129
|
+
const patterns = accept.split(',').map(s => s.trim()).filter(Boolean);
|
|
130
|
+
const mime = part.mimetype || '';
|
|
131
|
+
const ok = patterns.some(p => {
|
|
132
|
+
if (p.endsWith('/*')) return mime.startsWith(p.slice(0, -1));
|
|
133
|
+
return p === mime;
|
|
134
|
+
});
|
|
135
|
+
if (!ok) {
|
|
136
|
+
return `"${fieldCfg.label || fieldCfg.name}" file type "${mime}" not allowed (expected: ${accept}).`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Drain a multipart request into a JSON-shaped body. Text parts become string
|
|
144
|
+
* values; file parts are validated against the matching form field, saved to
|
|
145
|
+
* `content/media/`, and replaced by a `{ url, name, size, mime }` reference.
|
|
146
|
+
*
|
|
147
|
+
* Throws on validation failure (caller handles HTTP 400). Files are streamed
|
|
148
|
+
* into memory up to fastify-multipart's configured limit — fine for the
|
|
149
|
+
* resume-pdf / portrait-photo scale; large uploads should still use the admin
|
|
150
|
+
* media flow.
|
|
151
|
+
*
|
|
152
|
+
* @param {import('fastify').FastifyRequest} request
|
|
153
|
+
* @param {object} form
|
|
154
|
+
* @returns {Promise<object>}
|
|
155
|
+
*/
|
|
156
|
+
async function parseMultipartForm(request, form) {
|
|
157
|
+
const out = {};
|
|
158
|
+
const fileFields = new Map(
|
|
159
|
+
(form.fields || []).filter(f => f.type === 'file').map(f => [f.name, f])
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
for await (const part of request.parts()) {
|
|
163
|
+
if (part.type === 'file') {
|
|
164
|
+
const fieldCfg = fileFields.get(part.fieldname);
|
|
165
|
+
if (!fieldCfg) {
|
|
166
|
+
// Unknown file field — drain and skip rather than 400, so a
|
|
167
|
+
// form that gains a file input later doesn't reject older
|
|
168
|
+
// submissions in-flight from a stale page.
|
|
169
|
+
for await (const _ of part.file) { /* drain */ }
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const chunks = [];
|
|
173
|
+
for await (const chunk of part.file) chunks.push(chunk);
|
|
174
|
+
const buffer = Buffer.concat(chunks);
|
|
175
|
+
const err = validateUpload(fieldCfg, part, buffer.length);
|
|
176
|
+
if (err) throw new Error(err);
|
|
177
|
+
if (buffer.length === 0) continue; // empty file input — skip
|
|
178
|
+
const saved = await saveMedia(safeFilename(part.filename), buffer);
|
|
179
|
+
out[part.fieldname] = {
|
|
180
|
+
url: saved.url,
|
|
181
|
+
name: part.filename,
|
|
182
|
+
size: buffer.length,
|
|
183
|
+
mime: part.mimetype
|
|
184
|
+
};
|
|
185
|
+
} else {
|
|
186
|
+
// Text field — last value wins on duplicates (browser-standard).
|
|
187
|
+
out[part.fieldname] = part.value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
37
192
|
|
|
38
193
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
194
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
@@ -84,19 +239,27 @@ export async function formsRoutes(fastify) {
|
|
|
84
239
|
// -----------------------------------------------------------------------
|
|
85
240
|
// GET /forms — list all form definitions with submission counts
|
|
86
241
|
// -----------------------------------------------------------------------
|
|
87
|
-
fastify.get('/forms', canRead, async () => {
|
|
242
|
+
fastify.get('/forms', canRead, async (request) => {
|
|
243
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
88
244
|
const forms = await listForms();
|
|
89
245
|
const result = await Promise.all(forms.map(async form => {
|
|
90
246
|
let submissionCount = 0;
|
|
91
247
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
248
|
+
// listEntries returns { entries, total, page, limit } — total is the
|
|
249
|
+
// unpaginated count which is what we want for the badge here.
|
|
250
|
+
const r = await listEntries(form.slug, { limit: 0 });
|
|
251
|
+
submissionCount = r.total ?? (r.entries || []).length;
|
|
94
252
|
} catch {
|
|
95
253
|
// collection may not exist yet
|
|
96
254
|
}
|
|
97
|
-
|
|
255
|
+
// listForms returns metadata only; readForm returns full record with meta
|
|
256
|
+
let full = null;
|
|
257
|
+
try { full = await readForm(form.slug); } catch { /* skip */ }
|
|
258
|
+
return { form: { ...form, submissionCount }, full };
|
|
98
259
|
}));
|
|
99
|
-
return result
|
|
260
|
+
return result
|
|
261
|
+
.filter(({ full }) => canSeeArtefact(request.user, full))
|
|
262
|
+
.map(({ form }) => form);
|
|
100
263
|
});
|
|
101
264
|
|
|
102
265
|
// -----------------------------------------------------------------------
|
|
@@ -165,18 +328,25 @@ export async function formsRoutes(fastify) {
|
|
|
165
328
|
fastify.log.warn(`[forms] Could not auto-create collection "${slug}": ${err.message}`);
|
|
166
329
|
}
|
|
167
330
|
|
|
168
|
-
|
|
331
|
+
const warnings = await detectFormCollectionMismatch(form);
|
|
332
|
+
return reply.status(201).send(warnings.length ? { ...form, warnings } : form);
|
|
169
333
|
});
|
|
170
334
|
|
|
171
335
|
// -----------------------------------------------------------------------
|
|
172
336
|
// GET /forms/:slug — get single form (admin, includes actions)
|
|
173
337
|
// -----------------------------------------------------------------------
|
|
174
338
|
fastify.get('/forms/:slug', canRead, async (request, reply) => {
|
|
339
|
+
let form;
|
|
175
340
|
try {
|
|
176
|
-
|
|
341
|
+
form = await readForm(request.params.slug);
|
|
177
342
|
} catch {
|
|
178
343
|
return reply.status(404).send({ error: 'Form not found.' });
|
|
179
344
|
}
|
|
345
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
346
|
+
if (!canSeeArtefact(request.user, form)) {
|
|
347
|
+
return reply.status(403).send({ error: 'Access denied for this project' });
|
|
348
|
+
}
|
|
349
|
+
return form;
|
|
180
350
|
});
|
|
181
351
|
|
|
182
352
|
// -----------------------------------------------------------------------
|
|
@@ -213,7 +383,13 @@ export async function formsRoutes(fastify) {
|
|
|
213
383
|
updatedAt: new Date().toISOString()
|
|
214
384
|
};
|
|
215
385
|
await writeForm(slug, updated);
|
|
216
|
-
|
|
386
|
+
|
|
387
|
+
// Non-blocking warnings: check the form's fields against its target
|
|
388
|
+
// collection's required fields. Missing-required-on-form would cause
|
|
389
|
+
// every submission to fail server-side validation — admin gets a
|
|
390
|
+
// heads-up here so they can fix it BEFORE users hit silent rejection.
|
|
391
|
+
const warnings = await detectFormCollectionMismatch(updated);
|
|
392
|
+
return warnings.length ? { ...updated, warnings } : updated;
|
|
217
393
|
});
|
|
218
394
|
|
|
219
395
|
// -----------------------------------------------------------------------
|
|
@@ -235,8 +411,11 @@ export async function formsRoutes(fastify) {
|
|
|
235
411
|
fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
|
|
236
412
|
const { slug } = request.params;
|
|
237
413
|
try {
|
|
238
|
-
|
|
239
|
-
|
|
414
|
+
// listEntries returns { entries, total, page, limit } — unwrap to
|
|
415
|
+
// the array. Newest-first is the conventional display order for
|
|
416
|
+
// submission lists.
|
|
417
|
+
const { entries } = await listEntries(slug, { limit: 0 });
|
|
418
|
+
return [...entries].reverse();
|
|
240
419
|
} catch {
|
|
241
420
|
return reply.status(404).send({ error: 'Collection not found for this form.' });
|
|
242
421
|
}
|
|
@@ -255,7 +434,8 @@ export async function formsRoutes(fastify) {
|
|
|
255
434
|
}
|
|
256
435
|
let entries = [];
|
|
257
436
|
try {
|
|
258
|
-
|
|
437
|
+
const r = await listEntries(slug, { limit: 0 });
|
|
438
|
+
entries = r.entries || [];
|
|
259
439
|
} catch {
|
|
260
440
|
// empty collection
|
|
261
441
|
}
|
|
@@ -278,7 +458,8 @@ export async function formsRoutes(fastify) {
|
|
|
278
458
|
}
|
|
279
459
|
let entries = [];
|
|
280
460
|
try {
|
|
281
|
-
|
|
461
|
+
const r = await listEntries(slug, { limit: 0 });
|
|
462
|
+
entries = r.entries || [];
|
|
282
463
|
} catch {
|
|
283
464
|
// empty
|
|
284
465
|
}
|
|
@@ -313,9 +494,20 @@ export async function formsRoutes(fastify) {
|
|
|
313
494
|
return { ok: true };
|
|
314
495
|
});
|
|
315
496
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
497
|
+
/*
|
|
498
|
+
* POST /forms/submit/:slug — public form submission.
|
|
499
|
+
*
|
|
500
|
+
* Authentication is OPTIONAL: when a valid JWT is present the submitter
|
|
501
|
+
* is captured as the entry's `createdBy` and passed into any triggered
|
|
502
|
+
* action's template context (as `{{user.id}}`, `{{user.email}}`, etc.).
|
|
503
|
+
* Anonymous submissions still work — `createdBy` is null and actions
|
|
504
|
+
* receive an empty user. This is what makes the apply/bookmark patterns
|
|
505
|
+
* work without forcing every form to be auth-only:
|
|
506
|
+
*
|
|
507
|
+
* - Anonymous contact form → submitted by guest, user=null
|
|
508
|
+
* - "Apply for job" form → submitted by candidate, action's
|
|
509
|
+
* createInCollection step stamps the application with their id
|
|
510
|
+
*/
|
|
319
511
|
fastify.post('/forms/submit/:slug', async (request, reply) => {
|
|
320
512
|
const { slug } = request.params;
|
|
321
513
|
let form;
|
|
@@ -325,9 +517,35 @@ export async function formsRoutes(fastify) {
|
|
|
325
517
|
return reply.status(404).send({ error: 'Form not found.' });
|
|
326
518
|
}
|
|
327
519
|
|
|
328
|
-
|
|
520
|
+
// Multipart parsing — when the request is multipart (file uploads),
|
|
521
|
+
// we walk parts ourselves: text fields go into `body`, file parts are
|
|
522
|
+
// validated against the form's field config and saved to /content/media,
|
|
523
|
+
// then their { url, name, size, mime } reference object lands in `body`
|
|
524
|
+
// under the field name. Downstream code sees a uniform JSON-shaped body.
|
|
525
|
+
let body = request.body || {};
|
|
526
|
+
if (request.isMultipart && request.isMultipart()) {
|
|
527
|
+
try {
|
|
528
|
+
body = await parseMultipartForm(request, form);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
return reply.status(400).send({ error: err.message });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
329
533
|
const settings = form.settings || {};
|
|
330
534
|
|
|
535
|
+
// Best-effort auth — never reject, just enrich.
|
|
536
|
+
let submittingUser = null;
|
|
537
|
+
try {
|
|
538
|
+
const decoded = await request.jwtVerify();
|
|
539
|
+
if (decoded.type === 'access') {
|
|
540
|
+
submittingUser = {
|
|
541
|
+
id: decoded.id,
|
|
542
|
+
name: decoded.name,
|
|
543
|
+
email: decoded.email,
|
|
544
|
+
role: decoded.role
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
} catch { /* anonymous submission */ }
|
|
548
|
+
|
|
331
549
|
// Honeypot check — silently accept if filled (bot detected)
|
|
332
550
|
if (settings.honeypot && body._hp) {
|
|
333
551
|
return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
|
|
@@ -397,17 +615,42 @@ export async function formsRoutes(fastify) {
|
|
|
397
615
|
}
|
|
398
616
|
}
|
|
399
617
|
|
|
400
|
-
// Store in collection (sole submission store)
|
|
618
|
+
// Store in collection (sole submission store).
|
|
619
|
+
//
|
|
620
|
+
// CRITICAL: collection write failure is the difference between "your
|
|
621
|
+
// submission was saved" and "your submission disappeared into thin air".
|
|
622
|
+
// We MUST surface the error to the user rather than logging a warning
|
|
623
|
+
// and returning success — anything else is a silent-data-loss bug.
|
|
624
|
+
// (Email / webhook / action failures downstream are still treated as
|
|
625
|
+
// non-fatal — they fire AFTER the entry is persisted, so even a partial
|
|
626
|
+
// delivery has the original submission safely stored.)
|
|
401
627
|
const collectionAction = form.actions?.collection;
|
|
402
628
|
const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
|
|
403
629
|
let entry = null;
|
|
404
630
|
try {
|
|
405
631
|
const col = await getCollection(targetSlug);
|
|
406
632
|
if (col) {
|
|
407
|
-
entry = await createEntry(targetSlug, data, {
|
|
633
|
+
entry = await createEntry(targetSlug, data, {
|
|
634
|
+
source: `form:${slug}`,
|
|
635
|
+
createdBy: submittingUser?.id || null
|
|
636
|
+
});
|
|
637
|
+
} else {
|
|
638
|
+
// Target collection doesn't exist — admin misconfiguration.
|
|
639
|
+
// Better to fail loudly than silently lose submissions.
|
|
640
|
+
fastify.log.warn(`[forms] Submission for "${slug}" had no target collection "${targetSlug}"`);
|
|
641
|
+
return reply.status(500).send({
|
|
642
|
+
error: `Submission not saved — target collection "${targetSlug}" does not exist. Please contact the site administrator.`
|
|
643
|
+
});
|
|
408
644
|
}
|
|
409
645
|
} catch (err) {
|
|
410
|
-
fastify.log.warn(`[forms] Collection write failed for "${slug}": ${err.message}`);
|
|
646
|
+
fastify.log.warn(`[forms] Collection write failed for "${slug}" → "${targetSlug}": ${err.message}`);
|
|
647
|
+
// Distinguish validation errors (user-actionable) from other
|
|
648
|
+
// failures (admin needs to look) so the message helps the right
|
|
649
|
+
// person. Validation errors typically start with "Validation failed:".
|
|
650
|
+
const msg = err.message.startsWith('Validation failed')
|
|
651
|
+
? err.message.replace('Validation failed: ', '')
|
|
652
|
+
: `Submission could not be saved: ${err.message}`;
|
|
653
|
+
return reply.status(400).send({ error: msg });
|
|
411
654
|
}
|
|
412
655
|
|
|
413
656
|
// Email action
|
|
@@ -444,11 +687,13 @@ export async function formsRoutes(fastify) {
|
|
|
444
687
|
}
|
|
445
688
|
}
|
|
446
689
|
|
|
447
|
-
// CMS Action trigger
|
|
690
|
+
// CMS Action trigger — forwards the authenticated submitter so
|
|
691
|
+
// action steps can interpolate {{user.id}}, {{user.email}}, etc.
|
|
692
|
+
// (Anonymous submissions still trigger the action with user={}.)
|
|
448
693
|
const actionSlug = form.settings?.actionSlug;
|
|
449
694
|
if (actionSlug && entry) {
|
|
450
695
|
try {
|
|
451
|
-
await executeAction(actionSlug, entry.id, { user:
|
|
696
|
+
await executeAction(actionSlug, entry.id, { user: submittingUser });
|
|
452
697
|
} catch (err) {
|
|
453
698
|
fastify.log.warn(`[forms] Action "${actionSlug}" failed for form "${slug}": ${err.message}`);
|
|
454
699
|
}
|