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.
- package/LICENSE +21 -0
- package/README.md +469 -0
- package/admin/css/admin.css +1123 -0
- package/admin/index.html +72 -0
- package/admin/js/api.js +210 -0
- package/admin/js/app.js +270 -0
- package/admin/js/config/sidebar-config.js +107 -0
- package/admin/js/lib/card.js +63 -0
- package/admin/js/lib/image-editor.js +869 -0
- package/admin/js/lib/markdown-toolbar.js +421 -0
- package/admin/js/templates/dashboard.html +50 -0
- package/admin/js/templates/documentation.html +237 -0
- package/admin/js/templates/layouts.html +11 -0
- package/admin/js/templates/login.html +58 -0
- package/admin/js/templates/media.html +16 -0
- package/admin/js/templates/navigation.html +50 -0
- package/admin/js/templates/page-editor.html +126 -0
- package/admin/js/templates/pages.html +18 -0
- package/admin/js/templates/plugins.html +12 -0
- package/admin/js/templates/settings.html +190 -0
- package/admin/js/templates/tutorials.html +233 -0
- package/admin/js/templates/user-editor.html +12 -0
- package/admin/js/templates/users.html +10 -0
- package/admin/js/views/dashboard.js +48 -0
- package/admin/js/views/documentation.js +12 -0
- package/admin/js/views/index.js +33 -0
- package/admin/js/views/layouts.js +49 -0
- package/admin/js/views/login.js +254 -0
- package/admin/js/views/media.js +240 -0
- package/admin/js/views/navigation.js +152 -0
- package/admin/js/views/page-editor.js +479 -0
- package/admin/js/views/pages.js +64 -0
- package/admin/js/views/plugins.js +100 -0
- package/admin/js/views/settings.js +64 -0
- package/admin/js/views/tutorials.js +12 -0
- package/admin/js/views/user-editor.js +88 -0
- package/admin/js/views/users.js +73 -0
- package/bin/cli.js +334 -0
- package/config/auth.json +20 -0
- package/config/content.json +10 -0
- package/config/navigation.json +63 -0
- package/config/plugins.json +47 -0
- package/config/presets.json +34 -0
- package/config/server.json +6 -0
- package/config/site.json +33 -0
- package/package.json +67 -0
- package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
- package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
- package/plugins/back-to-top/config.js +10 -0
- package/plugins/back-to-top/plugin.js +24 -0
- package/plugins/back-to-top/plugin.json +36 -0
- package/plugins/back-to-top/public/inject-body.html +105 -0
- package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
- package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
- package/plugins/cookie-consent/config.js +30 -0
- package/plugins/cookie-consent/plugin.js +24 -0
- package/plugins/cookie-consent/plugin.json +36 -0
- package/plugins/cookie-consent/public/inject-body.html +69 -0
- package/plugins/custom-css/admin/templates/custom-css.html +17 -0
- package/plugins/custom-css/admin/views/custom-css.js +35 -0
- package/plugins/custom-css/config.js +1 -0
- package/plugins/custom-css/data/custom.css +0 -0
- package/plugins/custom-css/plugin.js +63 -0
- package/plugins/custom-css/plugin.json +32 -0
- package/plugins/custom-css/public/inject-head.html +1 -0
- package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
- package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
- package/plugins/domma-effects/config.js +9 -0
- package/plugins/domma-effects/plugin.js +22 -0
- package/plugins/domma-effects/plugin.json +36 -0
- package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
- package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
- package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
- package/plugins/domma-effects/public/celebrations/index.js +535 -0
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
- package/plugins/domma-effects/public/inject-body.html +268 -0
- package/plugins/example-analytics/admin/templates/analytics.html +10 -0
- package/plugins/example-analytics/admin/views/analytics.js +51 -0
- package/plugins/example-analytics/config.js +6 -0
- package/plugins/example-analytics/plugin.js +58 -0
- package/plugins/example-analytics/plugin.json +27 -0
- package/plugins/example-analytics/public/inject-body.html +13 -0
- package/plugins/example-analytics/public/inject-head.html +1 -0
- package/plugins/example-analytics/stats.json +1 -0
- package/plugins/form-builder/admin/templates/form-editor.html +158 -0
- package/plugins/form-builder/admin/templates/form-settings.html +29 -0
- package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
- package/plugins/form-builder/admin/templates/forms-list.html +17 -0
- package/plugins/form-builder/admin/views/form-editor.js +817 -0
- package/plugins/form-builder/admin/views/form-settings.js +38 -0
- package/plugins/form-builder/admin/views/form-submissions.js +295 -0
- package/plugins/form-builder/admin/views/forms-list.js +164 -0
- package/plugins/form-builder/config.js +9 -0
- package/plugins/form-builder/data/forms/contact-details.json +63 -0
- package/plugins/form-builder/data/forms/contact.json +52 -0
- package/plugins/form-builder/data/submissions/contact-details.json +1 -0
- package/plugins/form-builder/data/submissions/contact.json +14 -0
- package/plugins/form-builder/email.js +103 -0
- package/plugins/form-builder/plugin.js +454 -0
- package/plugins/form-builder/plugin.json +56 -0
- package/plugins/form-builder/public/inject-body.html +270 -0
- package/plugins/form-builder/public/inject-head.html +42 -0
- package/public/css/site.css +189 -0
- package/public/js/site.js +109 -0
- package/scripts/copy-domma.js +48 -0
- package/scripts/fresh.js +41 -0
- package/scripts/reset.js +124 -0
- package/scripts/seed.js +666 -0
- package/scripts/setup.js +263 -0
- package/server/config.js +56 -0
- package/server/middleware/auth.js +97 -0
- package/server/routes/api/auth.js +116 -0
- package/server/routes/api/layouts.js +25 -0
- package/server/routes/api/media.js +93 -0
- package/server/routes/api/navigation.js +37 -0
- package/server/routes/api/pages.js +118 -0
- package/server/routes/api/plugins.js +46 -0
- package/server/routes/api/settings.js +25 -0
- package/server/routes/api/users.js +110 -0
- package/server/routes/public.js +108 -0
- package/server/server.js +169 -0
- package/server/services/content.js +298 -0
- package/server/services/images.js +334 -0
- package/server/services/markdown.js +297 -0
- package/server/services/plugins.js +246 -0
- package/server/services/renderer.js +80 -0
- package/server/services/users.js +212 -0
- 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, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|