mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(forms): M3b public-submit endpoint — schließt den Public-Loop
Server-side Public-Submit für unlisted-shared Forms (Plan
docs/plans/forms-module.md M3.b):
- POST /api/v1/forms/public/:token/submit (apps/api):
- Token-resolve via unlistedSnapshots-Tabelle (eq, limit 1).
- Hard-blocks: 404 unbekannt, 410 revoked/expired, 400 wrong
collection, 400 invalid JSON.
- Schema-validiert serverseitig: filtert eingehende answers auf
field-IDs aus dem Snapshot (anti-injection), prüft required
Antwort-Felder + required consent-Felder.
- Hashed IP (SHA-256, hex) als Anti-Spam-Fingerprint, plus
User-Agent + Referer truncated, in submitterMeta.
- Schreibt sync_changes(table='formResponses', op='insert', data,
field_meta, actor='system:forms-public-submit', origin='system')
in einer Transaktion mit set_config('app.current_user_id') für
RLS — mirror vom articles import-extractor.
- Token-scoped rate-limit (10/min) + IP-scoped (30/min), gleiche
Architektur wie unlisted/public-routes.
- Returns { ok: true, responseId, submittedAt }.
- SharedFormView (apps/mana/apps/web): handleSubmit POSTet jetzt an
${PUBLIC_MANA_API_URL || origin:3060}/api/v1/forms/public/:token/submit.
Submitting-State (Disabled-Button + "Sende ..."), Error-Block bei
Server-Fehlern, Submitter-Block (Name + Email, beide optional). Der
DEV-Hinweis ist weg.
Encryption: server speichert plaintext im sync_changes-Blob. Der
Client-side Decrypt-Path ist no-op für non-encrypted shapes
(record-helpers.ts:241), also kein Crash beim Pull. Encrypted-at-rest
für public submissions ist M6 ZK-Mode (eigener per-Form-Key der
Form-Owner client-seitig hält).
Mounted pre-auth in apps/api/src/index.ts neben unlisted/public.
apps/api buildet (1769 modules, no TS errors). svelte-check:
0 errors in forms/. Forms-Modul ist End-to-End nutzbar — User legt
Form an, publisht, setzt visibility=unlisted, kopiert Share-Link,
externe Person füllt aus + sendet, Antwort landet im
ResponsesView des Owners.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d85d7c36b
commit
e99fea1938
3 changed files with 363 additions and 26 deletions
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
258
apps/api/src/modules/forms/public-routes.ts
Normal file
258
apps/api/src/modules/forms/public-routes.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {};
|
||||
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<string> {
|
||||
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;
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
let {
|
||||
blob,
|
||||
token: _token,
|
||||
token,
|
||||
expiresAt,
|
||||
}: {
|
||||
blob: Record<string, unknown>;
|
||||
|
|
@ -33,7 +33,26 @@
|
|||
const form = $derived(blob as unknown as FormBlob);
|
||||
|
||||
let answers = $state<Record<string, AnswerValue>>({});
|
||||
let submitterEmail = $state('');
|
||||
let submitterName = $state('');
|
||||
let submitted = $state(false);
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<string | null>(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<string, string | undefined>;
|
||||
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/<token>/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}
|
||||
<div class="thanks">
|
||||
<p class="thanks-message">{form.settings.successMessage}</p>
|
||||
<p class="dev-note">
|
||||
<em
|
||||
>Hinweis: Public-Submit ist im aktuellen Mana-Build noch nicht serverseitig verdrahtet —
|
||||
deine Antwort wurde nicht übermittelt.</em
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="form-body" onsubmit={handleSubmit}>
|
||||
|
|
@ -233,16 +274,34 @@
|
|||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="submitter-block">
|
||||
<label class="field-label">
|
||||
Dein Name <span class="optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={submitterName}
|
||||
oninput={(e) => (submitterName = (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="Anna Mustermann"
|
||||
/>
|
||||
<label class="field-label">
|
||||
Deine E-Mail <span class="optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={submitterEmail}
|
||||
oninput={(e) => (submitterEmail = (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="anna@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="form-footer">
|
||||
<button type="submit" class="submit" disabled={!allRequiredFilled}>
|
||||
{form.settings.submitButtonLabel}
|
||||
<button type="submit" class="submit" disabled={!allRequiredFilled || submitting}>
|
||||
{submitting ? 'Sende ...' : form.settings.submitButtonLabel}
|
||||
</button>
|
||||
<p class="dev-note">
|
||||
<em
|
||||
>Public-Submit landet im nächsten Mana-Schritt — vorerst zeigt der Klick nur die
|
||||
Bestätigungsnachricht.</em
|
||||
>
|
||||
</p>
|
||||
{#if submitError}
|
||||
<p class="error">{submitError}</p>
|
||||
{/if}
|
||||
{#if expiresAt}
|
||||
<p class="expiry">Dieser Link läuft am {fmtExpiry(expiresAt)} ab.</p>
|
||||
{/if}
|
||||
|
|
@ -434,18 +493,36 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dev-note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.expiry {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.submitter-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.optional {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.thanks {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue