domma-cms 0.1.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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Form Builder Plugin — Email Helper
3
+ * Nodemailer transport factory and generic form email sending utility.
4
+ */
5
+ import nodemailer from 'nodemailer';
6
+
7
+ /**
8
+ * Escape HTML special characters for safe use in email bodies.
9
+ *
10
+ * @param {string} str
11
+ * @returns {string}
12
+ */
13
+ export function escapeHtml(str) {
14
+ return String(str)
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&#39;');
20
+ }
21
+
22
+ /**
23
+ * Create a nodemailer transport.
24
+ * Falls back to an Ethereal test account when no SMTP host is configured.
25
+ *
26
+ * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
27
+ * @returns {Promise<import('nodemailer').Transporter>}
28
+ * @throws {Error} If Ethereal test account creation fails.
29
+ */
30
+ export async function createTransport(smtp) {
31
+ if (smtp?.host) {
32
+ return nodemailer.createTransport({
33
+ host: smtp.host,
34
+ port: smtp.port || 587,
35
+ secure: smtp.secure || false,
36
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
37
+ });
38
+ }
39
+
40
+ // No SMTP configured — use Ethereal for dev/demo
41
+ const testAccount = await nodemailer.createTestAccount();
42
+ console.log('[form-builder] No SMTP configured. Using Ethereal test account:', testAccount.user);
43
+ return nodemailer.createTransport({
44
+ host: 'smtp.ethereal.email',
45
+ port: 587,
46
+ secure: false,
47
+ auth: { user: testAccount.user, pass: testAccount.pass }
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Send an HTML + plain-text form submission notification email.
53
+ * Builds a generic table of field→value pairs from the submitted data.
54
+ *
55
+ * @param {import('nodemailer').Transporter} transport
56
+ * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
57
+ * @returns {Promise<void>}
58
+ * @throws {Error} If sending the email fails.
59
+ */
60
+ export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
61
+ const rows = fields.map(field => {
62
+ const val = data[field.name] ?? '';
63
+ const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
64
+ const safeLabel = escapeHtml(field.label || field.name);
65
+ return `
66
+ <tr>
67
+ <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
68
+ <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
69
+ </tr>`.trim();
70
+ }).join('\n');
71
+
72
+ const plainRows = fields.map(field => {
73
+ const val = data[field.name] ?? '';
74
+ return `${field.label || field.name}: ${val}`;
75
+ }).join('\n');
76
+
77
+ const html = `
78
+ <!DOCTYPE html>
79
+ <html>
80
+ <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
81
+ <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
82
+ <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
83
+ <table style="width:100%;border-collapse:collapse;margin-top:16px;">
84
+ ${rows}
85
+ </table>
86
+ </body>
87
+ </html>`.trim();
88
+
89
+ const text = `New form submission: ${formTitle}\n\n${plainRows}`;
90
+
91
+ const info = await transport.sendMail({
92
+ from: `"${fromName}" <${from}>`,
93
+ to,
94
+ subject,
95
+ text,
96
+ html
97
+ });
98
+
99
+ const previewUrl = nodemailer.getTestMessageUrl(info);
100
+ if (previewUrl) {
101
+ console.log('[form-builder] Email preview URL:', previewUrl);
102
+ }
103
+ }
@@ -0,0 +1,454 @@
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 { getPluginSettings, savePluginState } from '../../server/services/plugins.js';
26
+ import { getConfig } from '../../server/config.js';
27
+ import { createTransport, sendFormEmail } from './email.js';
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ const FORMS_DIR = path.join(__dirname, 'data', 'forms');
31
+ const SUBMISSIONS_DIR = path.join(__dirname, 'data', 'submissions');
32
+
33
+ // Per-slug rate limit store: slug → Map<ip, timestamp[]>
34
+ const rateLimitMap = new Map();
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // File helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ async function readForm(slug) {
41
+ const file = path.join(FORMS_DIR, `${slug}.json`);
42
+ return JSON.parse(await fs.readFile(file, 'utf8'));
43
+ }
44
+
45
+ async function writeForm(slug, data) {
46
+ const file = path.join(FORMS_DIR, `${slug}.json`);
47
+ await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
48
+ }
49
+
50
+ async function listForms() {
51
+ let entries;
52
+ try {
53
+ entries = await fs.readdir(FORMS_DIR);
54
+ } catch {
55
+ return [];
56
+ }
57
+ const forms = [];
58
+ for (const entry of entries.filter(e => e.endsWith('.json'))) {
59
+ try {
60
+ const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
61
+ forms.push(data);
62
+ } catch {
63
+ // skip malformed
64
+ }
65
+ }
66
+ return forms;
67
+ }
68
+
69
+ async function readSubmissions(slug) {
70
+ try {
71
+ return JSON.parse(await fs.readFile(path.join(SUBMISSIONS_DIR, `${slug}.json`), 'utf8'));
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+
77
+ async function writeSubmissions(slug, submissions) {
78
+ await fs.mkdir(SUBMISSIONS_DIR, { recursive: true });
79
+ await fs.writeFile(
80
+ path.join(SUBMISSIONS_DIR, `${slug}.json`),
81
+ JSON.stringify(submissions, null, 2) + '\n',
82
+ 'utf8'
83
+ );
84
+ }
85
+
86
+ function isRateLimited(slug, ip, limitPerMinute) {
87
+ const now = Date.now();
88
+ const windowMs = 60 * 1000;
89
+ if (!rateLimitMap.has(slug)) rateLimitMap.set(slug, new Map());
90
+ const slugMap = rateLimitMap.get(slug);
91
+ const timestamps = (slugMap.get(ip) || []).filter(t => now - t < windowMs);
92
+ if (timestamps.length >= limitPerMinute) return true;
93
+ timestamps.push(now);
94
+ slugMap.set(ip, timestamps);
95
+ return false;
96
+ }
97
+
98
+ function slugify(str) {
99
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
100
+ }
101
+
102
+ function submissionsToCSV(form, submissions) {
103
+ const fields = form.fields || [];
104
+ const headers = [...fields.map(f => `"${f.label || f.name}"`), '"Date"'];
105
+ const rows = submissions.map(s => {
106
+ const cols = fields.map(f => {
107
+ const val = String(s.data?.[f.name] ?? '').replace(/"/g, '""');
108
+ return `"${val}"`;
109
+ });
110
+ cols.push(`"${s.meta?.createdAt || ''}"`);
111
+ return cols.join(',');
112
+ });
113
+ return [headers.join(','), ...rows].join('\n');
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Plugin registration
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export default async function formBuilderPlugin(fastify, options) {
121
+ const { authenticate, requireAdmin } = options.auth;
122
+
123
+ // Ensure data dirs exist
124
+ await fs.mkdir(FORMS_DIR, { recursive: true });
125
+ await fs.mkdir(SUBMISSIONS_DIR, { recursive: true });
126
+
127
+ // -----------------------------------------------------------------------
128
+ // GET /forms — list all form definitions
129
+ // -----------------------------------------------------------------------
130
+ fastify.get('/forms', { preHandler: [authenticate, requireAdmin] }, async () => {
131
+ const forms = await listForms();
132
+ // Attach submission count to each form
133
+ const result = await Promise.all(forms.map(async form => {
134
+ const subs = await readSubmissions(form.slug);
135
+ return { ...form, submissionCount: subs.length };
136
+ }));
137
+ return result;
138
+ });
139
+
140
+ // -----------------------------------------------------------------------
141
+ // POST /forms — create new form
142
+ // -----------------------------------------------------------------------
143
+ fastify.post('/forms', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
144
+ const { title, slug: rawSlug } = request.body || {};
145
+ if (!title?.trim()) {
146
+ return reply.status(400).send({ error: 'Title is required.' });
147
+ }
148
+ const slug = rawSlug ? slugify(rawSlug) : slugify(title);
149
+ if (!slug) {
150
+ return reply.status(400).send({ error: 'Could not generate a valid slug.' });
151
+ }
152
+
153
+ // Check for existing form with same slug
154
+ try {
155
+ await fs.access(path.join(FORMS_DIR, `${slug}.json`));
156
+ return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
157
+ } catch {
158
+ // Does not exist — good
159
+ }
160
+
161
+ const now = new Date().toISOString();
162
+ const form = {
163
+ slug,
164
+ title: title.trim(),
165
+ description: '',
166
+ fields: [],
167
+ settings: {
168
+ submitText: 'Submit',
169
+ successMessage: 'Thank you for your submission.',
170
+ layout: 'stacked',
171
+ honeypot: true,
172
+ rateLimitPerMinute: 3
173
+ },
174
+ actions: {
175
+ email: { enabled: false, recipients: '', subjectPrefix: `[${title.trim()}]` },
176
+ webhook: { enabled: false, url: '', method: 'POST' }
177
+ },
178
+ createdAt: now,
179
+ updatedAt: now
180
+ };
181
+
182
+ await writeForm(slug, form);
183
+ await writeSubmissions(slug, []);
184
+ return reply.status(201).send(form);
185
+ });
186
+
187
+ // -----------------------------------------------------------------------
188
+ // GET /forms/:slug — get single form (admin, includes actions)
189
+ // -----------------------------------------------------------------------
190
+ fastify.get('/forms/:slug', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
191
+ try {
192
+ return await readForm(request.params.slug);
193
+ } catch {
194
+ return reply.status(404).send({ error: 'Form not found.' });
195
+ }
196
+ });
197
+
198
+ // -----------------------------------------------------------------------
199
+ // GET /forms/:slug/public — get form for public rendering (no actions)
200
+ // -----------------------------------------------------------------------
201
+ fastify.get('/forms/:slug/public', async (request, reply) => {
202
+ try {
203
+ const form = await readForm(request.params.slug);
204
+ // Strip sensitive actions block before returning to browser
205
+ const { actions: _actions, ...safe } = form;
206
+ return safe;
207
+ } catch {
208
+ return reply.status(404).send({ error: 'Form not found.' });
209
+ }
210
+ });
211
+
212
+ // -----------------------------------------------------------------------
213
+ // PUT /forms/:slug — update form definition
214
+ // -----------------------------------------------------------------------
215
+ fastify.put('/forms/:slug', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
216
+ const { slug } = request.params;
217
+ let existing;
218
+ try {
219
+ existing = await readForm(slug);
220
+ } catch {
221
+ return reply.status(404).send({ error: 'Form not found.' });
222
+ }
223
+
224
+ const body = request.body || {};
225
+ const updated = {
226
+ ...existing,
227
+ ...body,
228
+ slug, // slug is immutable via this route
229
+ createdAt: existing.createdAt,
230
+ updatedAt: new Date().toISOString()
231
+ };
232
+ await writeForm(slug, updated);
233
+ return updated;
234
+ });
235
+
236
+ // -----------------------------------------------------------------------
237
+ // DELETE /forms/:slug — delete form and its submissions
238
+ // -----------------------------------------------------------------------
239
+ fastify.delete('/forms/:slug', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
240
+ const { slug } = request.params;
241
+ try {
242
+ await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
243
+ } catch {
244
+ return reply.status(404).send({ error: 'Form not found.' });
245
+ }
246
+ try {
247
+ await fs.unlink(path.join(SUBMISSIONS_DIR, `${slug}.json`));
248
+ } catch {
249
+ // Submissions file may not exist — not an error
250
+ }
251
+ return { ok: true };
252
+ });
253
+
254
+ // -----------------------------------------------------------------------
255
+ // GET /forms/:slug/submissions — list submissions (newest first)
256
+ // -----------------------------------------------------------------------
257
+ fastify.get('/forms/:slug/submissions', { preHandler: [authenticate, requireAdmin] }, async (request) => {
258
+ const submissions = await readSubmissions(request.params.slug);
259
+ return submissions.slice().reverse();
260
+ });
261
+
262
+ // -----------------------------------------------------------------------
263
+ // GET /forms/:slug/submissions/export — CSV download
264
+ // -----------------------------------------------------------------------
265
+ fastify.get('/forms/:slug/submissions/export', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
266
+ const { slug } = request.params;
267
+ let form;
268
+ try {
269
+ form = await readForm(slug);
270
+ } catch {
271
+ return reply.status(404).send({ error: 'Form not found.' });
272
+ }
273
+ const submissions = await readSubmissions(slug);
274
+ const csv = submissionsToCSV(form, submissions);
275
+ reply
276
+ .header('Content-Type', 'text/csv; charset=utf-8')
277
+ .header('Content-Disposition', `attachment; filename="${slug}-submissions.csv"`);
278
+ return csv;
279
+ });
280
+
281
+ // -----------------------------------------------------------------------
282
+ // GET /forms/:slug/submissions/export/json — JSON download
283
+ // -----------------------------------------------------------------------
284
+ fastify.get('/forms/:slug/submissions/export/json', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
285
+ const { slug } = request.params;
286
+ try {
287
+ await readForm(slug);
288
+ } catch {
289
+ return reply.status(404).send({ error: 'Form not found.' });
290
+ }
291
+ const submissions = await readSubmissions(slug);
292
+ reply
293
+ .header('Content-Type', 'application/json; charset=utf-8')
294
+ .header('Content-Disposition', `attachment; filename="${slug}-submissions.json"`);
295
+ return JSON.stringify(submissions, null, 2);
296
+ });
297
+
298
+ // -----------------------------------------------------------------------
299
+ // DELETE /forms/:slug/submissions — clear all submissions
300
+ // -----------------------------------------------------------------------
301
+ fastify.delete('/forms/:slug/submissions', { preHandler: [authenticate, requireAdmin] }, async (request) => {
302
+ await writeSubmissions(request.params.slug, []);
303
+ return { ok: true };
304
+ });
305
+
306
+ // -----------------------------------------------------------------------
307
+ // DELETE /forms/:slug/submissions/:id — delete one submission
308
+ // -----------------------------------------------------------------------
309
+ fastify.delete('/forms/:slug/submissions/:id', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
310
+ const { slug, id } = request.params;
311
+ const submissions = await readSubmissions(slug);
312
+ const filtered = submissions.filter(s => s.id !== id);
313
+ if (filtered.length === submissions.length) {
314
+ return reply.status(404).send({ error: 'Submission not found.' });
315
+ }
316
+ await writeSubmissions(slug, filtered);
317
+ return { ok: true };
318
+ });
319
+
320
+ // -----------------------------------------------------------------------
321
+ // POST /submit/:slug — public form submission
322
+ // -----------------------------------------------------------------------
323
+ fastify.post('/submit/:slug', async (request, reply) => {
324
+ const { slug } = request.params;
325
+ let form;
326
+ try {
327
+ form = await readForm(slug);
328
+ } catch {
329
+ return reply.status(404).send({ error: 'Form not found.' });
330
+ }
331
+
332
+ const body = request.body || {};
333
+ const settings = form.settings || {};
334
+
335
+ // Honeypot check — silently accept if filled (bot detected)
336
+ if (settings.honeypot && body._hp) {
337
+ return { ok: true, message: settings.successMessage };
338
+ }
339
+
340
+ // Validate required fields
341
+ const missingFields = (form.fields || [])
342
+ .filter(f => f.required && !body[f.name]?.toString().trim())
343
+ .map(f => f.label || f.name);
344
+ if (missingFields.length) {
345
+ return reply.status(400).send({ error: `Required fields missing: ${missingFields.join(', ')}.` });
346
+ }
347
+
348
+ // Rate limit by IP
349
+ const ip = request.ip || request.socket?.remoteAddress || 'unknown';
350
+ const limit = settings.rateLimitPerMinute || 3;
351
+ if (isRateLimited(slug, ip, limit)) {
352
+ return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
353
+ }
354
+
355
+ // Build generic submission data map from defined fields
356
+ const data = {};
357
+ for (const field of form.fields || []) {
358
+ const val = body[field.name];
359
+ if (val !== undefined) {
360
+ data[field.name] = typeof val === 'string' ? val.trim() : val;
361
+ }
362
+ }
363
+
364
+ const submission = {
365
+ id: crypto.randomUUID(),
366
+ data,
367
+ meta: { ip, createdAt: new Date().toISOString() }
368
+ };
369
+
370
+ const submissions = await readSubmissions(slug);
371
+ submissions.push(submission);
372
+ await writeSubmissions(slug, submissions);
373
+
374
+ // Email action
375
+ const emailAction = form.actions?.email;
376
+ if (emailAction?.enabled && emailAction.recipients) {
377
+ try {
378
+ const smtp = getConfig('site').smtp || {};
379
+ const transport = await createTransport(smtp);
380
+ await sendFormEmail(transport, {
381
+ from: smtp.fromAddress,
382
+ fromName: smtp.fromName,
383
+ to: emailAction.recipients,
384
+ subject: `${emailAction.subjectPrefix || `[${form.title}]`} New submission`,
385
+ formTitle: form.title,
386
+ fields: form.fields,
387
+ data
388
+ });
389
+ } catch (err) {
390
+ fastify.log.warn(`[form-builder] Email send failed for "${slug}": ${err.message}`);
391
+ }
392
+ }
393
+
394
+ // Webhook action
395
+ const webhookAction = form.actions?.webhook;
396
+ if (webhookAction?.enabled && webhookAction.url) {
397
+ try {
398
+ await fetch(webhookAction.url, {
399
+ method: webhookAction.method || 'POST',
400
+ headers: { 'Content-Type': 'application/json' },
401
+ body: JSON.stringify({ form: slug, submission })
402
+ });
403
+ } catch (err) {
404
+ fastify.log.warn(`[form-builder] Webhook failed for "${slug}": ${err.message}`);
405
+ }
406
+ }
407
+
408
+ return { ok: true, message: settings.successMessage || 'Thank you for your submission.' };
409
+ });
410
+
411
+ // -----------------------------------------------------------------------
412
+ // GET /settings — global plugin settings (SMTP)
413
+ // -----------------------------------------------------------------------
414
+ fastify.get('/settings', { preHandler: [authenticate, requireAdmin] }, async () => {
415
+ return getPluginSettings('form-builder');
416
+ });
417
+
418
+ // -----------------------------------------------------------------------
419
+ // PUT /settings — save global settings
420
+ // -----------------------------------------------------------------------
421
+ fastify.put('/settings', { preHandler: [authenticate, requireAdmin] }, async (request) => {
422
+ savePluginState('form-builder', { settings: request.body || {} });
423
+ return { ok: true };
424
+ });
425
+
426
+ // -----------------------------------------------------------------------
427
+ // POST /test-email — send a test email
428
+ // -----------------------------------------------------------------------
429
+ fastify.post('/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
430
+ const smtp = getConfig('site').smtp || {};
431
+ const to = (request.body?.to) || smtp.fromAddress || 'test@ethereal.email';
432
+ try {
433
+ const transport = await createTransport(smtp);
434
+ await sendFormEmail(transport, {
435
+ from: smtp.fromAddress,
436
+ fromName: smtp.fromName,
437
+ to,
438
+ subject: '[Form Builder] Test Email',
439
+ formTitle: 'Test Form',
440
+ fields: [
441
+ { name: 'name', label: 'Name' },
442
+ { name: 'message', label: 'Message' }
443
+ ],
444
+ data: {
445
+ name: 'Test Sender',
446
+ message: 'This is a test email from your Domma CMS Form Builder plugin. If you received this, your SMTP settings are working correctly.'
447
+ }
448
+ });
449
+ return { ok: true, message: `Test email sent to ${to}` };
450
+ } catch (err) {
451
+ return reply.status(500).send({ error: `Failed to send test email: ${err.message}` });
452
+ }
453
+ });
454
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "form-builder",
3
+ "displayName": "Form Builder",
4
+ "version": "1.0.0",
5
+ "description": "Visual form builder with arbitrary field types, submission storage, email and webhook actions.",
6
+ "author": "Darryl Waterhouse",
7
+ "date": "2026-03-03",
8
+ "icon": "layout",
9
+ "admin": {
10
+ "sidebar": [
11
+ {
12
+ "id": "form-builder-forms",
13
+ "text": "Forms",
14
+ "icon": "layout",
15
+ "url": "#/plugins/form-builder",
16
+ "section": "#/plugins/form-builder"
17
+ },
18
+ {
19
+ "id": "form-builder-settings",
20
+ "text": "Form Settings",
21
+ "icon": "settings",
22
+ "url": "#/plugins/form-builder/settings",
23
+ "section": "#/plugins/form-builder/settings"
24
+ }
25
+ ],
26
+ "routes": [
27
+ { "path": "/plugins/form-builder", "view": "plugin-fb-forms-list", "title": "Forms - Domma CMS" },
28
+ { "path": "/plugins/form-builder/new", "view": "plugin-fb-form-editor", "title": "New Form - Domma CMS" },
29
+ { "path": "/plugins/form-builder/edit/:slug", "view": "plugin-fb-form-editor", "title": "Edit Form - Domma CMS" },
30
+ { "path": "/plugins/form-builder/:slug/submissions", "view": "plugin-fb-form-submissions", "title": "Submissions - Domma CMS" },
31
+ { "path": "/plugins/form-builder/settings", "view": "plugin-fb-form-settings", "title": "Form Builder Settings - Domma CMS" }
32
+ ],
33
+ "views": {
34
+ "plugin-fb-forms-list": {
35
+ "entry": "form-builder/admin/views/forms-list.js",
36
+ "exportName": "formsListView"
37
+ },
38
+ "plugin-fb-form-editor": {
39
+ "entry": "form-builder/admin/views/form-editor.js",
40
+ "exportName": "formEditorView"
41
+ },
42
+ "plugin-fb-form-submissions": {
43
+ "entry": "form-builder/admin/views/form-submissions.js",
44
+ "exportName": "formSubmissionsView"
45
+ },
46
+ "plugin-fb-form-settings": {
47
+ "entry": "form-builder/admin/views/form-settings.js",
48
+ "exportName": "formSettingsView"
49
+ }
50
+ }
51
+ },
52
+ "inject": {
53
+ "head": "public/inject-head.html",
54
+ "bodyEnd": "public/inject-body.html"
55
+ }
56
+ }