domma-cms 0.22.6 → 0.23.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.
@@ -1,746 +1,765 @@
1
- /**
2
- * Core Forms API Routes
3
- * REST endpoints for form CRUD, public rendering, and submission handling.
4
- * Submissions are stored exclusively in Collections (no dual-storage).
5
- *
6
- * Endpoints (prefix: /api):
7
- * GET /forms — admin: list all forms
8
- * POST /forms — admin: create new form
9
- * GET /forms/:slug — admin: get form definition
10
- * GET /forms/:slug/public — public: get form (no actions block)
11
- * PUT /forms/:slug — admin: update form definition
12
- * DELETE /forms/:slug — admin: delete form
13
- * GET /forms/:slug/submissions — admin: list submissions from collection
14
- * GET /forms/:slug/submissions/export — admin: CSV export from collection
15
- * DELETE /forms/:slug/submissions — admin: clear all submissions
16
- * DELETE /forms/:slug/submissions/:id — admin: delete one submission
17
- * POST /forms/submit/:slug — public: accept submission
18
- * POST /forms/test-email — admin: send test email
19
- */
20
- import {createRequire} from 'module';
21
- import {fileURLToPath} from 'url';
22
- import path from 'path';
23
- import {deleteForm, ensureFormsDir, listForms, readForm, slugify, writeForm} from '../../services/forms.js';
24
- import {executeAction} from '../../services/actions.js';
25
- import {createTransport, sendFormEmail} from '../../services/email.js';
26
- import {
27
- clearEntries,
28
- createCollection,
29
- createEntry,
30
- deleteEntry,
31
- getCollection,
32
- listEntries
33
- } from '../../services/collections.js';
34
- import {getConfig} from '../../config.js';
35
- import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
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
- }
192
-
193
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
194
- const ROOT = path.resolve(__dirname, '..', '..', '..');
195
-
196
- // Load shared logic engine (UMD/CJS) from core public assets
197
- const _require = createRequire(import.meta.url);
198
- const FormLogicEngine = _require('../../../public/js/form-logic-engine.js');
199
-
200
- // Per-slug rate limit store: slug → Map<ip, timestamp[]>
201
- const rateLimitMap = new Map();
202
-
203
- function isRateLimited(slug, ip, limitPerMinute) {
204
- const now = Date.now();
205
- const windowMs = 60 * 1000;
206
- if (!rateLimitMap.has(slug)) rateLimitMap.set(slug, new Map());
207
- const slugMap = rateLimitMap.get(slug);
208
- const timestamps = (slugMap.get(ip) || []).filter(t => now - t < windowMs);
209
- if (timestamps.length >= limitPerMinute) return true;
210
- timestamps.push(now);
211
- slugMap.set(ip, timestamps);
212
- return false;
213
- }
214
-
215
- function submissionsToCSV(form, entries) {
216
- const fields = (form.fields || []).filter(f => f.type !== 'page-break' && f.type !== 'spacer');
217
- const headers = [...fields.map(f => `"${f.label || f.name}"`), '"Date"'];
218
- const rows = entries.map(e => {
219
- const cols = fields.map(f => {
220
- const raw = e.data?.[f.name] ?? '';
221
- const str = Array.isArray(raw) ? raw.join('; ') : String(raw);
222
- const val = str.replace(/"/g, '""');
223
- return `"${val}"`;
224
- });
225
- cols.push(`"${e.meta?.createdAt || ''}"`);
226
- return cols.join(',');
227
- });
228
- return [headers.join(','), ...rows].join('\n');
229
- }
230
-
231
- export async function formsRoutes(fastify) {
232
- await ensureFormsDir();
233
-
234
- const canRead = { preHandler: [authenticate, requirePermission('collections', 'read')] };
235
- const canCreate = { preHandler: [authenticate, requirePermission('collections', 'create')] };
236
- const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
237
- const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
238
-
239
- // -----------------------------------------------------------------------
240
- // GET /forms — list all form definitions with submission counts
241
- // -----------------------------------------------------------------------
242
- fastify.get('/forms', canRead, async (request) => {
243
- const {canSeeArtefact} = await import('../../services/projects.js');
244
- const forms = await listForms();
245
- const result = await Promise.all(forms.map(async form => {
246
- let submissionCount = 0;
247
- try {
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;
252
- } catch {
253
- // collection may not exist yet
254
- }
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 };
259
- }));
260
- return result
261
- .filter(({ full }) => canSeeArtefact(request.user, full))
262
- .map(({ form }) => form);
263
- });
264
-
265
- // -----------------------------------------------------------------------
266
- // POST /forms — create new form
267
- // -----------------------------------------------------------------------
268
- fastify.post('/forms', canCreate, async (request, reply) => {
269
- const { title, slug: rawSlug } = request.body || {};
270
- if (!title?.trim()) {
271
- return reply.status(400).send({ error: 'Title is required.' });
272
- }
273
- const slug = rawSlug ? slugify(rawSlug) : slugify(title);
274
- if (!slug) {
275
- return reply.status(400).send({ error: 'Could not generate a valid slug.' });
276
- }
277
-
278
- // Check for existing form with same slug
279
- try {
280
- await readForm(slug);
281
- return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
282
- } catch {
283
- // Does not exist — good
284
- }
285
-
286
- const now = new Date().toISOString();
287
- const body = request.body || {};
288
- const form = {
289
- slug,
290
- title: title.trim(),
291
- description: body.description || '',
292
- fields: Array.isArray(body.fields) ? body.fields : [],
293
- settings: {
294
- submitText: 'Submit',
295
- successMessage: 'Thank you for your submission.',
296
- layout: 'stacked',
297
- honeypot: true,
298
- rateLimitPerMinute: 3,
299
- ...(body.settings || {})
300
- },
301
- actions: {
302
- email: { enabled: false, recipients: '', subjectPrefix: `[${title.trim()}]` },
303
- webhook: { enabled: false, url: '', method: 'POST' },
304
- collection: {enabled: true, slug},
305
- ...(body.actions || {})
306
- },
307
- createdAt: now,
308
- updatedAt: now
309
- };
310
-
311
- await writeForm(slug, form);
312
-
313
- // Auto-create a matching collection for this form (skip if one already exists)
314
- try {
315
- await createCollection({
316
- slug,
317
- title: title.trim(),
318
- description: `Submissions from the ${title.trim()} form.`,
319
- fields: [],
320
- api: {
321
- create: { enabled: false, access: 'admin' },
322
- read: { enabled: true, access: 'admin' },
323
- update: { enabled: false, access: 'admin' },
324
- delete: { enabled: false, access: 'admin' }
325
- }
326
- });
327
- } catch (err) {
328
- fastify.log.warn(`[forms] Could not auto-create collection "${slug}": ${err.message}`);
329
- }
330
-
331
- const warnings = await detectFormCollectionMismatch(form);
332
- return reply.status(201).send(warnings.length ? { ...form, warnings } : form);
333
- });
334
-
335
- // -----------------------------------------------------------------------
336
- // GET /forms/:slug — get single form (admin, includes actions)
337
- // -----------------------------------------------------------------------
338
- fastify.get('/forms/:slug', canRead, async (request, reply) => {
339
- let form;
340
- try {
341
- form = await readForm(request.params.slug);
342
- } catch {
343
- return reply.status(404).send({ error: 'Form not found.' });
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;
350
- });
351
-
352
- // -----------------------------------------------------------------------
353
- // GET /forms/:slug/public — get form for public rendering (no actions)
354
- // -----------------------------------------------------------------------
355
- fastify.get('/forms/:slug/public', async (request, reply) => {
356
- try {
357
- const form = await readForm(request.params.slug);
358
- const { actions: _actions, ...safe } = form;
359
- return safe;
360
- } catch {
361
- return reply.status(404).send({ error: 'Form not found.' });
362
- }
363
- });
364
-
365
- // -----------------------------------------------------------------------
366
- // PUT /forms/:slug — update form definition
367
- // -----------------------------------------------------------------------
368
- fastify.put('/forms/:slug', canUpdate, async (request, reply) => {
369
- const { slug } = request.params;
370
- let existing;
371
- try {
372
- existing = await readForm(slug);
373
- } catch {
374
- return reply.status(404).send({ error: 'Form not found.' });
375
- }
376
-
377
- const body = request.body || {};
378
- const updated = {
379
- ...existing,
380
- ...body,
381
- slug,
382
- createdAt: existing.createdAt,
383
- updatedAt: new Date().toISOString()
384
- };
385
- await writeForm(slug, updated);
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;
393
- });
394
-
395
- // -----------------------------------------------------------------------
396
- // DELETE /forms/:slug — delete form
397
- // -----------------------------------------------------------------------
398
- fastify.delete('/forms/:slug', canDelete, async (request, reply) => {
399
- const { slug } = request.params;
400
- try {
401
- await deleteForm(slug);
402
- } catch {
403
- return reply.status(404).send({ error: 'Form not found.' });
404
- }
405
- return { ok: true };
406
- });
407
-
408
- // -----------------------------------------------------------------------
409
- // GET /forms/:slug/submissions — list submissions from collection
410
- // -----------------------------------------------------------------------
411
- fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
412
- const { slug } = request.params;
413
- try {
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();
419
- } catch {
420
- return reply.status(404).send({ error: 'Collection not found for this form.' });
421
- }
422
- });
423
-
424
- // -----------------------------------------------------------------------
425
- // GET /forms/:slug/submissions/export CSV download from collection
426
- // -----------------------------------------------------------------------
427
- fastify.get('/forms/:slug/submissions/export', canRead, async (request, reply) => {
428
- const { slug } = request.params;
429
- let form;
430
- try {
431
- form = await readForm(slug);
432
- } catch {
433
- return reply.status(404).send({ error: 'Form not found.' });
434
- }
435
- let entries = [];
436
- try {
437
- const r = await listEntries(slug, { limit: 0 });
438
- entries = r.entries || [];
439
- } catch {
440
- // empty collection
441
- }
442
- const csv = submissionsToCSV(form, entries);
443
- reply.header('Content-Type', 'text/csv');
444
- reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.csv"`);
445
- return reply.send(csv);
446
- });
447
-
448
- // -----------------------------------------------------------------------
449
- // GET /forms/:slug/submissions/export/json — JSON export from collection
450
- // -----------------------------------------------------------------------
451
- fastify.get('/forms/:slug/submissions/export/json', canRead, async (request, reply) => {
452
- const { slug } = request.params;
453
- let form;
454
- try {
455
- form = await readForm(slug);
456
- } catch {
457
- return reply.status(404).send({ error: 'Form not found.' });
458
- }
459
- let entries = [];
460
- try {
461
- const r = await listEntries(slug, { limit: 0 });
462
- entries = r.entries || [];
463
- } catch {
464
- // empty
465
- }
466
- reply.header('Content-Type', 'application/json');
467
- reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.json"`);
468
- return reply.send(JSON.stringify(entries, null, 2));
469
- });
470
-
471
- // -----------------------------------------------------------------------
472
- // DELETE /forms/:slug/submissions clear all submissions
473
- // -----------------------------------------------------------------------
474
- fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
475
- const { slug } = request.params;
476
- try {
477
- await clearEntries(slug);
478
- } catch {
479
- return reply.status(404).send({ error: 'Collection not found for this form.' });
480
- }
481
- return { ok: true };
482
- });
483
-
484
- // -----------------------------------------------------------------------
485
- // DELETE /forms/:slug/submissions/:id delete one submission
486
- // -----------------------------------------------------------------------
487
- fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
488
- const { slug, id } = request.params;
489
- try {
490
- await deleteEntry(slug, id);
491
- } catch {
492
- return reply.status(404).send({ error: 'Submission not found.' });
493
- }
494
- return { ok: true };
495
- });
496
-
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
- */
511
- fastify.post('/forms/submit/:slug', async (request, reply) => {
512
- const { slug } = request.params;
513
- let form;
514
- try {
515
- form = await readForm(slug);
516
- } catch {
517
- return reply.status(404).send({ error: 'Form not found.' });
518
- }
519
-
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
- }
533
- const settings = form.settings || {};
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
-
549
- // Honeypot check — silently accept if filled (bot detected)
550
- if (settings.honeypot && body._hp) {
551
- return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
552
- }
553
-
554
- // Timing check — silently accept if submitted too fast (< 2 s, likely a bot)
555
- if (settings.honeypot && body._t) {
556
- const elapsed = Date.now() - Number(body._t);
557
- if (!Number.isNaN(elapsed) && elapsed < 2000) {
558
- return {ok: true, message: settings.successMessage, redirect: settings.successRedirect || null};
559
- }
560
- }
561
-
562
- // Build form values for engine evaluation
563
- const formValues = {};
564
- for (const field of form.fields || []) {
565
- if (field.type === 'page-break' || field.type === 'spacer') continue;
566
- const val = body[field.name];
567
- formValues[field.name] = val !== undefined ? (typeof val === 'string' ? val.trim() : val) : '';
568
- }
569
-
570
- // Evaluate conditional logic
571
- const missingFields = [];
572
- const validationErrors = [];
573
- const visibleFieldNames = new Set();
574
-
575
- for (const field of form.fields || []) {
576
- if (field.type === 'page-break' || field.type === 'spacer') continue;
577
- const vis = FormLogicEngine.evaluateFieldVisibility(field, formValues);
578
- if (vis === 'hidden') continue;
579
- visibleFieldNames.add(field.name);
580
-
581
- const value = formValues[field.name];
582
- const isEmpty = !value?.toString().trim();
583
- const required = FormLogicEngine.evaluateFieldRequirement(field, formValues);
584
- if (required && isEmpty) {
585
- missingFields.push(field.label || field.name);
586
- }
587
- if (!isEmpty) {
588
- const errors = FormLogicEngine.validateField(field, value, formValues);
589
- if (errors.length) validationErrors.push(errors[0].message);
590
- }
591
- }
592
-
593
- if (missingFields.length || validationErrors.length) {
594
- const parts = [];
595
- if (missingFields.length) parts.push(`Required fields missing: ${missingFields.join(', ')}`);
596
- if (validationErrors.length) parts.push(validationErrors.join('; '));
597
- return reply.status(400).send({ error: `${parts.join('. ')}.` });
598
- }
599
-
600
- // Rate limit by IP
601
- const ip = request.ip || request.socket?.remoteAddress || 'unknown';
602
- const limit = settings.rateLimitPerMinute || 3;
603
- if (isRateLimited(slug, ip, limit)) {
604
- return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
605
- }
606
-
607
- // Build submission data only include visible fields
608
- const data = {};
609
- for (const field of form.fields || []) {
610
- if (field.type === 'page-break' || field.type === 'spacer') continue;
611
- if (!visibleFieldNames.has(field.name)) continue;
612
- const val = body[field.name];
613
- if (val !== undefined) {
614
- data[field.name] = typeof val === 'string' ? val.trim() : val;
615
- }
616
- }
617
-
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.)
627
- const collectionAction = form.actions?.collection;
628
- const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
629
- let entry = null;
630
- try {
631
- const col = await getCollection(targetSlug);
632
- if (col) {
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
- });
644
- }
645
- } catch (err) {
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 });
654
- }
655
-
656
- // Email action
657
- const emailAction = form.actions?.email;
658
- if (emailAction?.enabled && emailAction.recipients) {
659
- try {
660
- const smtp = getConfig('site').smtp || {};
661
- const transport = await createTransport(smtp);
662
- await sendFormEmail(transport, {
663
- from: smtp.fromAddress,
664
- fromName: smtp.fromName,
665
- to: emailAction.recipients,
666
- subject: `${emailAction.subjectPrefix || `[${form.title}]`} New submission`,
667
- formTitle: form.title,
668
- fields: form.fields,
669
- data
670
- });
671
- } catch (err) {
672
- fastify.log.warn(`[forms] Email send failed for "${slug}": ${err.message}`);
673
- }
674
- }
675
-
676
- // Webhook action
677
- const webhookAction = form.actions?.webhook;
678
- if (webhookAction?.enabled && webhookAction.url) {
679
- try {
680
- await fetch(webhookAction.url, {
681
- method: webhookAction.method || 'POST',
682
- headers: { 'Content-Type': 'application/json' },
683
- body: JSON.stringify({ form: slug, data })
684
- });
685
- } catch (err) {
686
- fastify.log.warn(`[forms] Webhook failed for "${slug}": ${err.message}`);
687
- }
688
- }
689
-
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={}.)
693
- const actionSlug = form.settings?.actionSlug;
694
- if (actionSlug && entry) {
695
- try {
696
- await executeAction(actionSlug, entry.id, { user: submittingUser });
697
- } catch (err) {
698
- fastify.log.warn(`[forms] Action "${actionSlug}" failed for form "${slug}": ${err.message}`);
699
- }
700
- }
701
-
702
- hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
703
-
704
- // Template interpolation for successRedirect
705
- let redirect = settings.successRedirect || null;
706
- if (redirect && entry?.id) {
707
- redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
708
- }
709
-
710
- return {
711
- ok: true,
712
- entryId: entry?.id || null,
713
- message: settings.successMessage || 'Thank you for your submission.',
714
- redirect: redirect
715
- };
716
- });
717
-
718
- // -----------------------------------------------------------------------
719
- // POST /forms/test-email — send a test email
720
- // -----------------------------------------------------------------------
721
- fastify.post('/forms/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
722
- const smtp = getConfig('site').smtp || {};
723
- const to = (request.body?.to) || smtp.fromAddress || 'test@ethereal.email';
724
- try {
725
- const transport = await createTransport(smtp);
726
- await sendFormEmail(transport, {
727
- from: smtp.fromAddress,
728
- fromName: smtp.fromName,
729
- to,
730
- subject: '[Forms] Test Email',
731
- formTitle: 'Test Form',
732
- fields: [
733
- { name: 'name', label: 'Name' },
734
- { name: 'message', label: 'Message' }
735
- ],
736
- data: {
737
- name: 'Test Sender',
738
- message: 'This is a test email from your Domma CMS. If you received this, your SMTP settings are working correctly.'
739
- }
740
- });
741
- return { ok: true, message: `Test email sent to ${to}` };
742
- } catch (err) {
743
- return reply.status(500).send({ error: `Failed to send test email: ${err.message}` });
744
- }
745
- });
746
- }
1
+ /**
2
+ * Core Forms API Routes
3
+ * REST endpoints for form CRUD, public rendering, and submission handling.
4
+ * Submissions are stored exclusively in Collections (no dual-storage).
5
+ *
6
+ * Endpoints (prefix: /api):
7
+ * GET /forms — admin: list all forms
8
+ * POST /forms — admin: create new form
9
+ * GET /forms/:slug — admin: get form definition
10
+ * GET /forms/:slug/public — public: get form (no actions block)
11
+ * PUT /forms/:slug — admin: update form definition
12
+ * DELETE /forms/:slug — admin: delete form
13
+ * GET /forms/:slug/submissions — admin: list submissions from collection
14
+ * GET /forms/:slug/submissions/export — admin: CSV export from collection
15
+ * DELETE /forms/:slug/submissions — admin: clear all submissions
16
+ * DELETE /forms/:slug/submissions/:id — admin: delete one submission
17
+ * POST /forms/submit/:slug — public: accept submission
18
+ * POST /forms/test-email — admin: send test email
19
+ */
20
+ import {createRequire} from 'module';
21
+ import {fileURLToPath} from 'url';
22
+ import path from 'path';
23
+ import {deleteForm, ensureCollectionForForm, ensureFormsDir, listForms, readForm, slugify, writeForm} from '../../services/forms.js';
24
+ import {executeAction} from '../../services/actions.js';
25
+ import {createTransport, sendFormEmail} from '../../services/email.js';
26
+ import {
27
+ clearEntries,
28
+ createCollection,
29
+ createEntry,
30
+ deleteEntry,
31
+ getCollection,
32
+ listEntries
33
+ } from '../../services/collections.js';
34
+ import {getConfig} from '../../config.js';
35
+ import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
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
+ }
192
+
193
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
194
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
195
+
196
+ // Load shared logic engine (UMD/CJS) from core public assets
197
+ const _require = createRequire(import.meta.url);
198
+ const FormLogicEngine = _require('../../../public/js/form-logic-engine.js');
199
+
200
+ // Per-slug rate limit store: slug → Map<ip, timestamp[]>
201
+ const rateLimitMap = new Map();
202
+
203
+ function isRateLimited(slug, ip, limitPerMinute) {
204
+ const now = Date.now();
205
+ const windowMs = 60 * 1000;
206
+ if (!rateLimitMap.has(slug)) rateLimitMap.set(slug, new Map());
207
+ const slugMap = rateLimitMap.get(slug);
208
+ const timestamps = (slugMap.get(ip) || []).filter(t => now - t < windowMs);
209
+ if (timestamps.length >= limitPerMinute) return true;
210
+ timestamps.push(now);
211
+ slugMap.set(ip, timestamps);
212
+ return false;
213
+ }
214
+
215
+ function submissionsToCSV(form, entries) {
216
+ const fields = (form.fields || []).filter(f => f.type !== 'page-break' && f.type !== 'spacer');
217
+ const headers = [...fields.map(f => `"${f.label || f.name}"`), '"Date"'];
218
+ const rows = entries.map(e => {
219
+ const cols = fields.map(f => {
220
+ const raw = e.data?.[f.name] ?? '';
221
+ const str = Array.isArray(raw) ? raw.join('; ') : String(raw);
222
+ const val = str.replace(/"/g, '""');
223
+ return `"${val}"`;
224
+ });
225
+ cols.push(`"${e.meta?.createdAt || ''}"`);
226
+ return cols.join(',');
227
+ });
228
+ return [headers.join(','), ...rows].join('\n');
229
+ }
230
+
231
+ export async function formsRoutes(fastify) {
232
+ await ensureFormsDir();
233
+
234
+ const canRead = { preHandler: [authenticate, requirePermission('collections', 'read')] };
235
+ const canCreate = { preHandler: [authenticate, requirePermission('collections', 'create')] };
236
+ const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
237
+ const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
238
+
239
+ // -----------------------------------------------------------------------
240
+ // GET /forms — list all form definitions with submission counts
241
+ // -----------------------------------------------------------------------
242
+ fastify.get('/forms', canRead, async (request) => {
243
+ const {canSeeArtefact} = await import('../../services/projects.js');
244
+ const forms = await listForms();
245
+ const result = await Promise.all(forms.map(async form => {
246
+ let submissionCount = 0;
247
+ try {
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;
252
+ } catch {
253
+ // collection may not exist yet
254
+ }
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 };
259
+ }));
260
+ return result
261
+ .filter(({ full }) => canSeeArtefact(request.user, full))
262
+ .map(({ form }) => form);
263
+ });
264
+
265
+ // -----------------------------------------------------------------------
266
+ // POST /forms — create new form
267
+ // -----------------------------------------------------------------------
268
+ fastify.post('/forms', canCreate, async (request, reply) => {
269
+ const { title, slug: rawSlug } = request.body || {};
270
+ if (!title?.trim()) {
271
+ return reply.status(400).send({ error: 'Title is required.' });
272
+ }
273
+ const slug = rawSlug ? slugify(rawSlug) : slugify(title);
274
+ if (!slug) {
275
+ return reply.status(400).send({ error: 'Could not generate a valid slug.' });
276
+ }
277
+
278
+ // Check for existing form with same slug
279
+ try {
280
+ await readForm(slug);
281
+ return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
282
+ } catch {
283
+ // Does not exist — good
284
+ }
285
+
286
+ const now = new Date().toISOString();
287
+ const body = request.body || {};
288
+ const form = {
289
+ slug,
290
+ title: title.trim(),
291
+ description: body.description || '',
292
+ fields: Array.isArray(body.fields) ? body.fields : [],
293
+ settings: {
294
+ submitText: 'Submit',
295
+ successMessage: 'Thank you for your submission.',
296
+ layout: 'stacked',
297
+ honeypot: true,
298
+ rateLimitPerMinute: 3,
299
+ ...(body.settings || {})
300
+ },
301
+ actions: {
302
+ email: { enabled: false, recipients: '', subjectPrefix: `[${title.trim()}]` },
303
+ webhook: { enabled: false, url: '', method: 'POST' },
304
+ collection: {enabled: true, slug},
305
+ ...(body.actions || {})
306
+ },
307
+ createdAt: now,
308
+ updatedAt: now
309
+ };
310
+
311
+ await writeForm(slug, form);
312
+
313
+ // Auto-create a matching collection for this form (skip if one already exists)
314
+ try {
315
+ await createCollection({
316
+ slug,
317
+ title: title.trim(),
318
+ description: `Submissions from the ${title.trim()} form.`,
319
+ fields: [],
320
+ api: {
321
+ create: { enabled: false, access: 'admin' },
322
+ read: { enabled: true, access: 'admin' },
323
+ update: { enabled: false, access: 'admin' },
324
+ delete: { enabled: false, access: 'admin' }
325
+ }
326
+ });
327
+ } catch (err) {
328
+ fastify.log.warn(`[forms] Could not auto-create collection "${slug}": ${err.message}`);
329
+ }
330
+
331
+ const warnings = await detectFormCollectionMismatch(form);
332
+ return reply.status(201).send(warnings.length ? { ...form, warnings } : form);
333
+ });
334
+
335
+ // -----------------------------------------------------------------------
336
+ // GET /forms/:slug — get single form (admin, includes actions)
337
+ // -----------------------------------------------------------------------
338
+ fastify.get('/forms/:slug', canRead, async (request, reply) => {
339
+ let form;
340
+ try {
341
+ form = await readForm(request.params.slug);
342
+ } catch {
343
+ return reply.status(404).send({ error: 'Form not found.' });
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;
350
+ });
351
+
352
+ // -----------------------------------------------------------------------
353
+ // GET /forms/:slug/public — get form for public rendering (no actions)
354
+ // -----------------------------------------------------------------------
355
+ fastify.get('/forms/:slug/public', async (request, reply) => {
356
+ try {
357
+ const form = await readForm(request.params.slug);
358
+ const { actions: _actions, ...safe } = form;
359
+ return safe;
360
+ } catch {
361
+ return reply.status(404).send({ error: 'Form not found.' });
362
+ }
363
+ });
364
+
365
+ // -----------------------------------------------------------------------
366
+ // PUT /forms/:slug — update form definition
367
+ // -----------------------------------------------------------------------
368
+ fastify.put('/forms/:slug', canUpdate, async (request, reply) => {
369
+ const { slug } = request.params;
370
+ let existing;
371
+ try {
372
+ existing = await readForm(slug);
373
+ } catch {
374
+ return reply.status(404).send({ error: 'Form not found.' });
375
+ }
376
+
377
+ const body = request.body || {};
378
+ const updated = {
379
+ ...existing,
380
+ ...body,
381
+ slug,
382
+ createdAt: existing.createdAt,
383
+ updatedAt: new Date().toISOString()
384
+ };
385
+ await writeForm(slug, updated);
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;
393
+ });
394
+
395
+ // -----------------------------------------------------------------------
396
+ // DELETE /forms/:slug — delete form
397
+ // -----------------------------------------------------------------------
398
+ fastify.delete('/forms/:slug', canDelete, async (request, reply) => {
399
+ const { slug } = request.params;
400
+ try {
401
+ await deleteForm(slug);
402
+ } catch {
403
+ return reply.status(404).send({ error: 'Form not found.' });
404
+ }
405
+ return { ok: true };
406
+ });
407
+
408
+ // -----------------------------------------------------------------------
409
+ // GET /forms/:slug/submissions — list submissions from collection
410
+ // -----------------------------------------------------------------------
411
+ fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
412
+ const { slug } = request.params;
413
+ let form;
414
+ try {
415
+ form = await readForm(slug);
416
+ } catch {
417
+ return reply.status(404).send({ error: 'Form not found.' });
418
+ }
419
+ const collectionAction = form.actions?.collection;
420
+ const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
421
+
422
+ // A form whose backing collection has not been provisioned yet simply
423
+ // has no submissions — return an empty list rather than a 404 so the
424
+ // admin view loads cleanly instead of erroring.
425
+ const collection = await getCollection(targetSlug);
426
+ if (!collection) return [];
427
+
428
+ // listEntries returns { entries, total, page, limit } unwrap to the
429
+ // array. Newest-first is the conventional display order.
430
+ const { entries } = await listEntries(targetSlug, { limit: 0 });
431
+ return [...entries].reverse();
432
+ });
433
+
434
+ // -----------------------------------------------------------------------
435
+ // GET /forms/:slug/submissions/export — CSV download from collection
436
+ // -----------------------------------------------------------------------
437
+ fastify.get('/forms/:slug/submissions/export', canRead, async (request, reply) => {
438
+ const { slug } = request.params;
439
+ let form;
440
+ try {
441
+ form = await readForm(slug);
442
+ } catch {
443
+ return reply.status(404).send({ error: 'Form not found.' });
444
+ }
445
+ let entries = [];
446
+ try {
447
+ const r = await listEntries(slug, { limit: 0 });
448
+ entries = r.entries || [];
449
+ } catch {
450
+ // empty collection
451
+ }
452
+ const csv = submissionsToCSV(form, entries);
453
+ reply.header('Content-Type', 'text/csv');
454
+ reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.csv"`);
455
+ return reply.send(csv);
456
+ });
457
+
458
+ // -----------------------------------------------------------------------
459
+ // GET /forms/:slug/submissions/export/json — JSON export from collection
460
+ // -----------------------------------------------------------------------
461
+ fastify.get('/forms/:slug/submissions/export/json', canRead, async (request, reply) => {
462
+ const { slug } = request.params;
463
+ let form;
464
+ try {
465
+ form = await readForm(slug);
466
+ } catch {
467
+ return reply.status(404).send({ error: 'Form not found.' });
468
+ }
469
+ let entries = [];
470
+ try {
471
+ const r = await listEntries(slug, { limit: 0 });
472
+ entries = r.entries || [];
473
+ } catch {
474
+ // empty
475
+ }
476
+ reply.header('Content-Type', 'application/json');
477
+ reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.json"`);
478
+ return reply.send(JSON.stringify(entries, null, 2));
479
+ });
480
+
481
+ // -----------------------------------------------------------------------
482
+ // DELETE /forms/:slug/submissions — clear all submissions
483
+ // -----------------------------------------------------------------------
484
+ fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
485
+ const { slug } = request.params;
486
+ try {
487
+ await clearEntries(slug);
488
+ } catch {
489
+ return reply.status(404).send({ error: 'Collection not found for this form.' });
490
+ }
491
+ return { ok: true };
492
+ });
493
+
494
+ // -----------------------------------------------------------------------
495
+ // DELETE /forms/:slug/submissions/:id — delete one submission
496
+ // -----------------------------------------------------------------------
497
+ fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
498
+ const { slug, id } = request.params;
499
+ try {
500
+ await deleteEntry(slug, id);
501
+ } catch {
502
+ return reply.status(404).send({ error: 'Submission not found.' });
503
+ }
504
+ return { ok: true };
505
+ });
506
+
507
+ /*
508
+ * POST /forms/submit/:slug public form submission.
509
+ *
510
+ * Authentication is OPTIONAL: when a valid JWT is present the submitter
511
+ * is captured as the entry's `createdBy` and passed into any triggered
512
+ * action's template context (as `{{user.id}}`, `{{user.email}}`, etc.).
513
+ * Anonymous submissions still work — `createdBy` is null and actions
514
+ * receive an empty user. This is what makes the apply/bookmark patterns
515
+ * work without forcing every form to be auth-only:
516
+ *
517
+ * - Anonymous contact form submitted by guest, user=null
518
+ * - "Apply for job" form → submitted by candidate, action's
519
+ * createInCollection step stamps the application with their id
520
+ */
521
+ fastify.post('/forms/submit/:slug', async (request, reply) => {
522
+ const { slug } = request.params;
523
+ let form;
524
+ try {
525
+ form = await readForm(slug);
526
+ } catch {
527
+ return reply.status(404).send({ error: 'Form not found.' });
528
+ }
529
+
530
+ // Multipart parsing when the request is multipart (file uploads),
531
+ // we walk parts ourselves: text fields go into `body`, file parts are
532
+ // validated against the form's field config and saved to /content/media,
533
+ // then their { url, name, size, mime } reference object lands in `body`
534
+ // under the field name. Downstream code sees a uniform JSON-shaped body.
535
+ let body = request.body || {};
536
+ if (request.isMultipart && request.isMultipart()) {
537
+ try {
538
+ body = await parseMultipartForm(request, form);
539
+ } catch (err) {
540
+ return reply.status(400).send({ error: err.message });
541
+ }
542
+ }
543
+ const settings = form.settings || {};
544
+
545
+ // Best-effort auth — never reject, just enrich.
546
+ let submittingUser = null;
547
+ try {
548
+ const decoded = await request.jwtVerify();
549
+ if (decoded.type === 'access') {
550
+ submittingUser = {
551
+ id: decoded.id,
552
+ name: decoded.name,
553
+ email: decoded.email,
554
+ role: decoded.role
555
+ };
556
+ }
557
+ } catch { /* anonymous submission */ }
558
+
559
+ // Honeypot check — silently accept if filled (bot detected)
560
+ if (settings.honeypot && body._hp) {
561
+ return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
562
+ }
563
+
564
+ // Timing check — silently accept if submitted too fast (< 2 s, likely a bot)
565
+ if (settings.honeypot && body._t) {
566
+ const elapsed = Date.now() - Number(body._t);
567
+ if (!Number.isNaN(elapsed) && elapsed < 2000) {
568
+ return {ok: true, message: settings.successMessage, redirect: settings.successRedirect || null};
569
+ }
570
+ }
571
+
572
+ // Build form values for engine evaluation
573
+ const formValues = {};
574
+ for (const field of form.fields || []) {
575
+ if (field.type === 'page-break' || field.type === 'spacer') continue;
576
+ const val = body[field.name];
577
+ formValues[field.name] = val !== undefined ? (typeof val === 'string' ? val.trim() : val) : '';
578
+ }
579
+
580
+ // Evaluate conditional logic
581
+ const missingFields = [];
582
+ const validationErrors = [];
583
+ const visibleFieldNames = new Set();
584
+
585
+ for (const field of form.fields || []) {
586
+ if (field.type === 'page-break' || field.type === 'spacer') continue;
587
+ const vis = FormLogicEngine.evaluateFieldVisibility(field, formValues);
588
+ if (vis === 'hidden') continue;
589
+ visibleFieldNames.add(field.name);
590
+
591
+ const value = formValues[field.name];
592
+ const isEmpty = !value?.toString().trim();
593
+ const required = FormLogicEngine.evaluateFieldRequirement(field, formValues);
594
+ if (required && isEmpty) {
595
+ missingFields.push(field.label || field.name);
596
+ }
597
+ if (!isEmpty) {
598
+ const errors = FormLogicEngine.validateField(field, value, formValues);
599
+ if (errors.length) validationErrors.push(errors[0].message);
600
+ }
601
+ }
602
+
603
+ if (missingFields.length || validationErrors.length) {
604
+ const parts = [];
605
+ if (missingFields.length) parts.push(`Required fields missing: ${missingFields.join(', ')}`);
606
+ if (validationErrors.length) parts.push(validationErrors.join('; '));
607
+ return reply.status(400).send({ error: `${parts.join('. ')}.` });
608
+ }
609
+
610
+ // Rate limit by IP
611
+ const ip = request.ip || request.socket?.remoteAddress || 'unknown';
612
+ const limit = settings.rateLimitPerMinute || 3;
613
+ if (isRateLimited(slug, ip, limit)) {
614
+ return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
615
+ }
616
+
617
+ // Build submission data — only include visible fields
618
+ const data = {};
619
+ for (const field of form.fields || []) {
620
+ if (field.type === 'page-break' || field.type === 'spacer') continue;
621
+ if (!visibleFieldNames.has(field.name)) continue;
622
+ const val = body[field.name];
623
+ if (val !== undefined) {
624
+ data[field.name] = typeof val === 'string' ? val.trim() : val;
625
+ }
626
+ }
627
+
628
+ // Store in collection (sole submission store).
629
+ //
630
+ // CRITICAL: collection write failure is the difference between "your
631
+ // submission was saved" and "your submission disappeared into thin air".
632
+ // We MUST surface the error to the user rather than logging a warning
633
+ // and returning success — anything else is a silent-data-loss bug.
634
+ // (Email / webhook / action failures downstream are still treated as
635
+ // non-fatal they fire AFTER the entry is persisted, so even a partial
636
+ // delivery has the original submission safely stored.)
637
+ const collectionAction = form.actions?.collection;
638
+ const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
639
+ let entry = null;
640
+ try {
641
+ let col = await getCollection(targetSlug);
642
+ if (!col) {
643
+ // The backing collection was never created (a form pointing at a
644
+ // non-existent collection). Failing here would lose the visitor's
645
+ // submission outright, so self-heal by provisioning the collection
646
+ // from the form's own fields, then persist into it.
647
+ fastify.log.warn(`[forms] Auto-provisioning missing collection "${targetSlug}" for form "${slug}"`);
648
+ col = await ensureCollectionForForm(form);
649
+ }
650
+ if (col) {
651
+ entry = await createEntry(targetSlug, data, {
652
+ source: `form:${slug}`,
653
+ createdBy: submittingUser?.id || null
654
+ });
655
+ } else {
656
+ // Could not resolve or provision a target — e.g. collection
657
+ // storage is explicitly disabled with no slug. Fail loudly
658
+ // rather than silently lose submissions.
659
+ fastify.log.warn(`[forms] Submission for "${slug}" had no target collection "${targetSlug}"`);
660
+ return reply.status(500).send({
661
+ error: `Submission not saved — target collection "${targetSlug}" does not exist. Please contact the site administrator.`
662
+ });
663
+ }
664
+ } catch (err) {
665
+ fastify.log.warn(`[forms] Collection write failed for "${slug}" → "${targetSlug}": ${err.message}`);
666
+ // Distinguish validation errors (user-actionable) from other
667
+ // failures (admin needs to look) so the message helps the right
668
+ // person. Validation errors typically start with "Validation failed:".
669
+ const msg = err.message.startsWith('Validation failed')
670
+ ? err.message.replace('Validation failed: ', '')
671
+ : `Submission could not be saved: ${err.message}`;
672
+ return reply.status(400).send({ error: msg });
673
+ }
674
+
675
+ // Email action
676
+ const emailAction = form.actions?.email;
677
+ if (emailAction?.enabled && emailAction.recipients) {
678
+ try {
679
+ const smtp = getConfig('site').smtp || {};
680
+ const transport = await createTransport(smtp);
681
+ await sendFormEmail(transport, {
682
+ from: smtp.fromAddress,
683
+ fromName: smtp.fromName,
684
+ to: emailAction.recipients,
685
+ subject: `${emailAction.subjectPrefix || `[${form.title}]`} New submission`,
686
+ formTitle: form.title,
687
+ fields: form.fields,
688
+ data
689
+ });
690
+ } catch (err) {
691
+ fastify.log.warn(`[forms] Email send failed for "${slug}": ${err.message}`);
692
+ }
693
+ }
694
+
695
+ // Webhook action
696
+ const webhookAction = form.actions?.webhook;
697
+ if (webhookAction?.enabled && webhookAction.url) {
698
+ try {
699
+ await fetch(webhookAction.url, {
700
+ method: webhookAction.method || 'POST',
701
+ headers: { 'Content-Type': 'application/json' },
702
+ body: JSON.stringify({ form: slug, data })
703
+ });
704
+ } catch (err) {
705
+ fastify.log.warn(`[forms] Webhook failed for "${slug}": ${err.message}`);
706
+ }
707
+ }
708
+
709
+ // CMS Action trigger — forwards the authenticated submitter so
710
+ // action steps can interpolate {{user.id}}, {{user.email}}, etc.
711
+ // (Anonymous submissions still trigger the action with user={}.)
712
+ const actionSlug = form.settings?.actionSlug;
713
+ if (actionSlug && entry) {
714
+ try {
715
+ await executeAction(actionSlug, entry.id, { user: submittingUser });
716
+ } catch (err) {
717
+ fastify.log.warn(`[forms] Action "${actionSlug}" failed for form "${slug}": ${err.message}`);
718
+ }
719
+ }
720
+
721
+ hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
722
+
723
+ // Template interpolation for successRedirect
724
+ let redirect = settings.successRedirect || null;
725
+ if (redirect && entry?.id) {
726
+ redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
727
+ }
728
+
729
+ return {
730
+ ok: true,
731
+ entryId: entry?.id || null,
732
+ message: settings.successMessage || 'Thank you for your submission.',
733
+ redirect: redirect
734
+ };
735
+ });
736
+
737
+ // -----------------------------------------------------------------------
738
+ // POST /forms/test-email send a test email
739
+ // -----------------------------------------------------------------------
740
+ fastify.post('/forms/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
741
+ const smtp = getConfig('site').smtp || {};
742
+ const to = (request.body?.to) || smtp.fromAddress || 'test@ethereal.email';
743
+ try {
744
+ const transport = await createTransport(smtp);
745
+ await sendFormEmail(transport, {
746
+ from: smtp.fromAddress,
747
+ fromName: smtp.fromName,
748
+ to,
749
+ subject: '[Forms] Test Email',
750
+ formTitle: 'Test Form',
751
+ fields: [
752
+ { name: 'name', label: 'Name' },
753
+ { name: 'message', label: 'Message' }
754
+ ],
755
+ data: {
756
+ name: 'Test Sender',
757
+ message: 'This is a test email from your Domma CMS. If you received this, your SMTP settings are working correctly.'
758
+ }
759
+ });
760
+ return { ok: true, message: `Test email sent to ${to}` };
761
+ } catch (err) {
762
+ return reply.status(500).send({ error: `Failed to send test email: ${err.message}` });
763
+ }
764
+ });
765
+ }