diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 28d268591..ae3a78028 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -52,6 +52,7 @@ import { websiteRoutes } from './modules/website/routes'; import { websitePublicRoutes } from './modules/website/public-routes'; import { unlistedRoutes } from './modules/unlisted/routes'; import { unlistedPublicRoutes } from './modules/unlisted/public-routes'; +import { formsPublicRoutes } from './modules/forms/public-routes'; import { wetterRoutes } from './modules/wetter/routes'; const PORT = parseInt(process.env.PORT || '3060', 10); @@ -81,6 +82,7 @@ app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 })); app.route('/api/v1/wetter', wetterRoutes); app.route('/api/v1/website/public', websitePublicRoutes); app.route('/api/v1/unlisted/public', unlistedPublicRoutes); +app.route('/api/v1/forms/public', formsPublicRoutes); app.use('/api/*', authMiddleware()); diff --git a/apps/api/src/modules/forms/public-routes.ts b/apps/api/src/modules/forms/public-routes.ts new file mode 100644 index 000000000..a79770aac --- /dev/null +++ b/apps/api/src/modules/forms/public-routes.ts @@ -0,0 +1,258 @@ +/** + * Forms — public-submit endpoint. + * + * POST /:token/submit — accepts an answer payload from an + * unauthenticated submitter, resolves the + * form-owner via the unlisted-snapshots table, + * and writes the response into mana_sync as + * a fresh `formResponses` insert. The owner's + * client picks it up on the next sync pull. + * + * Mounted pre-auth in index.ts. Rate-limited per-token + per-IP, same + * shape as the unlisted public read endpoint. + * + * Security model: + * - The token is a 32-char base64url string from `unlistedSnapshots`. + * Unknown / expired / revoked tokens → 410/404, no information leak. + * - Server-side validation against the snapshot's `fields` array + * ensures the submitter can't inject arbitrary keys into the + * response — only field-ids that exist in the published schema. + * - The blob's `settings.responseLimit`, `closedAt`, etc. are + * intentionally NOT in the public snapshot (see resolvers.ts) so + * enforcement of those happens client-side / in M-future. + * - Server stores plaintext `answers` blob in sync_changes; the + * webapp's encrypt-registry decrypt path is no-op for non-encrypted + * shapes (record-helpers.ts:241). Encrypted-at-rest for public + * submissions is M6 (ZK-Mode) future work. + * + * Plan: docs/plans/forms-module.md M3.b. + */ + +import { Hono } from 'hono'; +import { rateLimitMiddleware } from '@mana/shared-hono'; +import { eq } from 'drizzle-orm'; +import { makeFieldMeta, type Actor, type FieldOrigin } from '@mana/shared-ai'; +import { errorResponse } from '../../lib/responses'; +import { getSyncConnection } from '../../mcp/sync-db'; +import { db, snapshots } from '../unlisted/schema'; + +const routes = new Hono(); + +const TOKEN_REGEX = /^[A-Za-z0-9_-]{32}$/; +const CLIENT_ID = 'forms-public-submit'; + +const SUBMITTER_ACTOR: Actor = Object.freeze({ + kind: 'system' as const, + principalId: 'system:forms-public-submit', + displayName: 'Form-Antwort', +}); +const SUBMITTER_ORIGIN: FieldOrigin = 'system'; + +// Token-scoped rate limit. 10 submits/min per token covers a busy +// signup form without enabling spam — typeform-class form-spam from a +// single tab is the realistic abuse pattern. +routes.use( + '/:token/submit', + rateLimitMiddleware({ + max: 10, + windowMs: 60_000, + keyFn: (c) => `forms:submit:token:${c.req.param('token')}`, + }) +); + +// IP-scoped rate limit. Stacks on the token limit. +routes.use( + '/:token/submit', + rateLimitMiddleware({ + max: 30, + windowMs: 60_000, + keyFn: (c) => { + const ip = + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || + c.req.header('x-real-ip') || + 'unknown'; + return `forms:submit:ip:${ip}`; + }, + }) +); + +interface FormSnapshotBlob { + title?: string; + description?: string | null; + fields?: Array<{ + id: string; + type: string; + label?: string; + required?: boolean; + options?: Array<{ id: string; label: string }>; + }>; + branching?: unknown[]; + settings?: { submitButtonLabel?: string; successMessage?: string }; +} + +interface SubmitBody { + answers?: Record; + submitterEmail?: string | null; + submitterName?: string | null; +} + +routes.post('/:token/submit', async (c) => { + const token = c.req.param('token'); + if (!TOKEN_REGEX.test(token)) { + return errorResponse(c, 'Invalid token format', 400, { code: 'INVALID_TOKEN' }); + } + + const rows = await db + .select({ + token: snapshots.token, + userId: snapshots.userId, + spaceId: snapshots.spaceId, + collection: snapshots.collection, + recordId: snapshots.recordId, + blob: snapshots.blob, + expiresAt: snapshots.expiresAt, + revokedAt: snapshots.revokedAt, + }) + .from(snapshots) + .where(eq(snapshots.token, token)) + .limit(1); + + const row = rows[0]; + if (!row) { + return errorResponse(c, 'Link nicht gefunden', 404, { code: 'NOT_FOUND' }); + } + if (row.collection !== 'forms') { + return errorResponse(c, 'Link gehoert nicht zu einem Formular', 400, { + code: 'WRONG_COLLECTION', + }); + } + if (row.revokedAt) { + return errorResponse(c, 'Link wurde widerrufen', 410, { code: 'REVOKED' }); + } + if (row.expiresAt && row.expiresAt.getTime() < Date.now()) { + return errorResponse(c, 'Link ist abgelaufen', 410, { code: 'EXPIRED' }); + } + + let body: SubmitBody; + try { + body = (await c.req.json()) as SubmitBody; + } catch { + return errorResponse(c, 'Body muss valid JSON sein', 400, { code: 'INVALID_JSON' }); + } + + const blob = (row.blob ?? {}) as FormSnapshotBlob; + const fields = Array.isArray(blob.fields) ? blob.fields : []; + const validFieldIds = new Set( + fields.filter((f) => f && typeof f.id === 'string' && f.type !== 'section').map((f) => f.id) + ); + + // Filter answers to only keys that exist in the published schema. + const cleanAnswers: Record = {}; + const incomingAnswers = body.answers ?? {}; + for (const [key, value] of Object.entries(incomingAnswers)) { + if (validFieldIds.has(key)) { + cleanAnswers[key] = value; + } + } + + // Required-field check — same constraint the client enforces, but + // authoritative on the server. + for (const field of fields) { + if (field.required && field.type !== 'section' && field.type !== 'consent') { + const v = cleanAnswers[field.id]; + if ( + v === null || + v === undefined || + (typeof v === 'string' && v.trim().length === 0) || + (Array.isArray(v) && v.length === 0) + ) { + return errorResponse(c, `Feld "${field.label ?? field.id}" ist erforderlich`, 400, { + code: 'REQUIRED_MISSING', + field: field.id, + }); + } + } + // Consent fields: must be true if required. + if (field.required && field.type === 'consent') { + if (cleanAnswers[field.id] !== true) { + return errorResponse(c, `Einwilligung "${field.label ?? field.id}" ist erforderlich`, 400, { + code: 'CONSENT_REQUIRED', + field: field.id, + }); + } + } + } + + const submitterEmail = + typeof body.submitterEmail === 'string' && body.submitterEmail.trim().length > 0 + ? body.submitterEmail.trim() + : null; + const submitterName = + typeof body.submitterName === 'string' && body.submitterName.trim().length > 0 + ? body.submitterName.trim() + : null; + + // Hash the IP rather than store raw — privacy-preserving anti-abuse + // fingerprint. Owner can spot patterns ("5 submissions from one + // hash in an hour → spam") without ever seeing the plaintext IP. + const ip = + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip') || ''; + const ipHash = ip ? await sha256Hex(ip) : undefined; + const userAgent = c.req.header('user-agent') ?? undefined; + const referrer = c.req.header('referer') ?? undefined; + + const responseId = crypto.randomUUID(); + const submittedAt = new Date().toISOString(); + + const data: Record = { + id: responseId, + formId: row.recordId, + submittedAt, + answers: cleanAnswers, + status: 'new', + spaceId: row.spaceId, + }; + if (submitterEmail) data.submitterEmail = submitterEmail; + if (submitterName) data.submitterName = submitterName; + if (ipHash || userAgent || referrer) { + data.submitterMeta = { + ...(ipHash ? { ipHash } : {}), + ...(userAgent ? { userAgent: userAgent.slice(0, 200) } : {}), + ...(referrer ? { referrer: referrer.slice(0, 500) } : {}), + }; + } + + const fieldMeta: Record = {}; + for (const key of Object.keys(data)) { + fieldMeta[key] = makeFieldMeta(submittedAt, SUBMITTER_ACTOR, SUBMITTER_ORIGIN); + } + + const sql = getSyncConnection(); + await sql.begin(async (tx) => { + await tx`SELECT set_config('app.current_user_id', ${row.userId}, true)`; + await tx` + INSERT INTO sync_changes + (app_id, table_name, record_id, user_id, op, data, field_meta, client_id, schema_version, actor, origin) + VALUES + ('forms', 'formResponses', ${responseId}, ${row.userId}, 'insert', + ${tx.json(data as never)}, ${tx.json(fieldMeta as never)}, + ${CLIENT_ID}, 1, ${tx.json(SUBMITTER_ACTOR as never)}, ${SUBMITTER_ORIGIN}) + `; + }); + + return c.json({ + ok: true, + responseId, + submittedAt, + }); +}); + +async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export const formsPublicRoutes = routes; diff --git a/apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte b/apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte index 895419710..2c93b146e 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte @@ -22,7 +22,7 @@ let { blob, - token: _token, + token, expiresAt, }: { blob: Record; @@ -33,7 +33,26 @@ const form = $derived(blob as unknown as FormBlob); let answers = $state>({}); + let submitterEmail = $state(''); + let submitterName = $state(''); let submitted = $state(false); + let submitting = $state(false); + let submitError = $state(null); + + function apiBaseUrl(): string { + // Public-form view is served from the same SvelteKit origin as + // the Mana webapp, but mana-api is a separate service. The + // PUBLIC_MANA_API_URL build-var carries the absolute URL in + // production; for local dev fallback to the conventional + // :3060 origin used by apps/api. + const env = import.meta.env as Record; + const fromEnv = env.PUBLIC_MANA_API_URL; + if (fromEnv) return fromEnv.replace(/\/$/, ''); + if (typeof window !== 'undefined') { + return window.location.origin.replace(/:5173/, ':3060'); + } + return 'http://localhost:3060'; + } const visibleFields = $derived(resolveVisibleFields(form.fields, form.branching ?? [], answers)); @@ -69,12 +88,40 @@ const allRequiredFilled = $derived(visibleFields.filter(isFieldRequired).every(isFieldFilled)); - function handleSubmit(e: SubmitEvent) { + async function handleSubmit(e: SubmitEvent) { e.preventDefault(); - // Public-Submit endpoint not yet wired (M3.b). Once it lands, this - // posts answers to /api/v1/forms/public//submit and shows - // the success message. - submitted = true; + if (submitting) return; + submitting = true; + submitError = null; + + try { + const url = `${apiBaseUrl()}/api/v1/forms/public/${encodeURIComponent(token)}/submit`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + answers, + submitterEmail: submitterEmail.trim() || null, + submitterName: submitterName.trim() || null, + }), + }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + let msg: string | undefined; + try { + msg = JSON.parse(txt)?.message; + } catch { + msg = txt; + } + submitError = msg || `Übertragung fehlgeschlagen (${res.status})`; + return; + } + submitted = true; + } catch (err) { + submitError = err instanceof Error ? err.message : 'Verbindung zum Server fehlgeschlagen.'; + } finally { + submitting = false; + } } function fmtExpiry(iso: string | null): string { @@ -98,12 +145,6 @@ {#if submitted}

{form.settings.successMessage}

-

- Hinweis: Public-Submit ist im aktuellen Mana-Build noch nicht serverseitig verdrahtet — - deine Antwort wurde nicht übermittelt. -

{:else}
@@ -233,16 +274,34 @@ {/each} +
+ + (submitterName = (e.currentTarget as HTMLInputElement).value)} + placeholder="Anna Mustermann" + /> + + (submitterEmail = (e.currentTarget as HTMLInputElement).value)} + placeholder="anna@example.com" + /> +
+