@aphexcms/plugin-forms 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/dist/submit.js ADDED
@@ -0,0 +1,213 @@
1
+ import { z } from 'zod';
2
+ import { validateFormData } from '@aphexcms/cms-core';
3
+ import { systemContext } from '@aphexcms/cms-core/local-api/auth-helpers';
4
+ import { compileForm, isInputField } from './compile.js';
5
+ import { fieldLabels, toStoredValue } from './template.js';
6
+ import { formSubmissionCreated } from './events.js';
7
+ /**
8
+ * `POST /api/form-submissions` — the one public surface this plugin opens.
9
+ *
10
+ * Everything here treats the request as hostile, because it is: this is the only
11
+ * endpoint in the CMS an anonymous visitor is *meant* to write through.
12
+ *
13
+ * - The submitted values are validated against the form's own fields, using the
14
+ * same engine the admin uses (see `compile.ts`). A select can only hold an
15
+ * option the form offers; an email field must hold an email.
16
+ * - Only fields the form declares are stored. A key the form doesn't define is
17
+ * dropped rather than persisted, so a crafted request can't stuff arbitrary
18
+ * data into a document.
19
+ * - The submission is written under `systemContext`, which bypasses RBAC — the
20
+ * visitor has no account. That is safe only because the shape written here is
21
+ * fully determined by the form document, never by the request.
22
+ * - No email is sent from this path. A submission is stored and an event is
23
+ * emitted; the notification runs as a durable job, so a mail outage can't fail
24
+ * a visitor's submit and a retry can't re-store the submission.
25
+ */
26
+ const submitRequest = z.object({
27
+ form: z.string().min(1),
28
+ version: z.string().min(1).max(128),
29
+ /** Field name → submitted value. */
30
+ data: z
31
+ .record(z.string(), z.unknown())
32
+ .refine((data) => Object.keys(data).length <= 50, 'Too many fields'),
33
+ /**
34
+ * Honeypot. A real visitor never sees this input and so never fills it; a bot
35
+ * that fills every field it finds does. Named innocuously on purpose.
36
+ */
37
+ website: z.string().optional()
38
+ });
39
+ /** Requests per window, per IP. Deliberately generous — this is a floor, not a filter. */
40
+ const IP_RATE_LIMIT = 10;
41
+ const FORM_RATE_LIMIT = 50;
42
+ const RATE_WINDOW_MS = 60_000;
43
+ const MAX_REQUEST_BYTES = 64 * 1024;
44
+ /**
45
+ * In-memory rate limiting.
46
+ *
47
+ * Per-process and therefore per-instance: behind several replicas the effective
48
+ * limit multiplies, and a restart forgets everything. That is a real limitation
49
+ * and it is the right trade for a starter — it costs nothing, needs no Redis, and
50
+ * stops the naive case. A site under actual attack wants this at the edge (a WAF
51
+ * or the platform's own rate limiting), where a request can be dropped before it
52
+ * reaches an application process at all.
53
+ */
54
+ const hits = new Map();
55
+ function rateLimited(key, limit) {
56
+ const now = Date.now();
57
+ const entry = hits.get(key);
58
+ if (!entry || now > entry.resetAt) {
59
+ hits.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
60
+ // Opportunistic sweep: without it the map grows for the life of the process,
61
+ // one entry per IP ever seen.
62
+ if (hits.size > 5000) {
63
+ for (const [k, v] of hits)
64
+ if (now > v.resetAt)
65
+ hits.delete(k);
66
+ }
67
+ return false;
68
+ }
69
+ entry.count += 1;
70
+ return entry.count > limit;
71
+ }
72
+ /** Trusted connection address supplied by the framework bridge. */
73
+ function clientKey(c) {
74
+ return c.env.clientAddress?.trim() || 'unknown';
75
+ }
76
+ async function parseRequest(c) {
77
+ const contentLength = Number(c.req.header('content-length'));
78
+ if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES)
79
+ return null;
80
+ const body = await c.req.text().catch(() => '');
81
+ if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES)
82
+ return null;
83
+ const json = (() => {
84
+ try {
85
+ return JSON.parse(body);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ })();
91
+ const parsed = submitRequest.safeParse(json);
92
+ return parsed.success ? parsed.data : null;
93
+ }
94
+ export async function handleSubmit(c) {
95
+ const { aphexCMS } = c.var;
96
+ const parsed = await parseRequest(c);
97
+ if (!parsed) {
98
+ return c.json({ success: false, error: 'Invalid request' }, 400);
99
+ }
100
+ const { form: formId, version, data, website } = parsed;
101
+ /*
102
+ * A tripped honeypot returns success. Telling a bot it was detected teaches
103
+ * whoever wrote it to stop filling that field; a 200 with nothing stored costs
104
+ * them a submission and no information.
105
+ */
106
+ if (website && website.trim() !== '') {
107
+ return c.json({ success: true });
108
+ }
109
+ if (rateLimited(`ip:${clientKey(c)}`, IP_RATE_LIMIT) ||
110
+ rateLimited(`form:${formId}`, FORM_RATE_LIMIT)) {
111
+ return c.json({ success: false, error: 'Too many submissions. Try again shortly.' }, 429);
112
+ }
113
+ const organizationId = await aphexCMS.databaseAdapter.resolvePublishedDocumentOrganizationId(formId, 'form');
114
+ if (!organizationId)
115
+ return c.json({ success: false, error: 'Form not found' }, 404);
116
+ const context = systemContext(organizationId);
117
+ /*
118
+ * Collections are reached by name, not through the typed `collections` map:
119
+ * that map is generated from the *app's* schemas, and a plugin's own
120
+ * collections aren't in it at compile time. `getCollection` is the accessor
121
+ * built for exactly this — dynamic access from route handlers.
122
+ */
123
+ const forms = aphexCMS.localAPI.getCollection('form');
124
+ const submissions = aphexCMS.localAPI.getCollection('formSubmission');
125
+ if (!forms || !submissions) {
126
+ aphexCMS.logger?.error('[forms] plugin collections are not registered');
127
+ return c.json({ success: false, error: 'Forms are not configured' }, 500);
128
+ }
129
+ // Published only. A form still in draft is not live, and honouring it would let
130
+ // anyone submit against a form the editor hasn't finished.
131
+ const formDoc = await forms
132
+ .findByID(context, formId, { perspective: 'published' })
133
+ .catch(() => null);
134
+ if (!formDoc)
135
+ return c.json({ success: false, error: 'Form not found' }, 404);
136
+ if (formDoc._meta?.status !== 'published') {
137
+ return c.json({ success: false, error: 'Form not found' }, 404);
138
+ }
139
+ if (!formDoc._meta?.publishedHash || formDoc._meta.publishedHash !== version) {
140
+ return c.json({ success: false, error: 'This form changed. Refresh the page and try again.' }, 409);
141
+ }
142
+ const declared = (formDoc.fields ?? []);
143
+ const inputs = declared.filter(isInputField);
144
+ // Keep only what the form declares — see the note above about stuffing.
145
+ const submitted = {};
146
+ for (const field of inputs)
147
+ submitted[field.name] = data[field.name];
148
+ const definition = compileForm({ ...formDoc, id: formId });
149
+ const shaped = definition.transform ? definition.transform(submitted) : submitted;
150
+ const result = await validateFormData(definition, shaped);
151
+ if (!result.isValid) {
152
+ // Field-keyed so the form can show each message against its own input.
153
+ const errors = {};
154
+ for (const entry of result.errors)
155
+ errors[entry.field] = entry.errors[0] ?? 'Invalid';
156
+ return c.json({ success: false, error: 'Please check the form', errors }, 400);
157
+ }
158
+ const answers = inputs.map((field) => ({
159
+ field: field.name,
160
+ value: toStoredValue(result.normalizedData[field.name])
161
+ }));
162
+ // A one-line description for the submissions list, so it's readable without
163
+ // opening every row. The first answer with something in it is nearly always
164
+ // the name or the email.
165
+ const labels = fieldLabels(declared);
166
+ const first = answers.find((a) => a.value);
167
+ const summary = first
168
+ ? `${labels.get(first.field) ?? first.field}: ${first.value}`
169
+ : 'Submission';
170
+ const submissionId = crypto.randomUUID();
171
+ const notificationEmails = (formDoc.emails ?? [])
172
+ .filter((email) => typeof email.to === 'string' && email.to.trim().length > 0)
173
+ .map((email, index) => ({
174
+ _type: 'submissionNotification',
175
+ _key: `notification-${index}`,
176
+ to: email.to?.trim(),
177
+ replyTo: email.replyTo?.trim() || undefined,
178
+ subject: email.subject ?? 'New form submission',
179
+ message: email.message ?? '{{allFields}}'
180
+ }));
181
+ await submissions.create(context, {
182
+ form: { _type: 'reference', _ref: formId },
183
+ summary: summary.slice(0, 200),
184
+ submittedAt: new Date().toISOString(),
185
+ submissionData: answers.map((answer, i) => ({
186
+ _type: 'answer',
187
+ _key: `a-${i}`,
188
+ ...answer
189
+ })),
190
+ notificationEmails,
191
+ notificationFieldLabels: inputs.map((field, index) => ({
192
+ _type: 'submissionFieldLabel',
193
+ _key: `label-${index}`,
194
+ field: field.name,
195
+ label: labels.get(field.name) ?? field.name
196
+ }))
197
+ }, {
198
+ id: submissionId,
199
+ skipVersioning: true,
200
+ outboxEvents: [
201
+ {
202
+ type: formSubmissionCreated.type,
203
+ payload: formSubmissionCreated.parse({ submissionId, formId })
204
+ }
205
+ ]
206
+ });
207
+ return c.json({
208
+ success: true,
209
+ confirmationType: formDoc.confirmationType ?? 'message',
210
+ redirectUrl: formDoc.confirmationType === 'redirect' ? (formDoc.redirectUrl ?? null) : null
211
+ });
212
+ }
213
+ //# sourceMappingURL=submit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submit.js","sourceRoot":"","sources":["../src/lib/submit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ import type { FormFieldValue } from './compile.js';
2
+ /**
3
+ * `{{fieldName}}` substitution for notification emails.
4
+ *
5
+ * Deliberately tiny and deliberately not a template engine. The strings come
6
+ * from an editor, and the values come from the public internet; anything with
7
+ * expressions, property access or partials in it would be a way to reach further
8
+ * than the submission. A name is looked up in a plain map or it is left alone.
9
+ */
10
+ /** One stored answer. */
11
+ export interface Answer {
12
+ field: string;
13
+ value: string;
14
+ }
15
+ /** The whole submission as `Label: value` lines, for the default `{{allFields}}`. */
16
+ export declare function renderAllFields(answers: Answer[], labels: Map<string, string>): string;
17
+ /**
18
+ * Replace `{{token}}` in an editor-authored string.
19
+ *
20
+ * An unknown token is left verbatim rather than blanked: a typo that renders as
21
+ * `{{emial}}` in the email is a bug someone notices and fixes, whereas one that
22
+ * silently renders nothing looks like the visitor left the field empty.
23
+ */
24
+ export declare function renderTemplate(template: string, answers: Answer[], labels: Map<string, string>): string;
25
+ /** Field name → the label an editor gave it, for readable email output. */
26
+ export declare function fieldLabels(fields: FormFieldValue[]): Map<string, string>;
27
+ /**
28
+ * A submitted value as a string, for storage and email.
29
+ *
30
+ * Everything is stored as text on purpose: a submission is a record of what was
31
+ * sent, and it has to stay readable after the form is edited. A checkbox reads
32
+ * "Yes"/"No" rather than "true"/"false" because the audience for a submission is
33
+ * a person reading an inbox.
34
+ */
35
+ export declare function toStoredValue(value: unknown): string;
36
+ //# sourceMappingURL=template.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../src/lib/template.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD;;;;;;;GAOG;AAEH,yBAAyB;AACzB,MAAM,WAAW,MAAM;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACd;AAKD,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAItF;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EAAE,EACjB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GACzB,MAAM,CASR;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAQzE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAIpD"}
@@ -0,0 +1,51 @@
1
+ /** `{{name}}` — a bare field name, nothing else. */
2
+ const TOKEN = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
3
+ /** The whole submission as `Label: value` lines, for the default `{{allFields}}`. */
4
+ export function renderAllFields(answers, labels) {
5
+ return answers
6
+ .map(({ field, value }) => `${labels.get(field) ?? field}: ${value || '—'}`)
7
+ .join('\n');
8
+ }
9
+ /**
10
+ * Replace `{{token}}` in an editor-authored string.
11
+ *
12
+ * An unknown token is left verbatim rather than blanked: a typo that renders as
13
+ * `{{emial}}` in the email is a bug someone notices and fixes, whereas one that
14
+ * silently renders nothing looks like the visitor left the field empty.
15
+ */
16
+ export function renderTemplate(template, answers, labels) {
17
+ const values = new Map(answers.map((a) => [a.field, a.value]));
18
+ const all = renderAllFields(answers, labels);
19
+ return template.replace(TOKEN, (match, name) => {
20
+ if (name === 'allFields')
21
+ return all;
22
+ const value = values.get(name);
23
+ return value === undefined ? match : value;
24
+ });
25
+ }
26
+ /** Field name → the label an editor gave it, for readable email output. */
27
+ export function fieldLabels(fields) {
28
+ const labels = new Map();
29
+ for (const field of fields) {
30
+ if (typeof field.name === 'string' && field.name) {
31
+ labels.set(field.name, field.label ?? field.name);
32
+ }
33
+ }
34
+ return labels;
35
+ }
36
+ /**
37
+ * A submitted value as a string, for storage and email.
38
+ *
39
+ * Everything is stored as text on purpose: a submission is a record of what was
40
+ * sent, and it has to stay readable after the form is edited. A checkbox reads
41
+ * "Yes"/"No" rather than "true"/"false" because the audience for a submission is
42
+ * a person reading an inbox.
43
+ */
44
+ export function toStoredValue(value) {
45
+ if (value === null || value === undefined)
46
+ return '';
47
+ if (typeof value === 'boolean')
48
+ return value ? 'Yes' : 'No';
49
+ return String(value);
50
+ }
51
+ //# sourceMappingURL=template.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template.js","sourceRoot":"","sources":["../src/lib/template.ts"],"names":[],"mappings":"AAiBA,oDAAoD;AACpD,MAAM,KAAK,GAAG,wCAAwC,CAAC;AAEvD,qFAAqF;AACrF,MAAM,UAAU,eAAe,CAAC,OAAiB,EAAE,MAA2B;IAC7E,OAAO,OAAO;SACZ,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,KAAK,KAAK,IAAI,GAAG,EAAE,CAAC;SAC3E,IAAI,CAAC,IAAI,CAAC,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC7B,QAAgB,EAChB,OAAiB,EACjB,MAA2B;IAE3B,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC/D,MAAM,GAAG,GAAG,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAE7C,OAAO,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE;QACtD,IAAI,IAAI,KAAK,WAAW;YAAE,OAAO,GAAG,CAAC;QACrC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC/B,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC;IAC5C,CAAC,CAAC,CAAC;AACJ,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,WAAW,CAAC,MAAwB;IACnD,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC5B,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;YAClD,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC;QACnD,CAAC;IACF,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC3C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IACrD,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5D,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;AACtB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@aphexcms/plugin-forms",
3
+ "version": "0.1.0",
4
+ "description": "Form builder plugin for AphexCMS — editor-composed forms, stored submissions, and email notifications",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/IcelandicIcecream/aphex.git",
12
+ "directory": "plugins/plugin-forms"
13
+ },
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "svelte": "./dist/index.js",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./schema": {
24
+ "types": "./dist/schema.d.ts",
25
+ "svelte": "./dist/schema.js",
26
+ "default": "./dist/schema.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "scripts": {
33
+ "dev": "tsc --watch --preserveWatchOutput",
34
+ "build": "tsc && node ../../scripts/fix-imports.js ./dist",
35
+ "check": "tsc --noEmit",
36
+ "dev:local": "node ../../scripts/swap-package-paths.js ./package.json src lib",
37
+ "prepack": "node ../../scripts/swap-package-paths.js ./package.json dist lib",
38
+ "postpack": "node ../../scripts/swap-package-paths.js ./package.json src lib"
39
+ },
40
+ "keywords": [
41
+ "aphex",
42
+ "aphexcms",
43
+ "cms",
44
+ "plugin",
45
+ "forms",
46
+ "form-builder"
47
+ ],
48
+ "devDependencies": {
49
+ "typescript": "^5.9.3",
50
+ "hono": "^4.6.0"
51
+ },
52
+ "peerDependencies": {
53
+ "@aphexcms/cms-core": "workspace:>=9",
54
+ "zod": "^4.0.0"
55
+ },
56
+ "dependencies": {
57
+ "@lucide/svelte": "^0.554.0"
58
+ }
59
+ }