@crombolo/server 0.0.0-stage → 0.9.1

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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Writing a form definition back to `schema/forms/`. `docs/18-forms.md`.
3
+ *
4
+ * The designer saves a form the way the type editors save a content type: the
5
+ * file is the truth, so the write goes to the file and `onChanged` publishes it
6
+ * to a shared schema store when one is configured. A site whose schema is a
7
+ * read-only directory refuses the save rather than appearing to take it.
8
+ *
9
+ * Validated before it is written, and against the whole set: a form that would
10
+ * fail the boot must not be the thing that is on disk.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import type { SchemaForm } from '@crombolo/core'
16
+ import {
17
+ fileNameFor,
18
+ loadSchemaDirectory,
19
+ parseForm,
20
+ type SchemaProblem,
21
+ validateForms,
22
+ writeForm,
23
+ } from '@crombolo/schema'
24
+
25
+ export interface FormFileOptions {
26
+ schemaDir: string
27
+ /** False on a deployed node with no schema store: the write would not last. */
28
+ writable: boolean
29
+ /** Publishes the directory to a shared store, when there is one. */
30
+ onChanged?: () => Promise<void>
31
+ }
32
+
33
+ export type FormWriteResult =
34
+ | { ok: true; file: string }
35
+ | { ok: false; status: 'readOnly' | 'invalid' | 'notFound'; problems: SchemaProblem[] }
36
+
37
+ const readOnly = (): FormWriteResult => ({
38
+ ok: false,
39
+ status: 'readOnly',
40
+ problems: [
41
+ {
42
+ file: 'schema/forms',
43
+ path: '',
44
+ message:
45
+ 'The schema directory is read-only on this node, so a form cannot be saved. Configure a schema store, or change the file in the repository.',
46
+ },
47
+ ],
48
+ })
49
+
50
+ /** Where a form's file goes: kebab-case of its alias, as the other types are. */
51
+ export const formFileName = (alias: string): string => fileNameFor(alias)
52
+
53
+ /**
54
+ * The designer's JSON as a definition.
55
+ *
56
+ * Coerced loosely and then held to the real gate: the candidate is written to
57
+ * TOML and read back with `parseForm`, which is strict about every key. So the
58
+ * one parser decides what a form may contain, and this only has to get the
59
+ * shape close enough to serialise.
60
+ */
61
+ export function coerceForm(value: unknown): SchemaForm {
62
+ const body = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
63
+ const text = (key: string): string | undefined => {
64
+ const found = body[key]
65
+ return typeof found === 'string' && found.trim() !== '' ? found : undefined
66
+ }
67
+ const flag = (key: string, fallback: boolean): boolean =>
68
+ typeof body[key] === 'boolean' ? (body[key] as boolean) : fallback
69
+ const count = (key: string): number | undefined =>
70
+ typeof body[key] === 'number' && Number.isFinite(body[key]) ? (body[key] as number) : undefined
71
+
72
+ const pages = Array.isArray(body.pages) ? body.pages : []
73
+ return {
74
+ key: text('key'),
75
+ alias: text('alias') ?? '',
76
+ name: text('name') ?? '',
77
+ notes: text('notes'),
78
+ storeEntries: flag('storeEntries', true),
79
+ requiresApproval: flag('requiresApproval', false),
80
+ submitLabel: text('submitLabel'),
81
+ nextLabel: text('nextLabel'),
82
+ previousLabel: text('previousLabel'),
83
+ messageOnSubmit: text('messageOnSubmit'),
84
+ redirectTo: text('redirectTo'),
85
+ theme: text('theme'),
86
+ honeypot: flag('honeypot', true),
87
+ minimumSubmitSeconds: count('minimumSubmitSeconds'),
88
+ maxEntries: count('maxEntries'),
89
+ since: text('since'),
90
+ pages: pages.map((page) => coercePage(page)),
91
+ workflows: (Array.isArray(body.workflows) ? body.workflows : []).map((workflow) =>
92
+ coerceWorkflow(workflow),
93
+ ),
94
+ }
95
+ }
96
+
97
+ const strings = (value: unknown): string[] =>
98
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
99
+
100
+ function coercePage(value: unknown): SchemaForm['pages'][number] {
101
+ const body = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
102
+ return {
103
+ caption: typeof body.caption === 'string' && body.caption ? body.caption : undefined,
104
+ condition: coerceCondition(body.condition),
105
+ groups: (Array.isArray(body.groups) ? body.groups : []).map((group) => coerceGroup(group)),
106
+ }
107
+ }
108
+
109
+ function coerceGroup(value: unknown): SchemaForm['pages'][number]['groups'][number] {
110
+ const body = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
111
+ return {
112
+ caption: typeof body.caption === 'string' && body.caption ? body.caption : undefined,
113
+ columns: typeof body.columns === 'number' ? body.columns : 1,
114
+ condition: coerceCondition(body.condition),
115
+ fields: (Array.isArray(body.fields) ? body.fields : []).map((field) => coerceField(field)),
116
+ }
117
+ }
118
+
119
+ type Field = SchemaForm['pages'][number]['groups'][number]['fields'][number]
120
+
121
+ function coerceField(value: unknown): Field {
122
+ const body = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
123
+ const text = (key: string): string | undefined => {
124
+ const found = body[key]
125
+ return typeof found === 'string' && found.trim() !== '' ? found : undefined
126
+ }
127
+ const count = (key: string): number | undefined =>
128
+ typeof body[key] === 'number' && Number.isFinite(body[key]) ? (body[key] as number) : undefined
129
+ return {
130
+ key: text('key'),
131
+ alias: text('alias') ?? '',
132
+ type: (text('type') ?? '') as Field['type'],
133
+ caption: text('caption') ?? '',
134
+ helpText: text('helpText'),
135
+ mandatory: body.mandatory === true,
136
+ mandatoryMessage: text('mandatoryMessage'),
137
+ pattern: text('pattern'),
138
+ patternMessage: text('patternMessage'),
139
+ sensitive: body.sensitive === true,
140
+ cssClass: text('cssClass'),
141
+ autocomplete: text('autocomplete'),
142
+ values: strings(body.values),
143
+ placeholder: text('placeholder'),
144
+ defaultValue: text('defaultValue'),
145
+ content: text('content'),
146
+ maxlength: count('maxlength'),
147
+ rows: count('rows'),
148
+ min: count('min'),
149
+ max: count('max'),
150
+ step: count('step'),
151
+ accept: strings(body.accept),
152
+ maxSizeKb: count('maxSizeKb'),
153
+ ...(typeof body.multiple === 'boolean' ? { multiple: body.multiple } : {}),
154
+ condition: coerceCondition(body.condition),
155
+ }
156
+ }
157
+
158
+ function coerceCondition(value: unknown): Field['condition'] {
159
+ if (!value || typeof value !== 'object') return undefined
160
+ const body = value as Record<string, unknown>
161
+ const rules = (Array.isArray(body.rule) ? body.rule : Array.isArray(body.rules) ? body.rules : [])
162
+ .filter((rule): rule is Record<string, unknown> => Boolean(rule) && typeof rule === 'object')
163
+ .map((rule) => ({
164
+ field: typeof rule.field === 'string' ? rule.field : '',
165
+ operator: (typeof rule.operator === 'string' ? rule.operator : 'is') as never,
166
+ value: typeof rule.value === 'string' ? rule.value : undefined,
167
+ }))
168
+ if (rules.length === 0) return undefined
169
+ return {
170
+ action: body.action === 'hide' ? 'hide' : 'show',
171
+ match: body.match === 'any' ? 'any' : 'all',
172
+ rules,
173
+ }
174
+ }
175
+
176
+ function coerceWorkflow(value: unknown): SchemaForm['workflows'][number] {
177
+ const body = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
178
+ return {
179
+ key: typeof body.key === 'string' && body.key ? body.key : undefined,
180
+ type: (typeof body.type === 'string' ? body.type : '') as never,
181
+ name: typeof body.name === 'string' ? body.name : '',
182
+ on: body.on === 'approve' ? 'approve' : 'submit',
183
+ settings:
184
+ body.settings && typeof body.settings === 'object' && !Array.isArray(body.settings)
185
+ ? (body.settings as Record<string, unknown>)
186
+ : {},
187
+ }
188
+ }
189
+
190
+ export async function saveForm(
191
+ candidate: SchemaForm,
192
+ options: FormFileOptions,
193
+ ): Promise<FormWriteResult> {
194
+ if (!options.writable) return readOnly()
195
+
196
+ // Written then read back with the strict parser, so the file the designer
197
+ // would produce is held to exactly what a hand-written one is.
198
+ const name = formFileName(candidate.alias || 'form')
199
+ const reparsed = parseForm(`schema/forms/${name}`, writeForm(candidate))
200
+ if (!reparsed.value) return { ok: false, status: 'invalid', problems: reparsed.problems }
201
+ const form = reparsed.value
202
+
203
+ // Against the whole set, because an alias has to be unique across forms and a
204
+ // workflow is held to a content type that exists.
205
+ const loaded = loadSchemaDirectory(options.schemaDir)
206
+ const others = (loaded.set.forms ?? []).filter(
207
+ (candidate) => candidate.alias.toLowerCase() !== form.alias.toLowerCase(),
208
+ )
209
+ const problems = validateForms([...others, form], {
210
+ contentTypeAliases: new Set(loaded.set.contentTypes.map((type) => type.alias)),
211
+ })
212
+ if (problems.length > 0) return { ok: false, status: 'invalid', problems }
213
+
214
+ const dir = join(options.schemaDir, 'forms')
215
+ mkdirSync(dir, { recursive: true })
216
+ const file = join(dir, name)
217
+ writeFileSync(file, writeForm(form), 'utf8')
218
+ await options.onChanged?.()
219
+ return { ok: true, file }
220
+ }
221
+
222
+ export async function deleteForm(
223
+ alias: string,
224
+ options: FormFileOptions,
225
+ ): Promise<FormWriteResult> {
226
+ if (!options.writable) return readOnly()
227
+ const file = join(options.schemaDir, 'forms', formFileName(alias))
228
+ if (!existsSync(file))
229
+ return {
230
+ ok: false,
231
+ status: 'notFound',
232
+ problems: [
233
+ { file: `schema/forms/${formFileName(alias)}`, path: '', message: 'is not there' },
234
+ ],
235
+ }
236
+ // The entries stay: they are the business record, and deleting a definition
237
+ // must not take what people sent it (`docs/18-forms.md`).
238
+ rmSync(file)
239
+ await options.onChanged?.()
240
+ return { ok: true, file }
241
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Taking a form submission. `docs/18-forms.md`.
3
+ *
4
+ * `POST /crombolo/forms/<key>`, outside the Management API because a visitor is
5
+ * not a backoffice user and there is no operation in the vendored contract for
6
+ * this.
7
+ *
8
+ * Two answers, by what the client asked for. A `fetch` caller gets JSON, which
9
+ * is the headless path. A plain browser form gets a **303 back to the page it
10
+ * came from**, so the form reappears inside its own layout with either its
11
+ * errors or its thank-you — which is what makes this work with no JavaScript at
12
+ * all. That state travels in a short-lived signed cookie, because a redirect
13
+ * cannot carry a body.
14
+ *
15
+ * Everything here signs synchronously, with `Bun.CryptoHasher` rather than
16
+ * `crypto.subtle`: a template signs a token while it renders, and a view cannot
17
+ * await.
18
+ */
19
+ import {
20
+ allFormFields,
21
+ DEFAULT_UPLOAD_SETTINGS,
22
+ FORM_HONEYPOT_FIELD,
23
+ FORM_RENDERED_FIELD,
24
+ FORM_RESERVED_FIELDS,
25
+ FORM_TOKEN_FIELD,
26
+ type FormFieldError,
27
+ type FormSubmissionState,
28
+ isUploadAllowed,
29
+ type SchemaForm,
30
+ type SubmittedValues,
31
+ type UploadSettings,
32
+ validateSubmission,
33
+ } from '@crombolo/core'
34
+ import {
35
+ type Db,
36
+ ensureKeyValue,
37
+ FormEntryRepository,
38
+ FormWorkflowRepository,
39
+ } from '@crombolo/data'
40
+ import { loadSchemaDirectory } from '@crombolo/schema'
41
+ import { logger } from './logging.ts'
42
+ import type { MediaFileStore } from './media-files.ts'
43
+
44
+ const log = logger('forms')
45
+
46
+ /** The cookie a refused or accepted submission travels back in. */
47
+ export const FORM_FLASH_COOKIE = 'crombolo-form'
48
+ /** Long enough to survive the redirect, short enough not to linger. */
49
+ const FLASH_SECONDS = 60
50
+ const SIGNING_KEY = 'crombolo/formTokenKey'
51
+ /** A payload larger than this keeps the messages and drops the typed values. */
52
+ const MAX_FLASH_BYTES = 3500
53
+
54
+ // ------------------------------------------------------------------ the definitions
55
+
56
+ export interface FormRegistry {
57
+ all(): readonly SchemaForm[]
58
+ byKey(key: string): SchemaForm | undefined
59
+ /** Drops the memo, for a test or after a schema write. */
60
+ invalidate(): void
61
+ }
62
+
63
+ /**
64
+ * The site's forms, re-read when they might have changed.
65
+ *
66
+ * Memoised for a moment rather than cached until told: form definitions are
67
+ * files, and nothing signals a change to them the way a publish signals a
68
+ * content change. A second of staleness is invisible to somebody editing one,
69
+ * and it bounds the cost of re-parsing `schema/` on a page that renders a form.
70
+ */
71
+ export function createFormRegistry(
72
+ schemaDir: string,
73
+ options: { ttlMs?: number } = {},
74
+ ): FormRegistry {
75
+ const ttl = options.ttlMs ?? 1000
76
+ let at = 0
77
+ let forms: readonly SchemaForm[] = []
78
+ let byKey = new Map<string, SchemaForm>()
79
+
80
+ const refresh = () => {
81
+ if (at !== 0 && Date.now() - at < ttl) return
82
+ try {
83
+ forms = loadSchemaDirectory(schemaDir).set.forms ?? []
84
+ byKey = new Map(forms.map((form) => [form.key ?? form.alias, form]))
85
+ } catch (error) {
86
+ // A broken schema directory fails the boot, so reaching here means it
87
+ // broke afterwards. Keeping the last good set beats rendering every form
88
+ // on the site as missing.
89
+ log.warning('The forms could not be re-read: {detail}', {
90
+ detail: error instanceof Error ? error.message : String(error),
91
+ })
92
+ }
93
+ at = Date.now()
94
+ }
95
+
96
+ return {
97
+ all() {
98
+ refresh()
99
+ return forms
100
+ },
101
+ byKey(key) {
102
+ refresh()
103
+ return byKey.get(key)
104
+ },
105
+ invalidate() {
106
+ at = 0
107
+ },
108
+ }
109
+ }
110
+
111
+ // ------------------------------------------------------------------ signing
112
+
113
+ function hmac(secret: string, payload: string): string {
114
+ const hasher = new Bun.CryptoHasher('sha256', secret)
115
+ hasher.update(payload)
116
+ return hasher.digest('base64url')
117
+ }
118
+
119
+ /** Constant-time comparison of two base64url digests of one size. */
120
+ function sameSignature(a: string, b: string): boolean {
121
+ if (a.length !== b.length) return false
122
+ let difference = 0
123
+ for (let i = 0; i < a.length; i++) difference |= a.charCodeAt(i) ^ b.charCodeAt(i)
124
+ return difference === 0
125
+ }
126
+
127
+ /**
128
+ * Signs the form and the moment it was rendered.
129
+ *
130
+ * This is what makes the timing guard mean anything: without it a bot posts its
131
+ * own `_crombolo_at` and the elapsed time is whatever it claims. It is not a
132
+ * CSRF token — a public form has no session to tie one to — and does not
133
+ * pretend to be one.
134
+ */
135
+ export function signFormToken(secret: string, formKey: string, renderedAt: number): string {
136
+ return hmac(secret, `${formKey}|${renderedAt}`)
137
+ }
138
+
139
+ export function verifyFormToken(
140
+ secret: string,
141
+ formKey: string,
142
+ renderedAt: number,
143
+ token: string,
144
+ ): boolean {
145
+ if (!token) return false
146
+ return sameSignature(signFormToken(secret, formKey, renderedAt), token)
147
+ }
148
+
149
+ /** The per-site signing key, created once and kept so it survives a restart. */
150
+ export async function formSigningSecret(db: Db): Promise<string> {
151
+ return ensureKeyValue(db, SIGNING_KEY, () => crypto.randomUUID())
152
+ }
153
+
154
+ // ------------------------------------------------------------------ the flash cookie
155
+
156
+ interface Flash extends FormSubmissionState {
157
+ /** When it was written, so a stale cookie is ignored rather than replayed. */
158
+ at: number
159
+ }
160
+
161
+ export function writeFlash(secret: string, state: FormSubmissionState): string {
162
+ const payload: Flash = { ...state, at: Date.now() }
163
+ let json = JSON.stringify(payload)
164
+ if (json.length > MAX_FLASH_BYTES)
165
+ // The messages matter more than redisplaying what was typed, and a cookie
166
+ // the browser silently drops would lose both.
167
+ json = JSON.stringify({ ...payload, values: {} })
168
+ const body = Buffer.from(json).toString('base64url')
169
+ return `${body}.${hmac(secret, body)}`
170
+ }
171
+
172
+ export function readFlash(
173
+ secret: string,
174
+ cookie: string | undefined,
175
+ ): FormSubmissionState | undefined {
176
+ if (!cookie) return undefined
177
+ const [body, signature] = cookie.split('.')
178
+ if (!body || !signature) return undefined
179
+ if (!sameSignature(hmac(secret, body), signature)) return undefined
180
+ try {
181
+ const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Flash
182
+ if (Date.now() - parsed.at > FLASH_SECONDS * 1000) return undefined
183
+ return {
184
+ formKey: parsed.formKey,
185
+ formAlias: parsed.formAlias,
186
+ values: parsed.values ?? {},
187
+ errors: parsed.errors ?? [],
188
+ message: parsed.message,
189
+ }
190
+ } catch {
191
+ return undefined
192
+ }
193
+ }
194
+
195
+ // ------------------------------------------------------------------ the submission
196
+
197
+ export interface FormSubmitDeps {
198
+ db: Db
199
+ forms: FormRegistry
200
+ mediaFiles: MediaFileStore
201
+ /**
202
+ * What an upload may be, as the media library has it. Both lists matter: the
203
+ * deny list keeps an `.aspx` out, and `allowedExtensions` is empty by
204
+ * default — so checking only that would refuse everything.
205
+ */
206
+ uploads?: UploadSettings
207
+ /** Resolves a document key to the URL to send the visitor to. */
208
+ urlOf?: (key: string) => Promise<string | undefined>
209
+ }
210
+
211
+ export type FormSubmitOutcome =
212
+ | { status: 'notFound' }
213
+ | { status: 'badToken' }
214
+ | { status: 'full'; form: SchemaForm }
215
+ | { status: 'invalid'; form: SchemaForm; state: FormSubmissionState }
216
+ | {
217
+ status: 'ok'
218
+ form: SchemaForm
219
+ entryId: string | undefined
220
+ spam: boolean
221
+ /** How many workflows were queued to run after this. */
222
+ queued: number
223
+ state: FormSubmissionState
224
+ /** Where to send a browser, when the form names a page. */
225
+ redirectTo: string | undefined
226
+ }
227
+
228
+ /** A salted hash, never an address: enough to rate-limit, not a liability. */
229
+ function hashIp(secret: string, ip: string | undefined): string | null {
230
+ if (!ip) return null
231
+ return hmac(secret, `ip|${ip}`).slice(0, 32)
232
+ }
233
+
234
+ /** The posted body as field alias to values, with the machinery left out. */
235
+ function readBody(data: FormData): SubmittedValues {
236
+ const out: SubmittedValues = {}
237
+ for (const [name, value] of data.entries()) {
238
+ if (FORM_RESERVED_FIELDS.includes(name)) continue
239
+ if (typeof value !== 'string') continue
240
+ const values = out[name] ?? []
241
+ values.push(value)
242
+ out[name] = values
243
+ }
244
+ return out
245
+ }
246
+
247
+ const extensionOf = (fileName: string): string => {
248
+ const dot = fileName.lastIndexOf('.')
249
+ return dot >= 0 ? fileName.slice(dot).toLowerCase() : ''
250
+ }
251
+
252
+ export async function submitForm(
253
+ deps: FormSubmitDeps,
254
+ request: Request,
255
+ formKey: string,
256
+ options: { ip?: string; now?: number } = {},
257
+ ): Promise<FormSubmitOutcome> {
258
+ const form = deps.forms.byKey(formKey)
259
+ if (!form) return { status: 'notFound' }
260
+
261
+ const secret = await formSigningSecret(deps.db)
262
+ const data = await request.formData()
263
+ const now = options.now ?? Date.now()
264
+
265
+ const renderedAt = Number(data.get(FORM_RENDERED_FIELD) ?? 0)
266
+ const token = String(data.get(FORM_TOKEN_FIELD) ?? '')
267
+ // Always required: the renderer puts a token in every form it draws, so a
268
+ // submission without a good one did not come from a page this site rendered.
269
+ if (!verifyFormToken(secret, formKey, renderedAt, token)) return { status: 'badToken' }
270
+
271
+ // Two guards, both silent. A bot is told the submission worked, because
272
+ // telling it otherwise is how it learns to get past them.
273
+ const honeypot = String(data.get(FORM_HONEYPOT_FIELD) ?? '').trim() !== ''
274
+ const tooFast =
275
+ form.minimumSubmitSeconds !== undefined &&
276
+ renderedAt > 0 &&
277
+ now - renderedAt < form.minimumSubmitSeconds * 1000
278
+ const spam = honeypot || tooFast
279
+
280
+ const entries = new FormEntryRepository(deps.db)
281
+ const key = form.key ?? form.alias
282
+ if (form.maxEntries !== undefined && (await entries.count(key)) >= form.maxEntries)
283
+ return { status: 'full', form }
284
+
285
+ const submitted = readBody(data)
286
+
287
+ // Uploads take the same two steps a media upload takes, so the safe naming,
288
+ // the type sniffing and the extension rules are the ones already tested.
289
+ const refusedUploads: FormFieldError[] = []
290
+ const uploads = allFormFields(form).filter((field) => field.type === 'fileUpload')
291
+ for (const field of uploads) {
292
+ const files = data.getAll(field.alias).filter((value): value is File => value instanceof File)
293
+ const placed: string[] = []
294
+ const name = field.caption || field.alias
295
+ for (const file of files) {
296
+ if (file.size === 0) continue
297
+ // Refused before it is written anywhere.
298
+ if (!isUploadAllowed(file.name, deps.uploads ?? DEFAULT_UPLOAD_SETTINGS)) {
299
+ refusedUploads.push({
300
+ field: field.alias,
301
+ message: `${name} cannot be a ${extensionOf(file.name)} file`,
302
+ })
303
+ continue
304
+ }
305
+ if (field.maxSizeKb !== undefined && file.size > field.maxSizeKb * 1024) {
306
+ refusedUploads.push({
307
+ field: field.alias,
308
+ message: `${name} must be smaller than ${field.maxSizeKb}KB`,
309
+ })
310
+ continue
311
+ }
312
+ const id = crypto.randomUUID()
313
+ await deps.mediaFiles.saveTemporary(id, file)
314
+ const result = await deps.mediaFiles.place(id)
315
+ if (result) placed.push(result.src)
316
+ }
317
+ if (placed.length > 0) submitted[field.alias] = placed
318
+ }
319
+
320
+ const result = validateSubmission(form, submitted)
321
+ // What the file rules refused, which core cannot know: it has no notion of a
322
+ // file's bytes or of what a server could be made to execute.
323
+ result.errors.push(...refusedUploads)
324
+
325
+ if (result.errors.length > 0)
326
+ return {
327
+ status: 'invalid',
328
+ form,
329
+ state: { formKey: key, formAlias: form.alias, values: submitted, errors: result.errors },
330
+ }
331
+
332
+ let entryId: string | undefined
333
+ if (form.storeEntries) {
334
+ const entry = await entries.create({
335
+ formKey: key,
336
+ formAlias: form.alias,
337
+ culture: null,
338
+ pageKey: null,
339
+ ipHash: hashIp(secret, options.ip),
340
+ userAgent: request.headers.get('user-agent')?.slice(0, 500) ?? null,
341
+ spam,
342
+ values: result.values,
343
+ // Approval holds an entry at `submitted` until somebody moves it; without
344
+ // it the entry is final the moment it arrives.
345
+ state: 'submitted',
346
+ })
347
+ entryId = entry.id
348
+ }
349
+ // Queued, not run: the entry is stored, and a workflow that fails must not
350
+ // take the request with it. The job timer drains the queue.
351
+ //
352
+ // Spam is the exception — a workflow is an outbound effect, and firing one for
353
+ // a submission already judged to be a bot is how a form becomes a relay.
354
+ const triggered = spam ? [] : form.workflows.filter((workflow) => workflow.on === 'submit')
355
+ if (triggered.length > 0)
356
+ await new FormWorkflowRepository(deps.db).enqueue(
357
+ triggered.map((workflow) => ({
358
+ formKey: key,
359
+ formAlias: form.alias,
360
+ workflowName: workflow.name,
361
+ workflowType: workflow.type,
362
+ runOn: workflow.on,
363
+ entryId: entryId ?? null,
364
+ payload: result.values,
365
+ })),
366
+ )
367
+
368
+ log.info('{form} received a submission{spam}', {
369
+ form: form.alias,
370
+ spam: spam ? ' (marked as spam)' : '',
371
+ })
372
+
373
+ const redirectTo = form.redirectTo ? await deps.urlOf?.(form.redirectTo) : undefined
374
+ return {
375
+ status: 'ok',
376
+ form,
377
+ entryId,
378
+ spam,
379
+ queued: triggered.length,
380
+ redirectTo,
381
+ state: {
382
+ formKey: key,
383
+ formAlias: form.alias,
384
+ values: {},
385
+ errors: [],
386
+ message: form.messageOnSubmit ?? 'Thank you.',
387
+ },
388
+ }
389
+ }
390
+
391
+ /** Whether the caller wants JSON rather than a redirect back to the page. */
392
+ export function wantsJson(request: Request): boolean {
393
+ if (request.headers.get('x-requested-with') === 'fetch') return true
394
+ const accept = request.headers.get('accept') ?? ''
395
+ // `text/html` anywhere means a browser navigation, whatever else it will take.
396
+ return accept.includes('application/json') && !accept.includes('text/html')
397
+ }