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.
- package/README.md +3 -3
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +2313 -0
- package/admin/dist/domma/domma-tools.min.js +10 -0
- package/admin/index.html +4 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +8 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +18 -10
- package/admin/js/templates/action-editor.html +171 -0
- package/admin/js/templates/actions-list.html +19 -0
- package/admin/js/templates/api-reference.html +1411 -0
- package/admin/js/templates/block-editor.html +158 -0
- package/admin/js/templates/blocks.html +8 -0
- package/admin/js/templates/collection-editor.html +47 -0
- package/admin/js/templates/collection-entries.html +3 -0
- package/admin/js/templates/collections.html +51 -4
- package/admin/js/templates/documentation.html +258 -0
- package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
- package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
- package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
- package/admin/js/templates/login.html +29 -4
- package/admin/js/templates/my-profile.html +17 -0
- package/admin/js/templates/page-editor.html +39 -0
- package/admin/js/templates/pages.html +6 -1
- package/admin/js/templates/pro-docs.html +259 -0
- package/admin/js/templates/role-editor.html +59 -0
- package/admin/js/templates/roles.html +10 -0
- package/admin/js/templates/settings.html +167 -23
- package/admin/js/templates/tutorials.html +81 -0
- package/admin/js/templates/user-editor.html +7 -0
- package/admin/js/templates/users.html +3 -26
- package/admin/js/templates/view-editor.html +201 -0
- package/admin/js/templates/view-preview.html +51 -0
- package/admin/js/templates/views-list.html +19 -0
- package/admin/js/views/action-editor.js +1 -0
- package/admin/js/views/actions-list.js +1 -0
- package/admin/js/views/api-reference.js +1 -0
- package/admin/js/views/block-editor.js +8 -0
- package/admin/js/views/blocks.js +4 -0
- package/admin/js/views/collection-editor.js +3 -3
- package/admin/js/views/collection-entries.js +1 -1
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +8 -0
- package/admin/js/views/form-submissions.js +1 -0
- package/admin/js/views/forms.js +1 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/media.js +1 -1
- package/admin/js/views/my-profile.js +1 -0
- package/admin/js/views/page-editor.js +34 -15
- package/admin/js/views/pages.js +5 -5
- package/admin/js/views/plugins.js +10 -10
- package/admin/js/views/pro-docs.js +1 -0
- package/admin/js/views/role-editor.js +1 -0
- package/admin/js/views/roles.js +4 -0
- package/admin/js/views/settings.js +3 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +4 -7
- package/admin/js/views/view-editor.js +1 -0
- package/admin/js/views/view-preview.js +1 -0
- package/admin/js/views/views-list.js +1 -0
- package/bin/cli.js +1 -1
- package/config/auth.json +1 -0
- package/config/connections.json.bak +9 -0
- package/config/connections.json.example +9 -0
- package/config/navigation.json +5 -15
- package/config/plugins.json +19 -29
- package/config/server.json +6 -6
- package/config/site.json +16 -6
- package/package.json +25 -10
- package/plugins/example-analytics/stats.json +17 -12
- package/plugins/form-builder/data/forms/contacts.json +62 -62
- package/plugins/form-builder/data/forms/enquiries.json +103 -0
- package/plugins/form-builder/data/forms/feedback.json +17 -16
- package/plugins/form-builder/data/forms/notes.json +79 -0
- package/plugins/form-builder/data/forms/to-do.json +100 -0
- package/plugins/form-builder/data/submissions/contacts.json +1 -26
- package/plugins/form-builder/data/submissions/notes.json +1 -0
- package/plugins/form-builder/data/submissions/to-do.json +1 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/plugin.js +233 -0
- package/plugins/theme-roller/plugin.json +31 -0
- package/plugins/theme-roller/public/active-theme.css +0 -0
- package/plugins/theme-roller/public/inject-head-late.html +1 -0
- package/public/css/forms.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/forms.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +194 -129
- package/scripts/pro.js +254 -0
- package/scripts/reset.js +33 -8
- package/scripts/seed.js +677 -128
- package/scripts/setup.js +1 -0
- package/server/middleware/auth.js +136 -120
- package/server/routes/api/actions.js +200 -0
- package/server/routes/api/auth.js +292 -146
- package/server/routes/api/blocks.js +84 -0
- package/server/routes/api/collections.js +79 -27
- package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
- package/server/routes/api/layouts.js +49 -39
- package/server/routes/api/media.js +118 -92
- package/server/routes/api/navigation.js +40 -36
- package/server/routes/api/pages.js +132 -118
- package/server/routes/api/plugins.js +6 -3
- package/server/routes/api/settings.js +104 -88
- package/server/routes/api/users.js +27 -19
- package/server/routes/api/views.js +148 -0
- package/server/routes/public.js +124 -108
- package/server/server.js +269 -181
- package/server/services/actions.js +387 -0
- package/server/services/adapterRegistry.js +98 -0
- package/server/services/adapters/FileAdapter.js +192 -0
- package/server/services/adapters/MongoAdapter.js +220 -0
- package/server/services/blocks.js +162 -0
- package/server/services/collections.js +74 -86
- package/server/services/connectionManager.js +102 -0
- package/server/services/content.js +312 -307
- package/server/services/email.js +126 -0
- package/server/services/forms.js +173 -0
- package/server/services/markdown.js +1378 -747
- package/server/services/permissionRegistry.js +173 -0
- package/server/services/presetCollections.js +251 -0
- package/server/services/renderer.js +98 -2
- package/server/services/roles.js +227 -0
- package/server/services/rowAccess.js +104 -0
- package/server/services/userProfiles.js +199 -0
- package/server/services/users.js +281 -212
- package/server/services/views.js +280 -0
- package/server/templates/page.html +124 -113
- package/plugins/form-builder/admin/templates/form-settings.html +0 -29
- package/plugins/form-builder/admin/views/form-editor.js +0 -1444
- package/plugins/form-builder/admin/views/form-settings.js +0 -38
- package/plugins/form-builder/admin/views/form-submissions.js +0 -295
- package/plugins/form-builder/admin/views/forms-list.js +0 -164
- package/plugins/form-builder/config.js +0 -9
- package/plugins/form-builder/data/forms/consent.json +0 -104
- package/plugins/form-builder/data/forms/contact-details.json +0 -99
- package/plugins/form-builder/data/submissions/consent.json +0 -13
- package/plugins/form-builder/plugin.json +0 -52
- package/plugins/form-builder/public/inject-body.html +0 -352
- package/plugins/form-builder/public/inject-head.html +0 -58
- package/plugins/form-builder/public/package.json +0 -1
- package/scripts/copy-domma.js +0 -48
- package/server/services/userTypes.js +0 -167
- /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
- /package/{plugins/form-builder/public → public/js}/form-logic-engine.js +0 -0
|
@@ -1,505 +1,491 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* GET /forms/:slug
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* GET /forms/:slug/submissions
|
|
14
|
-
*
|
|
15
|
-
* DELETE /forms/:slug/submissions
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import
|
|
22
|
-
import path from 'path';
|
|
23
|
-
import
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
} catch {
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// -----------------------------------------------------------------------
|
|
219
|
-
//
|
|
220
|
-
// -----------------------------------------------------------------------
|
|
221
|
-
fastify.
|
|
222
|
-
const { slug } = request.params;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// -----------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// -----------------------------------------------------------------------
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// -----------------------------------------------------------------------
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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(`[
|
|
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,
|
|
440
|
-
});
|
|
441
|
-
} catch (err) {
|
|
442
|
-
fastify.log.warn(`[
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
const
|
|
448
|
-
if (
|
|
449
|
-
try {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
}
|