/** * Public form submission handler. * * This is the main entry point for form submissions from anonymous visitors. * Handles spam protection, validation, file uploads, notifications, and webhooks. */ import type { RouteContext, StorageCollection } from "emdash"; import { PluginRouteError } from "emdash"; import { ulid } from "ulidx"; import { formatSubmissionText, formatWebhookPayload } from "../format.js"; import type { SubmitInput } from "../schemas.js"; import { verifyTurnstile } from "../turnstile.js"; import type { FormDefinition, Submission, SubmissionFile } from "../types.js"; import { getFormFields } from "../types.js"; import { validateSubmission } from "../validation.js"; /** Typed access to plugin storage collections */ function forms(ctx: RouteContext): StorageCollection { return ctx.storage.forms as StorageCollection; } function submissions(ctx: RouteContext): StorageCollection { return ctx.storage.submissions as StorageCollection; } export async function submitHandler(ctx: RouteContext) { const input = ctx.input; // 1. Load form definition (by ID first, then by slug) let formId = input.formId; let form = await forms(ctx).get(formId); if (!form) { const bySlug = await forms(ctx).query({ where: { slug: input.formId }, limit: 1, }); if (bySlug.items.length > 0) { formId = bySlug.items[0]!.id; form = bySlug.items[0]!.data; } } if (!form) { throw PluginRouteError.notFound("Form not found"); } if (form.status === "paused") { throw new PluginRouteError( "FORM_PAUSED", "This form is not currently accepting submissions", 410, ); } const settings = form.settings; // 2. Spam protection if (settings.spamProtection === "turnstile") { const token = input.data["cf-turnstile-response"]; if (typeof token !== "string" || !token) { throw PluginRouteError.forbidden("Spam verification required"); } const secretKey = await ctx.kv.get("settings:turnstileSecretKey"); if (!secretKey || !ctx.http) { throw PluginRouteError.internal("Turnstile is not configured"); } const result = await verifyTurnstile( token, secretKey, ctx.http.fetch.bind(ctx.http), ctx.requestMeta.ip, ); if (!result.success) { ctx.log.warn("Turnstile verification failed", { errorCodes: result.errorCodes, }); throw PluginRouteError.forbidden("Spam verification failed. Please try again."); } } if (settings.spamProtection === "honeypot") { if (input.data._hp) { // Honeypot triggered — return success silently return { success: true, message: settings.confirmationMessage, }; } } // 3. Validate submission data const allFields = getFormFields(form); const result = validateSubmission(allFields, input.data); if (!result.valid) { throw PluginRouteError.badRequest("Validation failed", { errors: result.errors }); } // 4. Upload files const files: SubmissionFile[] = []; if (input.files && ctx.media && "upload" in ctx.media) { const mediaWithWrite = ctx.media as { upload( filename: string, contentType: string, bytes: ArrayBuffer, ): Promise<{ mediaId: string; storageKey: string; url: string }>; }; for (const field of allFields.filter((f) => f.type === "file")) { const fileData = input.files[field.name]; if (!fileData) continue; // Validate file type if (field.validation?.accept) { const allowed = field.validation.accept.split(",").map((s) => s.trim().toLowerCase()); const ext = `.${fileData.filename.split(".").pop()?.toLowerCase()}`; const typeMatch = allowed.some( (a) => a === ext || a === fileData.contentType || fileData.contentType.startsWith(a.replace("/*", "/")), ); if (!typeMatch) { throw PluginRouteError.badRequest(`File type not allowed for ${field.label}`); } } // Validate file size if ( field.validation?.maxFileSize && fileData.bytes.byteLength > field.validation.maxFileSize ) { throw PluginRouteError.badRequest( `File too large for ${field.label}. Maximum: ${Math.round(field.validation.maxFileSize / 1024)} KB`, ); } const uploaded = await mediaWithWrite.upload( fileData.filename, fileData.contentType, fileData.bytes, ); files.push({ fieldName: field.name, filename: fileData.filename, contentType: fileData.contentType, size: fileData.bytes.byteLength, mediaId: uploaded.mediaId, }); } } // 5. Store submission const submissionId = ulid(); const submission: Submission = { formId, data: result.data, files: files.length > 0 ? files : undefined, status: "new", starred: false, createdAt: new Date().toISOString(), meta: { ip: ctx.requestMeta.ip, userAgent: ctx.requestMeta.userAgent, referer: ctx.requestMeta.referer, country: ctx.requestMeta.geo?.country ?? null, }, }; await submissions(ctx).put(submissionId, submission); // 6. Update form counters (use count() to avoid race conditions // from concurrent submissions doing read-modify-write) const submissionCount = await submissions(ctx).count({ formId }); await forms(ctx).put(formId, { ...form, submissionCount, lastSubmissionAt: new Date().toISOString(), }); // 7. Immediate email notifications (not digest) if (settings.notifyEmails.length > 0 && !settings.digestEnabled && ctx.email) { const text = formatSubmissionText(form, result.data, files); for (const email of settings.notifyEmails) { await ctx.email .send({ to: email, subject: `New submission: ${form.name}`, text, }) .catch((err: unknown) => { ctx.log.error("Failed to send notification email", { error: String(err), to: email, }); }); } } // 8. Autoresponder if (settings.autoresponder && ctx.email) { const emailField = allFields.find((f) => f.type === "email"); const submitterEmail = emailField ? result.data[emailField.name] : null; if (typeof submitterEmail === "string" && submitterEmail) { await ctx.email .send({ to: submitterEmail, subject: settings.autoresponder.subject, text: settings.autoresponder.body, }) .catch((err: unknown) => { ctx.log.error("Failed to send autoresponder", { error: String(err) }); }); } } // 9. Webhook (fire and forget) if (settings.webhookUrl && ctx.http) { const payload = formatWebhookPayload(form, submissionId, result.data, files); ctx.http .fetch(settings.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) .catch((err: unknown) => { ctx.log.error("Webhook failed", { error: String(err), url: settings.webhookUrl, }); }); } // 10. Return success return { success: true, message: settings.confirmationMessage, redirect: settings.redirectUrl, }; } // ─── Public Form Definition Endpoint ───────────────────────────── export async function definitionHandler( ctx: RouteContext, ) { const { id } = ctx.input; // Look up by ID first, then by slug let form = await forms(ctx).get(id); if (!form) { const bySlug = await forms(ctx).query({ where: { slug: id }, limit: 1, }); if (bySlug.items.length > 0) { form = bySlug.items[0]!.data; } } if (!form) { throw PluginRouteError.notFound("Form not found"); } if (form.status !== "active") { throw new PluginRouteError("FORM_PAUSED", "This form is not currently available", 410); } // Include Turnstile site key if configured const turnstileSiteKey = form.settings.spamProtection === "turnstile" ? await ctx.kv.get("settings:turnstileSiteKey") : null; // Return only the settings needed for client rendering — never expose // admin emails, webhook URLs, or other internal configuration. return { name: form.name, slug: form.slug, pages: form.pages, settings: { spamProtection: form.settings.spamProtection, submitLabel: form.settings.submitLabel, nextLabel: form.settings.nextLabel, prevLabel: form.settings.prevLabel, }, status: form.status, _turnstileSiteKey: turnstileSiteKey, }; }