domma-cms 0.3.0 → 0.5.2

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.
Files changed (150) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +8 -4
  8. package/admin/js/config/sidebar-config.js +1 -1
  9. package/admin/js/lib/markdown-toolbar.js +18 -10
  10. package/admin/js/templates/action-editor.html +171 -0
  11. package/admin/js/templates/actions-list.html +19 -0
  12. package/admin/js/templates/api-reference.html +1411 -0
  13. package/admin/js/templates/block-editor.html +158 -0
  14. package/admin/js/templates/blocks.html +8 -0
  15. package/admin/js/templates/collection-editor.html +47 -0
  16. package/admin/js/templates/collection-entries.html +3 -0
  17. package/admin/js/templates/collections.html +51 -4
  18. package/admin/js/templates/documentation.html +258 -0
  19. package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
  20. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  21. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  22. package/admin/js/templates/login.html +29 -4
  23. package/admin/js/templates/my-profile.html +17 -0
  24. package/admin/js/templates/page-editor.html +39 -0
  25. package/admin/js/templates/pages.html +6 -1
  26. package/admin/js/templates/pro-docs.html +259 -0
  27. package/admin/js/templates/role-editor.html +59 -0
  28. package/admin/js/templates/roles.html +10 -0
  29. package/admin/js/templates/settings.html +167 -23
  30. package/admin/js/templates/tutorials.html +81 -0
  31. package/admin/js/templates/user-editor.html +7 -0
  32. package/admin/js/templates/users.html +3 -26
  33. package/admin/js/templates/view-editor.html +201 -0
  34. package/admin/js/templates/view-preview.html +51 -0
  35. package/admin/js/templates/views-list.html +19 -0
  36. package/admin/js/views/action-editor.js +1 -0
  37. package/admin/js/views/actions-list.js +1 -0
  38. package/admin/js/views/api-reference.js +1 -0
  39. package/admin/js/views/block-editor.js +8 -0
  40. package/admin/js/views/blocks.js +4 -0
  41. package/admin/js/views/collection-editor.js +3 -3
  42. package/admin/js/views/collection-entries.js +1 -1
  43. package/admin/js/views/collections.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +8 -0
  46. package/admin/js/views/form-submissions.js +1 -0
  47. package/admin/js/views/forms.js +1 -0
  48. package/admin/js/views/index.js +1 -1
  49. package/admin/js/views/login.js +2 -2
  50. package/admin/js/views/media.js +1 -1
  51. package/admin/js/views/my-profile.js +1 -0
  52. package/admin/js/views/page-editor.js +34 -15
  53. package/admin/js/views/pages.js +5 -5
  54. package/admin/js/views/plugins.js +10 -10
  55. package/admin/js/views/pro-docs.js +1 -0
  56. package/admin/js/views/role-editor.js +1 -0
  57. package/admin/js/views/roles.js +4 -0
  58. package/admin/js/views/settings.js +3 -1
  59. package/admin/js/views/user-editor.js +1 -1
  60. package/admin/js/views/users.js +4 -7
  61. package/admin/js/views/view-editor.js +1 -0
  62. package/admin/js/views/view-preview.js +1 -0
  63. package/admin/js/views/views-list.js +1 -0
  64. package/bin/cli.js +1 -1
  65. package/config/auth.json +1 -0
  66. package/config/connections.json.bak +9 -0
  67. package/config/connections.json.example +9 -0
  68. package/config/navigation.json +5 -15
  69. package/config/plugins.json +19 -29
  70. package/config/server.json +6 -6
  71. package/config/site.json +16 -6
  72. package/package.json +25 -10
  73. package/plugins/example-analytics/stats.json +17 -12
  74. package/plugins/form-builder/data/forms/contacts.json +62 -62
  75. package/plugins/form-builder/data/forms/enquiries.json +103 -0
  76. package/plugins/form-builder/data/forms/feedback.json +17 -16
  77. package/plugins/form-builder/data/forms/notes.json +79 -0
  78. package/plugins/form-builder/data/forms/to-do.json +100 -0
  79. package/plugins/form-builder/data/submissions/contacts.json +1 -26
  80. package/plugins/form-builder/data/submissions/notes.json +1 -0
  81. package/plugins/form-builder/data/submissions/to-do.json +1 -0
  82. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  83. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  84. package/plugins/theme-roller/config.js +1 -0
  85. package/plugins/theme-roller/plugin.js +233 -0
  86. package/plugins/theme-roller/plugin.json +31 -0
  87. package/plugins/theme-roller/public/active-theme.css +0 -0
  88. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  89. package/public/css/forms.css +1 -0
  90. package/public/css/site.css +1 -1
  91. package/public/js/forms.js +1 -0
  92. package/public/js/site.js +1 -1
  93. package/scripts/build.js +194 -129
  94. package/scripts/pro.js +254 -0
  95. package/scripts/reset.js +33 -8
  96. package/scripts/seed.js +677 -128
  97. package/scripts/setup.js +1 -0
  98. package/server/middleware/auth.js +136 -120
  99. package/server/routes/api/actions.js +200 -0
  100. package/server/routes/api/auth.js +292 -146
  101. package/server/routes/api/blocks.js +84 -0
  102. package/server/routes/api/collections.js +79 -27
  103. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
  104. package/server/routes/api/layouts.js +49 -39
  105. package/server/routes/api/media.js +118 -92
  106. package/server/routes/api/navigation.js +40 -36
  107. package/server/routes/api/pages.js +132 -118
  108. package/server/routes/api/plugins.js +6 -3
  109. package/server/routes/api/settings.js +104 -88
  110. package/server/routes/api/users.js +27 -19
  111. package/server/routes/api/views.js +148 -0
  112. package/server/routes/public.js +124 -108
  113. package/server/server.js +269 -181
  114. package/server/services/actions.js +387 -0
  115. package/server/services/adapterRegistry.js +98 -0
  116. package/server/services/adapters/FileAdapter.js +192 -0
  117. package/server/services/adapters/MongoAdapter.js +220 -0
  118. package/server/services/blocks.js +162 -0
  119. package/server/services/collections.js +74 -86
  120. package/server/services/connectionManager.js +102 -0
  121. package/server/services/content.js +312 -307
  122. package/server/services/email.js +126 -0
  123. package/server/services/forms.js +173 -0
  124. package/server/services/markdown.js +1378 -747
  125. package/server/services/permissionRegistry.js +173 -0
  126. package/server/services/presetCollections.js +251 -0
  127. package/server/services/renderer.js +98 -2
  128. package/server/services/roles.js +227 -0
  129. package/server/services/rowAccess.js +104 -0
  130. package/server/services/userProfiles.js +199 -0
  131. package/server/services/users.js +281 -212
  132. package/server/services/views.js +280 -0
  133. package/server/templates/page.html +124 -113
  134. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  135. package/plugins/form-builder/admin/views/form-editor.js +0 -1444
  136. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  137. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  138. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  139. package/plugins/form-builder/config.js +0 -9
  140. package/plugins/form-builder/data/forms/consent.json +0 -104
  141. package/plugins/form-builder/data/forms/contact-details.json +0 -99
  142. package/plugins/form-builder/data/submissions/consent.json +0 -13
  143. package/plugins/form-builder/plugin.json +0 -52
  144. package/plugins/form-builder/public/inject-body.html +0 -352
  145. package/plugins/form-builder/public/inject-head.html +0 -58
  146. package/plugins/form-builder/public/package.json +0 -1
  147. package/scripts/copy-domma.js +0 -48
  148. package/server/services/userTypes.js +0 -167
  149. /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
  150. /package/{plugins/form-builder/public → public/js}/form-logic-engine.js +0 -0
@@ -1,505 +1,491 @@
1
- /**
2
- * Form Builder Plugin — Server
3
- * Handles form CRUD, public submission, submissions management, and SMTP settings.
4
- *
5
- * Endpoints (prefix: /api/plugins/form-builder):
6
- * GET /forms — admin: list all forms
7
- * POST /forms — admin: create new form
8
- * GET /forms/:slug — admin: get form definition
9
- * GET /forms/:slug/public public: get form (no actions block)
10
- * PUT /forms/:slug admin: update form definition
11
- * DELETE /forms/:slug — admin: delete form + submissions
12
- * GET /forms/:slug/submissions — admin: list submissions (newest first)
13
- * GET /forms/:slug/submissions/export — admin: CSV export
14
- * DELETE /forms/:slug/submissions — admin: clear all submissions
15
- * DELETE /forms/:slug/submissions/:id — admin: delete one submission
16
- * POST /submit/:slug public: accept submission
17
- * GET /settings admin: read global settings
18
- * PUT /settings — admin: save global settings
19
- * POST /test-email — admin: send test email
20
- */
21
- import fs from 'fs/promises';
22
- import path from 'path';
23
- import crypto from 'crypto';
24
- import {fileURLToPath} from 'url';
25
- import {createRequire} from 'module';
26
- import {getPluginSettings, savePluginState} from '../../server/services/plugins.js';
27
- import {getConfig} from '../../server/config.js';
28
- import {createTransport, sendFormEmail} from './email.js';
29
- import {createEntry, getCollection} from '../../server/services/collections.js';
30
-
31
- // Load shared logic engine (UMD/CJS) from browser-compatible public file
32
- const _require = createRequire(import.meta.url);
33
- const FormLogicEngine = _require('./public/form-logic-engine.js');
34
-
35
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
- const FORMS_DIR = path.join(__dirname, 'data', 'forms');
37
- const SUBMISSIONS_DIR = path.join(__dirname, 'data', 'submissions');
38
-
39
- // Per-slug rate limit store: slug → Map<ip, timestamp[]>
40
- const rateLimitMap = new Map();
41
-
42
- // ---------------------------------------------------------------------------
43
- // File helpers
44
- // ---------------------------------------------------------------------------
45
-
46
- async function readForm(slug) {
47
- const file = path.join(FORMS_DIR, `${slug}.json`);
48
- return JSON.parse(await fs.readFile(file, 'utf8'));
49
- }
50
-
51
- async function writeForm(slug, data) {
52
- const file = path.join(FORMS_DIR, `${slug}.json`);
53
- await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
54
- }
55
-
56
- async function listForms() {
57
- let entries;
58
- try {
59
- entries = await fs.readdir(FORMS_DIR);
60
- } catch {
61
- return [];
62
- }
63
- const forms = [];
64
- for (const entry of entries.filter(e => e.endsWith('.json'))) {
65
- try {
66
- const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
67
- forms.push(data);
68
- } catch {
69
- // skip malformed
70
- }
71
- }
72
- return forms;
73
- }
74
-
75
- async function readSubmissions(slug) {
76
- try {
77
- return JSON.parse(await fs.readFile(path.join(SUBMISSIONS_DIR, `${slug}.json`), 'utf8'));
78
- } catch {
79
- return [];
80
- }
81
- }
82
-
83
- async function writeSubmissions(slug, submissions) {
84
- await fs.mkdir(SUBMISSIONS_DIR, { recursive: true });
85
- await fs.writeFile(
86
- path.join(SUBMISSIONS_DIR, `${slug}.json`),
87
- JSON.stringify(submissions, null, 2) + '\n',
88
- 'utf8'
89
- );
90
- }
91
-
92
- function isRateLimited(slug, ip, limitPerMinute) {
93
- const now = Date.now();
94
- const windowMs = 60 * 1000;
95
- if (!rateLimitMap.has(slug)) rateLimitMap.set(slug, new Map());
96
- const slugMap = rateLimitMap.get(slug);
97
- const timestamps = (slugMap.get(ip) || []).filter(t => now - t < windowMs);
98
- if (timestamps.length >= limitPerMinute) return true;
99
- timestamps.push(now);
100
- slugMap.set(ip, timestamps);
101
- return false;
102
- }
103
-
104
- function slugify(str) {
105
- return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
106
- }
107
-
108
- function submissionsToCSV(form, submissions) {
109
- const fields = form.fields || [];
110
- const headers = [...fields.map(f => `"${f.label || f.name}"`), '"Date"'];
111
- const rows = submissions.map(s => {
112
- const cols = fields.map(f => {
113
- const val = String(s.data?.[f.name] ?? '').replace(/"/g, '""');
114
- return `"${val}"`;
115
- });
116
- cols.push(`"${s.meta?.createdAt || ''}"`);
117
- return cols.join(',');
118
- });
119
- return [headers.join(','), ...rows].join('\n');
120
- }
121
-
122
- // ---------------------------------------------------------------------------
123
- // Plugin registration
124
- // ---------------------------------------------------------------------------
125
-
126
- export default async function formBuilderPlugin(fastify, options) {
127
- const { authenticate, requireAdmin } = options.auth;
128
-
129
- // Ensure data dirs exist
130
- await fs.mkdir(FORMS_DIR, { recursive: true });
131
- await fs.mkdir(SUBMISSIONS_DIR, { recursive: true });
132
-
133
- // -----------------------------------------------------------------------
134
- // GET /forms — list all form definitions
135
- // -----------------------------------------------------------------------
136
- fastify.get('/forms', { preHandler: [authenticate, requireAdmin] }, async () => {
137
- const forms = await listForms();
138
- // Attach submission count to each form
139
- const result = await Promise.all(forms.map(async form => {
140
- const subs = await readSubmissions(form.slug);
141
- return { ...form, submissionCount: subs.length };
142
- }));
143
- return result;
144
- });
145
-
146
- // -----------------------------------------------------------------------
147
- // POST /forms — create new form
148
- // -----------------------------------------------------------------------
149
- fastify.post('/forms', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
150
- const { title, slug: rawSlug } = request.body || {};
151
- if (!title?.trim()) {
152
- return reply.status(400).send({ error: 'Title is required.' });
153
- }
154
- const slug = rawSlug ? slugify(rawSlug) : slugify(title);
155
- if (!slug) {
156
- return reply.status(400).send({ error: 'Could not generate a valid slug.' });
157
- }
158
-
159
- // Check for existing form with same slug
160
- try {
161
- await fs.access(path.join(FORMS_DIR, `${slug}.json`));
162
- return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
163
- } catch {
164
- // Does not exist good
165
- }
166
-
167
- const now = new Date().toISOString();
168
- const form = {
169
- slug,
170
- title: title.trim(),
171
- description: '',
172
- fields: [],
173
- settings: {
174
- submitText: 'Submit',
175
- successMessage: 'Thank you for your submission.',
176
- layout: 'stacked',
177
- honeypot: true,
178
- rateLimitPerMinute: 3
179
- },
180
- actions: {
181
- email: { enabled: false, recipients: '', subjectPrefix: `[${title.trim()}]` },
182
- webhook: { enabled: false, url: '', method: 'POST' }
183
- },
184
- createdAt: now,
185
- updatedAt: now
186
- };
187
-
188
- await writeForm(slug, form);
189
- await writeSubmissions(slug, []);
190
- return reply.status(201).send(form);
191
- });
192
-
193
- // -----------------------------------------------------------------------
194
- // GET /forms/:slug — get single form (admin, includes actions)
195
- // -----------------------------------------------------------------------
196
- fastify.get('/forms/:slug', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
197
- try {
198
- return await readForm(request.params.slug);
199
- } catch {
200
- return reply.status(404).send({ error: 'Form not found.' });
201
- }
202
- });
203
-
204
- // -----------------------------------------------------------------------
205
- // GET /forms/:slug/public — get form for public rendering (no actions)
206
- // -----------------------------------------------------------------------
207
- fastify.get('/forms/:slug/public', async (request, reply) => {
208
- try {
209
- const form = await readForm(request.params.slug);
210
- // Strip sensitive actions block before returning to browser
211
- const { actions: _actions, ...safe } = form;
212
- return safe;
213
- } catch {
214
- return reply.status(404).send({ error: 'Form not found.' });
215
- }
216
- });
217
-
218
- // -----------------------------------------------------------------------
219
- // PUT /forms/:slug — update form definition
220
- // -----------------------------------------------------------------------
221
- fastify.put('/forms/:slug', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
222
- const { slug } = request.params;
223
- let existing;
224
- try {
225
- existing = await readForm(slug);
226
- } catch {
227
- return reply.status(404).send({ error: 'Form not found.' });
228
- }
229
-
230
- const body = request.body || {};
231
- const updated = {
232
- ...existing,
233
- ...body,
234
- slug, // slug is immutable via this route
235
- createdAt: existing.createdAt,
236
- updatedAt: new Date().toISOString()
237
- };
238
- await writeForm(slug, updated);
239
- return updated;
240
- });
241
-
242
- // -----------------------------------------------------------------------
243
- // DELETE /forms/:slug — delete form and its submissions
244
- // -----------------------------------------------------------------------
245
- fastify.delete('/forms/:slug', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
246
- const { slug } = request.params;
247
- try {
248
- await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
249
- } catch {
250
- return reply.status(404).send({ error: 'Form not found.' });
251
- }
252
- try {
253
- await fs.unlink(path.join(SUBMISSIONS_DIR, `${slug}.json`));
254
- } catch {
255
- // Submissions file may not exist — not an error
256
- }
257
- return { ok: true };
258
- });
259
-
260
- // -----------------------------------------------------------------------
261
- // GET /forms/:slug/submissions — list submissions (newest first)
262
- // -----------------------------------------------------------------------
263
- fastify.get('/forms/:slug/submissions', { preHandler: [authenticate, requireAdmin] }, async (request) => {
264
- const submissions = await readSubmissions(request.params.slug);
265
- return submissions.slice().reverse();
266
- });
267
-
268
- // -----------------------------------------------------------------------
269
- // GET /forms/:slug/submissions/export — CSV download
270
- // -----------------------------------------------------------------------
271
- fastify.get('/forms/:slug/submissions/export', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
272
- const { slug } = request.params;
273
- let form;
274
- try {
275
- form = await readForm(slug);
276
- } catch {
277
- return reply.status(404).send({ error: 'Form not found.' });
278
- }
279
- const submissions = await readSubmissions(slug);
280
- const csv = submissionsToCSV(form, submissions);
281
- reply
282
- .header('Content-Type', 'text/csv; charset=utf-8')
283
- .header('Content-Disposition', `attachment; filename="${slug}-submissions.csv"`);
284
- return csv;
285
- });
286
-
287
- // -----------------------------------------------------------------------
288
- // GET /forms/:slug/submissions/export/json — JSON download
289
- // -----------------------------------------------------------------------
290
- fastify.get('/forms/:slug/submissions/export/json', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
291
- const { slug } = request.params;
292
- try {
293
- await readForm(slug);
294
- } catch {
295
- return reply.status(404).send({ error: 'Form not found.' });
296
- }
297
- const submissions = await readSubmissions(slug);
298
- reply
299
- .header('Content-Type', 'application/json; charset=utf-8')
300
- .header('Content-Disposition', `attachment; filename="${slug}-submissions.json"`);
301
- return JSON.stringify(submissions, null, 2);
302
- });
303
-
304
- // -----------------------------------------------------------------------
305
- // DELETE /forms/:slug/submissions clear all submissions
306
- // -----------------------------------------------------------------------
307
- fastify.delete('/forms/:slug/submissions', { preHandler: [authenticate, requireAdmin] }, async (request) => {
308
- await writeSubmissions(request.params.slug, []);
309
- return { ok: true };
310
- });
311
-
312
- // -----------------------------------------------------------------------
313
- // DELETE /forms/:slug/submissions/:id — delete one submission
314
- // -----------------------------------------------------------------------
315
- fastify.delete('/forms/:slug/submissions/:id', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
316
- const { slug, id } = request.params;
317
- const submissions = await readSubmissions(slug);
318
- const filtered = submissions.filter(s => s.id !== id);
319
- if (filtered.length === submissions.length) {
320
- return reply.status(404).send({ error: 'Submission not found.' });
321
- }
322
- await writeSubmissions(slug, filtered);
323
- return { ok: true };
324
- });
325
-
326
- // -----------------------------------------------------------------------
327
- // POST /submit/:slug public form submission
328
- // -----------------------------------------------------------------------
329
- fastify.post('/submit/:slug', async (request, reply) => {
330
- const { slug } = request.params;
331
- let form;
332
- try {
333
- form = await readForm(slug);
334
- } catch {
335
- return reply.status(404).send({ error: 'Form not found.' });
336
- }
337
-
338
- const body = request.body || {};
339
- const settings = form.settings || {};
340
-
341
- // Honeypot check — silently accept if filled (bot detected)
342
- if (settings.honeypot && body._hp) {
343
- return { ok: true, message: settings.successMessage };
344
- }
345
-
346
- // Build form values for engine evaluation (trim strings, default to '')
347
- const formValues = {};
348
- for (const field of form.fields || []) {
349
- if (field.type === 'page-break' || field.type === 'spacer') continue;
350
- const val = body[field.name];
351
- formValues[field.name] = val !== undefined ? (typeof val === 'string' ? val.trim() : val) : '';
352
- }
353
-
354
- // Evaluate conditional logic — visibility, requirement, validation
355
- const missingFields = [];
356
- const validationErrors = [];
357
- const visibleFieldNames = new Set();
358
-
359
- for (const field of form.fields || []) {
360
- if (field.type === 'page-break' || field.type === 'spacer') continue;
361
- const vis = FormLogicEngine.evaluateFieldVisibility(field, formValues);
362
- if (vis === 'hidden') continue;
363
- visibleFieldNames.add(field.name);
364
-
365
- const value = formValues[field.name];
366
- const isEmpty = !value?.toString().trim();
367
- const required = FormLogicEngine.evaluateFieldRequirement(field, formValues);
368
- if (required && isEmpty) {
369
- missingFields.push(field.label || field.name);
370
- }
371
- if (!isEmpty) {
372
- const errors = FormLogicEngine.validateField(field, value, formValues);
373
- if (errors.length) validationErrors.push(errors[0].message);
374
- }
375
- }
376
-
377
- if (missingFields.length || validationErrors.length) {
378
- const parts = [];
379
- if (missingFields.length) parts.push(`Required fields missing: ${missingFields.join(', ')}`);
380
- if (validationErrors.length) parts.push(validationErrors.join('; '));
381
- return reply.status(400).send({ error: `${parts.join('. ')}.` });
382
- }
383
-
384
- // Rate limit by IP
385
- const ip = request.ip || request.socket?.remoteAddress || 'unknown';
386
- const limit = settings.rateLimitPerMinute || 3;
387
- if (isRateLimited(slug, ip, limit)) {
388
- return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
389
- }
390
-
391
- // Build submission data only include visible fields
392
- const data = {};
393
- for (const field of form.fields || []) {
394
- if (field.type === 'page-break' || field.type === 'spacer') continue;
395
- if (!visibleFieldNames.has(field.name)) continue;
396
- const val = body[field.name];
397
- if (val !== undefined) {
398
- data[field.name] = typeof val === 'string' ? val.trim() : val;
399
- }
400
- }
401
-
402
- const submission = {
403
- id: crypto.randomUUID(),
404
- data,
405
- meta: { ip, createdAt: new Date().toISOString() }
406
- };
407
-
408
- const submissions = await readSubmissions(slug);
409
- submissions.push(submission);
410
- await writeSubmissions(slug, submissions);
411
-
412
- // Email action
413
- const emailAction = form.actions?.email;
414
- if (emailAction?.enabled && emailAction.recipients) {
415
- try {
416
- const smtp = getConfig('site').smtp || {};
417
- const transport = await createTransport(smtp);
418
- await sendFormEmail(transport, {
419
- from: smtp.fromAddress,
420
- fromName: smtp.fromName,
421
- to: emailAction.recipients,
422
- subject: `${emailAction.subjectPrefix || `[${form.title}]`} New submission`,
423
- formTitle: form.title,
424
- fields: form.fields,
425
- data
426
- });
427
- } catch (err) {
428
- fastify.log.warn(`[form-builder] Email send failed for "${slug}": ${err.message}`);
429
- }
430
- }
431
-
432
- // Webhook action
433
- const webhookAction = form.actions?.webhook;
434
- if (webhookAction?.enabled && webhookAction.url) {
435
- try {
436
- await fetch(webhookAction.url, {
437
- method: webhookAction.method || 'POST',
438
- headers: { 'Content-Type': 'application/json' },
439
- body: JSON.stringify({ form: slug, submission })
440
- });
441
- } catch (err) {
442
- fastify.log.warn(`[form-builder] Webhook failed for "${slug}": ${err.message}`);
443
- }
444
- }
445
-
446
- // Collection action
447
- const collectionAction = form.actions?.collection;
448
- if (collectionAction?.enabled && collectionAction.slug) {
449
- try {
450
- const col = await getCollection(collectionAction.slug);
451
- if (col) {
452
- await createEntry(collectionAction.slug, data, { source: `form:${slug}` });
453
- }
454
- } catch (err) {
455
- fastify.log.warn(`[form-builder] Collection write failed for "${slug}": ${err.message}`);
456
- }
457
- }
458
-
459
- return { ok: true, message: settings.successMessage || 'Thank you for your submission.' };
460
- });
461
-
462
- // -----------------------------------------------------------------------
463
- // GET /settings — global plugin settings (SMTP)
464
- // -----------------------------------------------------------------------
465
- fastify.get('/settings', { preHandler: [authenticate, requireAdmin] }, async () => {
466
- return getPluginSettings('form-builder');
467
- });
468
-
469
- // -----------------------------------------------------------------------
470
- // PUT /settings save global settings
471
- // -----------------------------------------------------------------------
472
- fastify.put('/settings', { preHandler: [authenticate, requireAdmin] }, async (request) => {
473
- savePluginState('form-builder', { settings: request.body || {} });
474
- return { ok: true };
475
- });
476
-
477
- // -----------------------------------------------------------------------
478
- // POST /test-email send a test email
479
- // -----------------------------------------------------------------------
480
- fastify.post('/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
481
- const smtp = getConfig('site').smtp || {};
482
- const to = (request.body?.to) || smtp.fromAddress || 'test@ethereal.email';
483
- try {
484
- const transport = await createTransport(smtp);
485
- await sendFormEmail(transport, {
486
- from: smtp.fromAddress,
487
- fromName: smtp.fromName,
488
- to,
489
- subject: '[Form Builder] Test Email',
490
- formTitle: 'Test Form',
491
- fields: [
492
- { name: 'name', label: 'Name' },
493
- { name: 'message', label: 'Message' }
494
- ],
495
- data: {
496
- name: 'Test Sender',
497
- message: 'This is a test email from your Domma CMS Form Builder plugin. If you received this, your SMTP settings are working correctly.'
498
- }
499
- });
500
- return { ok: true, message: `Test email sent to ${to}` };
501
- } catch (err) {
502
- return reply.status(500).send({ error: `Failed to send test email: ${err.message}` });
503
- }
504
- });
505
- }
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
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
39
+
40
+ // Load shared logic engine (UMD/CJS) from core public assets
41
+ const _require = createRequire(import.meta.url);
42
+ const FormLogicEngine = _require('../../../public/js/form-logic-engine.js');
43
+
44
+ // Per-slug rate limit store: slug → Map<ip, timestamp[]>
45
+ const rateLimitMap = new Map();
46
+
47
+ function isRateLimited(slug, ip, limitPerMinute) {
48
+ const now = Date.now();
49
+ const windowMs = 60 * 1000;
50
+ if (!rateLimitMap.has(slug)) rateLimitMap.set(slug, new Map());
51
+ const slugMap = rateLimitMap.get(slug);
52
+ const timestamps = (slugMap.get(ip) || []).filter(t => now - t < windowMs);
53
+ if (timestamps.length >= limitPerMinute) return true;
54
+ timestamps.push(now);
55
+ slugMap.set(ip, timestamps);
56
+ return false;
57
+ }
58
+
59
+ function submissionsToCSV(form, entries) {
60
+ const fields = (form.fields || []).filter(f => f.type !== 'page-break' && f.type !== 'spacer');
61
+ const headers = [...fields.map(f => `"${f.label || f.name}"`), '"Date"'];
62
+ const rows = entries.map(e => {
63
+ const cols = fields.map(f => {
64
+ const raw = e.data?.[f.name] ?? '';
65
+ const str = Array.isArray(raw) ? raw.join('; ') : String(raw);
66
+ const val = str.replace(/"/g, '""');
67
+ return `"${val}"`;
68
+ });
69
+ cols.push(`"${e.meta?.createdAt || ''}"`);
70
+ return cols.join(',');
71
+ });
72
+ return [headers.join(','), ...rows].join('\n');
73
+ }
74
+
75
+ export async function formsRoutes(fastify) {
76
+ await ensureFormsDir();
77
+
78
+ const canRead = { preHandler: [authenticate, requirePermission('collections', 'read')] };
79
+ const canCreate = { preHandler: [authenticate, requirePermission('collections', 'create')] };
80
+ const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
81
+ const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
82
+
83
+ // -----------------------------------------------------------------------
84
+ // GET /forms list all form definitions with submission counts
85
+ // -----------------------------------------------------------------------
86
+ fastify.get('/forms', canRead, async () => {
87
+ const forms = await listForms();
88
+ const result = await Promise.all(forms.map(async form => {
89
+ let submissionCount = 0;
90
+ try {
91
+ const entries = await listEntries(form.slug);
92
+ submissionCount = entries.length;
93
+ } catch {
94
+ // collection may not exist yet
95
+ }
96
+ return { ...form, submissionCount };
97
+ }));
98
+ return result;
99
+ });
100
+
101
+ // -----------------------------------------------------------------------
102
+ // POST /forms — create new form
103
+ // -----------------------------------------------------------------------
104
+ fastify.post('/forms', canCreate, async (request, reply) => {
105
+ const { title, slug: rawSlug } = request.body || {};
106
+ if (!title?.trim()) {
107
+ return reply.status(400).send({ error: 'Title is required.' });
108
+ }
109
+ const slug = rawSlug ? slugify(rawSlug) : slugify(title);
110
+ if (!slug) {
111
+ return reply.status(400).send({ error: 'Could not generate a valid slug.' });
112
+ }
113
+
114
+ // Check for existing form with same slug
115
+ try {
116
+ await readForm(slug);
117
+ return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
118
+ } catch {
119
+ // Does not exist — good
120
+ }
121
+
122
+ const now = new Date().toISOString();
123
+ const body = request.body || {};
124
+ const form = {
125
+ slug,
126
+ title: title.trim(),
127
+ description: body.description || '',
128
+ fields: Array.isArray(body.fields) ? body.fields : [],
129
+ settings: {
130
+ submitText: 'Submit',
131
+ successMessage: 'Thank you for your submission.',
132
+ layout: 'stacked',
133
+ honeypot: true,
134
+ rateLimitPerMinute: 3,
135
+ ...(body.settings || {})
136
+ },
137
+ actions: {
138
+ email: { enabled: false, recipients: '', subjectPrefix: `[${title.trim()}]` },
139
+ webhook: { enabled: false, url: '', method: 'POST' },
140
+ collection: {enabled: true, slug},
141
+ ...(body.actions || {})
142
+ },
143
+ createdAt: now,
144
+ updatedAt: now
145
+ };
146
+
147
+ await writeForm(slug, form);
148
+
149
+ // Auto-create a matching collection for this form (skip if one already exists)
150
+ try {
151
+ await createCollection({
152
+ slug,
153
+ title: title.trim(),
154
+ description: `Submissions from the ${title.trim()} form.`,
155
+ fields: [],
156
+ api: {
157
+ create: { enabled: false, access: 'admin' },
158
+ read: { enabled: true, access: 'admin' },
159
+ update: { enabled: false, access: 'admin' },
160
+ delete: { enabled: false, access: 'admin' }
161
+ }
162
+ });
163
+ } catch (err) {
164
+ fastify.log.warn(`[forms] Could not auto-create collection "${slug}": ${err.message}`);
165
+ }
166
+
167
+ return reply.status(201).send(form);
168
+ });
169
+
170
+ // -----------------------------------------------------------------------
171
+ // GET /forms/:slug — get single form (admin, includes actions)
172
+ // -----------------------------------------------------------------------
173
+ fastify.get('/forms/:slug', canRead, async (request, reply) => {
174
+ try {
175
+ return await readForm(request.params.slug);
176
+ } catch {
177
+ return reply.status(404).send({ error: 'Form not found.' });
178
+ }
179
+ });
180
+
181
+ // -----------------------------------------------------------------------
182
+ // GET /forms/:slug/public get form for public rendering (no actions)
183
+ // -----------------------------------------------------------------------
184
+ fastify.get('/forms/:slug/public', async (request, reply) => {
185
+ try {
186
+ const form = await readForm(request.params.slug);
187
+ const { actions: _actions, ...safe } = form;
188
+ return safe;
189
+ } catch {
190
+ return reply.status(404).send({ error: 'Form not found.' });
191
+ }
192
+ });
193
+
194
+ // -----------------------------------------------------------------------
195
+ // PUT /forms/:slug — update form definition
196
+ // -----------------------------------------------------------------------
197
+ fastify.put('/forms/:slug', canUpdate, async (request, reply) => {
198
+ const { slug } = request.params;
199
+ let existing;
200
+ try {
201
+ existing = await readForm(slug);
202
+ } catch {
203
+ return reply.status(404).send({ error: 'Form not found.' });
204
+ }
205
+
206
+ const body = request.body || {};
207
+ const updated = {
208
+ ...existing,
209
+ ...body,
210
+ slug,
211
+ createdAt: existing.createdAt,
212
+ updatedAt: new Date().toISOString()
213
+ };
214
+ await writeForm(slug, updated);
215
+ return updated;
216
+ });
217
+
218
+ // -----------------------------------------------------------------------
219
+ // DELETE /forms/:slug — delete form
220
+ // -----------------------------------------------------------------------
221
+ fastify.delete('/forms/:slug', canDelete, async (request, reply) => {
222
+ const { slug } = request.params;
223
+ try {
224
+ await deleteForm(slug);
225
+ } catch {
226
+ return reply.status(404).send({ error: 'Form not found.' });
227
+ }
228
+ return { ok: true };
229
+ });
230
+
231
+ // -----------------------------------------------------------------------
232
+ // GET /forms/:slug/submissions — list submissions from collection
233
+ // -----------------------------------------------------------------------
234
+ fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
235
+ const { slug } = request.params;
236
+ try {
237
+ const entries = await listEntries(slug);
238
+ return entries.slice().reverse();
239
+ } catch {
240
+ return reply.status(404).send({ error: 'Collection not found for this form.' });
241
+ }
242
+ });
243
+
244
+ // -----------------------------------------------------------------------
245
+ // GET /forms/:slug/submissions/export CSV download from collection
246
+ // -----------------------------------------------------------------------
247
+ fastify.get('/forms/:slug/submissions/export', canRead, async (request, reply) => {
248
+ const { slug } = request.params;
249
+ let form;
250
+ try {
251
+ form = await readForm(slug);
252
+ } catch {
253
+ return reply.status(404).send({ error: 'Form not found.' });
254
+ }
255
+ let entries = [];
256
+ try {
257
+ entries = await listEntries(slug);
258
+ } catch {
259
+ // empty collection
260
+ }
261
+ const csv = submissionsToCSV(form, entries);
262
+ reply.header('Content-Type', 'text/csv');
263
+ reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.csv"`);
264
+ return reply.send(csv);
265
+ });
266
+
267
+ // -----------------------------------------------------------------------
268
+ // GET /forms/:slug/submissions/export/json — JSON export from collection
269
+ // -----------------------------------------------------------------------
270
+ fastify.get('/forms/:slug/submissions/export/json', canRead, async (request, reply) => {
271
+ const { slug } = request.params;
272
+ let form;
273
+ try {
274
+ form = await readForm(slug);
275
+ } catch {
276
+ return reply.status(404).send({ error: 'Form not found.' });
277
+ }
278
+ let entries = [];
279
+ try {
280
+ entries = await listEntries(slug);
281
+ } catch {
282
+ // empty
283
+ }
284
+ reply.header('Content-Type', 'application/json');
285
+ reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.json"`);
286
+ return reply.send(JSON.stringify(entries, null, 2));
287
+ });
288
+
289
+ // -----------------------------------------------------------------------
290
+ // DELETE /forms/:slug/submissions clear all submissions
291
+ // -----------------------------------------------------------------------
292
+ fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
293
+ const { slug } = request.params;
294
+ try {
295
+ await clearEntries(slug);
296
+ } catch {
297
+ return reply.status(404).send({ error: 'Collection not found for this form.' });
298
+ }
299
+ return { ok: true };
300
+ });
301
+
302
+ // -----------------------------------------------------------------------
303
+ // DELETE /forms/:slug/submissions/:id — delete one submission
304
+ // -----------------------------------------------------------------------
305
+ fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
306
+ const { slug, id } = request.params;
307
+ try {
308
+ await deleteEntry(slug, id);
309
+ } catch {
310
+ return reply.status(404).send({ error: 'Submission not found.' });
311
+ }
312
+ return { ok: true };
313
+ });
314
+
315
+ // -----------------------------------------------------------------------
316
+ // POST /forms/submit/:slug public form submission
317
+ // -----------------------------------------------------------------------
318
+ fastify.post('/forms/submit/:slug', async (request, reply) => {
319
+ const { slug } = request.params;
320
+ let form;
321
+ try {
322
+ form = await readForm(slug);
323
+ } catch {
324
+ return reply.status(404).send({ error: 'Form not found.' });
325
+ }
326
+
327
+ const body = request.body || {};
328
+ const settings = form.settings || {};
329
+
330
+ // Honeypot check silently accept if filled (bot detected)
331
+ if (settings.honeypot && body._hp) {
332
+ return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
333
+ }
334
+
335
+ // Timing check — silently accept if submitted too fast (< 2 s, likely a bot)
336
+ if (settings.honeypot && body._t) {
337
+ const elapsed = Date.now() - Number(body._t);
338
+ if (!Number.isNaN(elapsed) && elapsed < 2000) {
339
+ return {ok: true, message: settings.successMessage, redirect: settings.successRedirect || null};
340
+ }
341
+ }
342
+
343
+ // Build form values for engine evaluation
344
+ const formValues = {};
345
+ for (const field of form.fields || []) {
346
+ if (field.type === 'page-break' || field.type === 'spacer') continue;
347
+ const val = body[field.name];
348
+ formValues[field.name] = val !== undefined ? (typeof val === 'string' ? val.trim() : val) : '';
349
+ }
350
+
351
+ // Evaluate conditional logic
352
+ const missingFields = [];
353
+ const validationErrors = [];
354
+ const visibleFieldNames = new Set();
355
+
356
+ for (const field of form.fields || []) {
357
+ if (field.type === 'page-break' || field.type === 'spacer') continue;
358
+ const vis = FormLogicEngine.evaluateFieldVisibility(field, formValues);
359
+ if (vis === 'hidden') continue;
360
+ visibleFieldNames.add(field.name);
361
+
362
+ const value = formValues[field.name];
363
+ const isEmpty = !value?.toString().trim();
364
+ const required = FormLogicEngine.evaluateFieldRequirement(field, formValues);
365
+ if (required && isEmpty) {
366
+ missingFields.push(field.label || field.name);
367
+ }
368
+ if (!isEmpty) {
369
+ const errors = FormLogicEngine.validateField(field, value, formValues);
370
+ if (errors.length) validationErrors.push(errors[0].message);
371
+ }
372
+ }
373
+
374
+ if (missingFields.length || validationErrors.length) {
375
+ const parts = [];
376
+ if (missingFields.length) parts.push(`Required fields missing: ${missingFields.join(', ')}`);
377
+ if (validationErrors.length) parts.push(validationErrors.join('; '));
378
+ return reply.status(400).send({ error: `${parts.join('. ')}.` });
379
+ }
380
+
381
+ // Rate limit by IP
382
+ const ip = request.ip || request.socket?.remoteAddress || 'unknown';
383
+ const limit = settings.rateLimitPerMinute || 3;
384
+ if (isRateLimited(slug, ip, limit)) {
385
+ return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
386
+ }
387
+
388
+ // Build submission data only include visible fields
389
+ const data = {};
390
+ for (const field of form.fields || []) {
391
+ if (field.type === 'page-break' || field.type === 'spacer') continue;
392
+ if (!visibleFieldNames.has(field.name)) continue;
393
+ const val = body[field.name];
394
+ if (val !== undefined) {
395
+ data[field.name] = typeof val === 'string' ? val.trim() : val;
396
+ }
397
+ }
398
+
399
+ // Store in collection (sole submission store)
400
+ const collectionAction = form.actions?.collection;
401
+ const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
402
+ let entry = null;
403
+ try {
404
+ const col = await getCollection(targetSlug);
405
+ if (col) {
406
+ entry = await createEntry(targetSlug, data, { source: `form:${slug}` });
407
+ }
408
+ } catch (err) {
409
+ fastify.log.warn(`[forms] Collection write failed for "${slug}": ${err.message}`);
410
+ }
411
+
412
+ // Email action
413
+ const emailAction = form.actions?.email;
414
+ if (emailAction?.enabled && emailAction.recipients) {
415
+ try {
416
+ const smtp = getConfig('site').smtp || {};
417
+ const transport = await createTransport(smtp);
418
+ await sendFormEmail(transport, {
419
+ from: smtp.fromAddress,
420
+ fromName: smtp.fromName,
421
+ to: emailAction.recipients,
422
+ subject: `${emailAction.subjectPrefix || `[${form.title}]`} New submission`,
423
+ formTitle: form.title,
424
+ fields: form.fields,
425
+ data
426
+ });
427
+ } catch (err) {
428
+ fastify.log.warn(`[forms] Email send failed for "${slug}": ${err.message}`);
429
+ }
430
+ }
431
+
432
+ // Webhook action
433
+ const webhookAction = form.actions?.webhook;
434
+ if (webhookAction?.enabled && webhookAction.url) {
435
+ try {
436
+ await fetch(webhookAction.url, {
437
+ method: webhookAction.method || 'POST',
438
+ headers: { 'Content-Type': 'application/json' },
439
+ body: JSON.stringify({ form: slug, data })
440
+ });
441
+ } catch (err) {
442
+ fastify.log.warn(`[forms] Webhook failed for "${slug}": ${err.message}`);
443
+ }
444
+ }
445
+
446
+ // CMS Action trigger
447
+ const actionSlug = form.settings?.actionSlug;
448
+ if (actionSlug && entry) {
449
+ try {
450
+ await executeAction(actionSlug, entry.id, { user: null });
451
+ } catch (err) {
452
+ fastify.log.warn(`[forms] Action "${actionSlug}" failed for form "${slug}": ${err.message}`);
453
+ }
454
+ }
455
+
456
+ return {
457
+ ok: true,
458
+ message: settings.successMessage || 'Thank you for your submission.',
459
+ redirect: settings.successRedirect || null
460
+ };
461
+ });
462
+
463
+ // -----------------------------------------------------------------------
464
+ // POST /forms/test-email — send a test email
465
+ // -----------------------------------------------------------------------
466
+ fastify.post('/forms/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
467
+ const smtp = getConfig('site').smtp || {};
468
+ const to = (request.body?.to) || smtp.fromAddress || 'test@ethereal.email';
469
+ try {
470
+ const transport = await createTransport(smtp);
471
+ await sendFormEmail(transport, {
472
+ from: smtp.fromAddress,
473
+ fromName: smtp.fromName,
474
+ to,
475
+ subject: '[Forms] Test Email',
476
+ formTitle: 'Test Form',
477
+ fields: [
478
+ { name: 'name', label: 'Name' },
479
+ { name: 'message', label: 'Message' }
480
+ ],
481
+ data: {
482
+ name: 'Test Sender',
483
+ message: 'This is a test email from your Domma CMS. If you received this, your SMTP settings are working correctly.'
484
+ }
485
+ });
486
+ return { ok: true, message: `Test email sent to ${to}` };
487
+ } catch (err) {
488
+ return reply.status(500).send({ error: `Failed to send test email: ${err.message}` });
489
+ }
490
+ });
491
+ }