diff --git a/apps/api/drizzle/website/0001_submissions.sql b/apps/api/drizzle/website/0001_submissions.sql new file mode 100644 index 000000000..c07c927c3 --- /dev/null +++ b/apps/api/drizzle/website/0001_submissions.sql @@ -0,0 +1,23 @@ +-- Website module — M4 form submissions. +-- See docs/plans/website-builder.md §M4 + §D8. +-- +-- Apply with: +-- psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql + +CREATE TABLE IF NOT EXISTS "website"."submissions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "site_id" uuid NOT NULL, + "block_id" uuid NOT NULL, + "payload" jsonb NOT NULL, + "target_module" text NOT NULL, + "target_action" text NOT NULL, + "target_record_id" uuid, + "status" text NOT NULL, + "error_message" text, + "ip" text, + "user_agent" text, + "created_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "submissions_site_created_idx" + ON "website"."submissions" ("site_id", "created_at" DESC); diff --git a/apps/api/src/modules/website/public-routes.ts b/apps/api/src/modules/website/public-routes.ts index 6b99e7607..fed6983cb 100644 --- a/apps/api/src/modules/website/public-routes.ts +++ b/apps/api/src/modules/website/public-routes.ts @@ -10,8 +10,10 @@ import { Hono } from 'hono'; import { and, eq } from 'drizzle-orm'; import { db, publishedSnapshots } from './schema'; import { errorResponse } from '../../lib/responses'; +import { websiteSubmitRoutes } from './submit'; const routes = new Hono(); +routes.route('/', websiteSubmitRoutes); /** * GET /api/v1/website/public/sites/:slug diff --git a/apps/api/src/modules/website/publish.ts b/apps/api/src/modules/website/publish.ts index 94d33693d..270d5fb62 100644 --- a/apps/api/src/modules/website/publish.ts +++ b/apps/api/src/modules/website/publish.ts @@ -11,7 +11,7 @@ import { z } from 'zod'; import { and, desc, eq } from 'drizzle-orm'; import type { AuthVariables } from '@mana/shared-hono'; import { errorResponse, validationError } from '../../lib/responses'; -import { db, publishedSnapshots } from './schema'; +import { db, publishedSnapshots, submissions } from './schema'; import { isValidSlug } from './reserved-slugs'; const routes = new Hono<{ Variables: AuthVariables }>(); @@ -243,4 +243,61 @@ routes.post('/sites/:id/rollback/:snapshotId', async (c) => { return c.json({ rolledBack: true, slug: target[0].slug }); }); +// ─── GET /sites/:id/submissions ───────────────────────── +// Owner-only list of inbox submissions for a site. + +routes.get('/sites/:id/submissions', async (c) => { + const siteId = c.req.param('id'); + if (!siteId) return errorResponse(c, 'siteId required', 400); + + const rows = await db + .select({ + id: submissions.id, + siteId: submissions.siteId, + blockId: submissions.blockId, + payload: submissions.payload, + targetModule: submissions.targetModule, + status: submissions.status, + errorMessage: submissions.errorMessage, + createdAt: submissions.createdAt, + }) + .from(submissions) + .where(eq(submissions.siteId, siteId)) + .orderBy(desc(submissions.createdAt)) + .limit(200); + + return c.json({ + submissions: rows.map((r) => ({ + id: r.id, + blockId: r.blockId, + payload: r.payload, + targetModule: r.targetModule, + status: r.status, + errorMessage: r.errorMessage, + createdAt: r.createdAt.toISOString(), + })), + }); +}); + +// ─── DELETE /sites/:id/submissions/:submissionId ─────── + +routes.delete('/sites/:id/submissions/:submissionId', async (c) => { + const siteId = c.req.param('id'); + const submissionId = c.req.param('submissionId'); + if (!siteId || !submissionId) { + return errorResponse(c, 'siteId + submissionId required', 400); + } + + const deleted = await db + .delete(submissions) + .where(and(eq(submissions.id, submissionId), eq(submissions.siteId, siteId))) + .returning({ id: submissions.id }); + + if (deleted.length === 0) { + return errorResponse(c, 'Submission not found', 404, { code: 'NOT_FOUND' }); + } + + return c.json({ deleted: true }); +}); + export const websitePublishRoutes = routes; diff --git a/apps/api/src/modules/website/schema.ts b/apps/api/src/modules/website/schema.ts index 6e4be6931..b1e64d7d8 100644 --- a/apps/api/src/modules/website/schema.ts +++ b/apps/api/src/modules/website/schema.ts @@ -61,9 +61,42 @@ export const publishedSnapshots = websiteSchema.table( ] ); +/** + * Form submissions inbox. Every POST to /public/submit/:siteId/:blockId + * lands here. `payload` is nulled after successful delivery to the + * target module (M4.x); M4 first-pass keeps it indefinitely so the + * owner sees it in the submissions UI. + */ +export const submissions = websiteSchema.table( + 'submissions', + { + id: uuid('id').defaultRandom().primaryKey(), + siteId: uuid('site_id').notNull(), + /** No FK — blocks live in Dexie + the sync pool, not here. */ + blockId: uuid('block_id').notNull(), + /** JSON object — keys match the block's declared field names. */ + payload: jsonb('payload').notNull(), + /** Denormalized at submit time so target routing survives block edits. */ + targetModule: text('target_module').notNull(), + /** `'inbox'` in M4; expand when we wire contacts/notify handlers. */ + targetAction: text('target_action').notNull(), + /** FK into the target module's record once delivered (M4.x). */ + targetRecordId: uuid('target_record_id'), + /** 'received' | 'delivered' | 'failed'. */ + status: text('status').notNull(), + errorMessage: text('error_message'), + ip: text('ip'), + userAgent: text('user_agent'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('submissions_site_created_idx').on(table.siteId, table.createdAt)] +); + export const db = drizzle(getConnection(), { - schema: { publishedSnapshots }, + schema: { publishedSnapshots, submissions }, }); export type PublishedSnapshotRow = typeof publishedSnapshots.$inferSelect; export type NewPublishedSnapshot = typeof publishedSnapshots.$inferInsert; +export type SubmissionRow = typeof submissions.$inferSelect; +export type NewSubmission = typeof submissions.$inferInsert; diff --git a/apps/api/src/modules/website/submit.ts b/apps/api/src/modules/website/submit.ts new file mode 100644 index 000000000..bb0a11f39 --- /dev/null +++ b/apps/api/src/modules/website/submit.ts @@ -0,0 +1,215 @@ +/** + * Public form-submit endpoint — UNAUTHENTICATED. + * + * Flow: + * 1. Look up the current published snapshot for this site + * 2. Walk the snapshot blob to find the block, extract its form schema + * 3. Validate incoming payload against declared fields (type + length + * + required) + * 4. Honeypot check (the block renderer adds a hidden `_trap` field — + * if present and non-empty, silently 202 without recording) + * 5. Rate-limit per IP (10 submissions / 5 minutes / site) + * 6. Insert into website.submissions with status='received' + * + * M4 first-pass: target delivery is not wired. The owner sees + * submissions in /website/[id]/submissions. Contacts / notify + * forwarding is M4.x once server-side tool handlers exist. + * + * Security: this route runs BEFORE authMiddleware so anonymous visitors + * can submit. Every bit of user-controlled input (site slug, block id, + * payload) is treated as untrusted. + */ + +import { Hono } from 'hono'; +import { and, eq } from 'drizzle-orm'; +import { db, publishedSnapshots, submissions } from './schema'; + +const routes = new Hono(); + +// Simple in-memory rate limit (per-IP, per-site, sliding window). +// Replace with Redis when the service scales horizontally (M7). +const RATE_WINDOW_MS = 5 * 60 * 1000; +const RATE_MAX = 10; +const rateLimits = new Map(); + +function rateLimitHit(key: string): boolean { + const now = Date.now(); + const bucket = rateLimits.get(key); + if (!bucket || now - bucket.windowStart > RATE_WINDOW_MS) { + rateLimits.set(key, { count: 1, windowStart: now }); + return false; + } + bucket.count += 1; + return bucket.count > RATE_MAX; +} + +interface SnapshotBlob { + version: string; + pages?: Array<{ + blocks?: SnapshotBlock[]; + }>; +} + +interface SnapshotBlock { + id: string; + type: string; + props: unknown; + children?: SnapshotBlock[]; +} + +interface FormBlockProps { + fields: Array<{ + name: string; + label: string; + type: 'text' | 'email' | 'tel' | 'url' | 'textarea' | 'number'; + required: boolean; + maxLength: number; + }>; + target: 'inbox'; +} + +function findFormBlock(blob: SnapshotBlob, blockId: string): SnapshotBlock | null { + const pages = blob.pages ?? []; + for (const page of pages) { + const hit = walk(page.blocks ?? [], blockId); + if (hit) return hit; + } + return null; +} + +function walk(blocks: SnapshotBlock[], blockId: string): SnapshotBlock | null { + for (const block of blocks) { + if (block.id === blockId) return block; + if (block.children) { + const hit = walk(block.children, blockId); + if (hit) return hit; + } + } + return null; +} + +function isFormBlock(block: SnapshotBlock): block is SnapshotBlock & { props: FormBlockProps } { + if (block.type !== 'form') return false; + const props = block.props as Partial; + return Array.isArray(props?.fields); +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function validateField( + field: FormBlockProps['fields'][number], + raw: unknown +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: `Feld "${field.name}" muss Text sein` }; + } + const trimmed = raw.trim(); + if (field.required && trimmed.length === 0) { + return { ok: false, error: `Pflichtfeld "${field.label}" fehlt` }; + } + if (trimmed.length > field.maxLength) { + return { ok: false, error: `Feld "${field.label}" ist zu lang` }; + } + if (trimmed.length === 0) return { ok: true, value: '' }; + + if (field.type === 'email' && !EMAIL_RE.test(trimmed)) { + return { ok: false, error: `"${field.label}" ist keine gültige E-Mail` }; + } + if (field.type === 'url') { + try { + new URL(trimmed); + } catch { + return { ok: false, error: `"${field.label}" ist keine gültige URL` }; + } + } + if (field.type === 'number' && !/^-?\d+(\.\d+)?$/.test(trimmed)) { + return { ok: false, error: `"${field.label}" muss eine Zahl sein` }; + } + return { ok: true, value: trimmed }; +} + +routes.post('/submit/:siteSlug/:blockId', async (c) => { + const { siteSlug, blockId } = c.req.param(); + if (!siteSlug || !blockId) { + return c.json({ error: 'siteSlug + blockId required' }, 400); + } + + // Rate-limit. `cf-connecting-ip` + `x-forwarded-for` for proxied + // deployments, fall back to a generic bucket if we can't find + // anything (better than letting the bucket be "nothing"). + const ip = + c.req.header('cf-connecting-ip') ?? + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? + 'unknown'; + if (rateLimitHit(`${siteSlug}:${ip}`)) { + return c.json({ error: 'Rate limit überschritten — bitte später erneut versuchen' }, 429); + } + + // Load current published snapshot to find the block schema. The + // block definition lives IN the snapshot blob, not in a separate + // table — this keeps the source of truth in one place and prevents + // a block-edit race (site gets unpublished while a form is in the + // middle of being submitted). + const snapshotRow = await db + .select({ blob: publishedSnapshots.blob, siteId: publishedSnapshots.siteId }) + .from(publishedSnapshots) + .where(and(eq(publishedSnapshots.slug, siteSlug), eq(publishedSnapshots.isCurrent, true))) + .limit(1); + + if (!snapshotRow[0]) { + return c.json({ error: 'Website nicht gefunden oder offline' }, 404); + } + + const block = findFormBlock(snapshotRow[0].blob as SnapshotBlob, blockId); + if (!block) { + return c.json({ error: 'Block nicht gefunden' }, 404); + } + if (!isFormBlock(block)) { + return c.json({ error: 'Block ist kein Formular' }, 400); + } + + const rawBody = (await c.req.json().catch(() => null)) as Record | null; + if (!rawBody || typeof rawBody !== 'object') { + return c.json({ error: 'Payload fehlt oder ungültig' }, 400); + } + + // Honeypot — the renderer names the trap input whatever value is in + // `_trap`. For M4 it's the public `honeypot` field bound in the + // Form renderer. We also look for a generic "_trap" key. + const trap = rawBody.honeypot ?? rawBody._trap; + if (typeof trap === 'string' && trap.trim().length > 0) { + // Act as success to the bot, store nothing. + return c.json({ ok: true, spam: true }, 202); + } + + // Validate every declared field. Ignore unknown keys the client + // tried to sneak in. + const cleaned: Record = {}; + for (const field of block.props.fields) { + const result = validateField(field, rawBody[field.name]); + if (!result.ok) { + return c.json({ error: result.error, field: field.name }, 400); + } + cleaned[field.name] = result.value; + } + + const userAgent = c.req.header('user-agent') ?? null; + + const [row] = await db + .insert(submissions) + .values({ + siteId: snapshotRow[0].siteId, + blockId, + payload: cleaned, + targetModule: 'inbox', + targetAction: 'inbox', + status: 'received', + ip: ip === 'unknown' ? null : ip, + userAgent: userAgent && userAgent.length > 500 ? userAgent.slice(0, 500) : userAgent, + }) + .returning({ id: submissions.id }); + + return c.json({ ok: true, submissionId: row?.id ?? null }, 201); +}); + +export const websiteSubmitRoutes = routes; diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts new file mode 100644 index 000000000..de9b21bb7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -0,0 +1,150 @@ +/** + * Module-embed resolvers — client-side functions that walk Dexie to + * pre-fetch data for `moduleEmbed` blocks at publish time. + * + * These run exactly once per publish and inline the result into the + * snapshot's `block.props.resolved` field, so the public renderer + * reads the blob and never needs Dexie or the network to show embedded + * content. Trade-off: publishes are slightly slower, public visits are + * much faster. + * + * Every resolver MUST enforce the source's public-visibility rules — + * e.g. `picture.board.isPublic === true`. An owner who embeds a + * private board gets an empty result with a clear error message in the + * resolved.error field. + */ + +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import { mediaFileUrl } from './upload'; +import type { EmbedItem, EmbedSource, ModuleEmbedProps } from '@mana/website-blocks'; +import type { LocalBoard, LocalBoardItem, LocalImage } from '$lib/modules/picture/types'; +import type { LocalLibraryEntry } from '$lib/modules/library/types'; + +export interface ResolvedEmbed { + items: EmbedItem[]; + error?: string; + resolvedAt: string; +} + +/** Resolve a single moduleEmbed block's props. */ +export async function resolveEmbed(props: ModuleEmbedProps): Promise { + const now = new Date().toISOString(); + try { + let items: EmbedItem[]; + switch (props.source as EmbedSource) { + case 'picture.board': + items = await resolvePictureBoard(props); + break; + case 'library.entries': + items = await resolveLibraryEntries(props); + break; + default: + return { + items: [], + error: `Unbekannte Quelle: ${props.source}`, + resolvedAt: now, + }; + } + return { items: items.slice(0, props.maxItems), resolvedAt: now }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { items: [], error: message, resolvedAt: now }; + } +} + +/** + * Picture-board: returns image items for a board that the owner marked + * `isPublic=true`. Private boards return an error. + */ +async function resolvePictureBoard(props: ModuleEmbedProps): Promise { + if (!props.sourceId) { + throw new Error('Bitte wähle ein Board aus'); + } + + const [rawBoard] = await db + .table('boards') + .where('id') + .equals(props.sourceId) + .toArray(); + + if (!rawBoard || rawBoard.deletedAt) { + throw new Error('Board nicht gefunden'); + } + if (!rawBoard.isPublic) { + throw new Error('Board ist nicht öffentlich — setze "Öffentlich" im Picture-Modul'); + } + + const items = await db + .table('boardItems') + .where('boardId') + .equals(props.sourceId) + .toArray(); + + const imageItems = items + .filter((i) => !i.deletedAt && i.itemType === 'image' && i.imageId) + .sort((a, b) => a.zIndex - b.zIndex); + + if (imageItems.length === 0) return []; + + const imageIds = imageItems.map((i) => i.imageId as string); + const images = await db.table('images').where('id').anyOf(imageIds).toArray(); + const decrypted = (await decryptRecords('images', images)) as LocalImage[]; + const imageById = new Map(); + for (const img of decrypted) imageById.set(img.id, img); + + const out: EmbedItem[] = []; + for (const item of imageItems) { + const img = imageById.get(item.imageId as string); + if (!img) continue; + const url = img.publicUrl ?? mediaFileUrl(img.id, 'medium'); + out.push({ + title: img.prompt?.slice(0, 120) || 'Bild', + imageUrl: url, + }); + } + return out; +} + +/** + * Library-entries: returns book/movie/series/comic entries. Owner-only + * data by default — M4 first-pass exposes it if the owner opts in via + * `filter.isFavorite` (favorites are the typical "show-on-my-site" + * subset). Future: per-entry `showOnWebsite` flag. + */ +async function resolveLibraryEntries(props: ModuleEmbedProps): Promise { + let locals = await db.table('libraryEntries').toArray(); + locals = locals.filter((e) => !e.deletedAt); + + if (props.filter?.kind) { + locals = locals.filter((e) => e.kind === props.filter?.kind); + } + if (props.filter?.status) { + locals = locals.filter((e) => e.status === props.filter?.status); + } + if (props.filter?.isFavorite === true) { + locals = locals.filter((e) => e.isFavorite === true); + } + + // Newest completions first. + locals.sort((a, b) => { + const aKey = a.completedAt ?? a.updatedAt ?? ''; + const bKey = b.completedAt ?? b.updatedAt ?? ''; + return bKey.localeCompare(aKey); + }); + + const decrypted = (await decryptRecords('libraryEntries', locals)) as LocalLibraryEntry[]; + + return decrypted.map((entry) => { + const creators = (entry.creators ?? []).slice(0, 2).join(', '); + const year = entry.year ? ` · ${entry.year}` : ''; + const subtitle = creators ? `${creators}${year}` : year.trim() || undefined; + return { + title: entry.title, + subtitle, + imageUrl: + entry.coverUrl ?? + (entry.coverMediaId ? mediaFileUrl(entry.coverMediaId, 'medium') : undefined), + }; + }); +} diff --git a/apps/mana/apps/web/src/lib/modules/website/publish.ts b/apps/mana/apps/web/src/lib/modules/website/publish.ts index c411bea9d..defdee1ba 100644 --- a/apps/mana/apps/web/src/lib/modules/website/publish.ts +++ b/apps/mana/apps/web/src/lib/modules/website/publish.ts @@ -16,6 +16,7 @@ import { getManaApiUrl } from '$lib/api/config'; import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections'; +import { resolveEmbed } from './embeds'; import type { LocalWebsite, LocalWebsiteBlock, @@ -25,6 +26,7 @@ import type { SiteSettings, PageSeo, } from './types'; +import type { ModuleEmbedProps } from '@mana/website-blocks'; // ─── Snapshot shape ────────────────────────────────────── @@ -127,6 +129,13 @@ export async function buildSnapshot(siteId: string): Promise { blocks: buildBlockTree(blocksByPage.get(p.id) ?? []), })); + // Pre-resolve moduleEmbed blocks: walk the tree, fetch source data + // from Dexie, inline into block.props. The public renderer serves + // the snapshot without further lookups. See plan §M4. + for (const page of pages) { + await resolveEmbedsInTree(page.blocks); + } + return { version: SNAPSHOT_VERSION, site: toSnapshotSite(site), @@ -134,6 +143,19 @@ export async function buildSnapshot(siteId: string): Promise { }; } +async function resolveEmbedsInTree(nodes: SnapshotBlockNode[]): Promise { + for (const node of nodes) { + if (node.type === 'moduleEmbed') { + const props = node.props as ModuleEmbedProps; + const resolved = await resolveEmbed(props); + node.props = { ...props, resolved }; + } + if (node.children.length > 0) { + await resolveEmbedsInTree(node.children); + } + } +} + function toSnapshotSite(site: LocalWebsite): SnapshotSite { return { id: site.id, @@ -282,6 +304,44 @@ export async function fetchSnapshotHistory( return body.snapshots; } +export interface SubmissionEntry { + id: string; + blockId: string; + payload: Record; + targetModule: string; + status: string; + errorMessage: string | null; + createdAt: string; +} + +export async function fetchSubmissions(siteId: string, jwt: string): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/submissions`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (!res.ok) { + throw new PublishError(`Submissions fetch failed (${res.status})`, 'UNKNOWN', res.status); + } + const body = (await res.json()) as { submissions: SubmissionEntry[] }; + return body.submissions; +} + +export async function deleteSubmission( + siteId: string, + submissionId: string, + jwt: string +): Promise { + const res = await fetch( + `${getManaApiUrl()}/api/v1/website/sites/${siteId}/submissions/${submissionId}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${jwt}` }, + } + ); + if (!res.ok && res.status !== 404) { + throw new PublishError(`Delete submission failed (${res.status})`, 'UNKNOWN', res.status); + } +} + export async function rollbackSnapshot( siteId: string, snapshotId: string, diff --git a/apps/mana/apps/web/src/lib/modules/website/views/SubmissionsView.svelte b/apps/mana/apps/web/src/lib/modules/website/views/SubmissionsView.svelte new file mode 100644 index 000000000..747820e83 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/views/SubmissionsView.svelte @@ -0,0 +1,231 @@ + + +
+
+
+

Eingegangen

+

Formular-Einsendungen von deiner Website.

+
+ +
+ + {#if loadError} +

{loadError}

+ {:else if entries === null} +
Lade…
+ {:else if entries.length === 0} +
+

Noch keine Einsendungen.

+ Sobald jemand ein Formular auf deiner Website ausfüllt, landet es hier. +
+ {:else} +
    + {#each entries as entry (entry.id)} +
  • +
    + {formatDate(entry.createdAt)} +
    + {entry.status} + +
    +
    +
    + {#each Object.entries(entry.payload) as [key, value] (key)} +
    {key}
    +
    {value}
    + {/each} +
    + {#if entry.errorMessage} +

    {entry.errorMessage}

    + {/if} +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/website/[siteId]/submissions/+page.svelte b/apps/mana/apps/web/src/routes/(app)/website/[siteId]/submissions/+page.svelte new file mode 100644 index 000000000..3ebb85ca9 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/website/[siteId]/submissions/+page.svelte @@ -0,0 +1,22 @@ + + + + {site?.name ?? 'Website'} – Submissions + + + + + diff --git a/apps/mana/apps/web/src/routes/s/[siteSlug]/__submit/[blockId]/+server.ts b/apps/mana/apps/web/src/routes/s/[siteSlug]/__submit/[blockId]/+server.ts new file mode 100644 index 000000000..80bb6b203 --- /dev/null +++ b/apps/mana/apps/web/src/routes/s/[siteSlug]/__submit/[blockId]/+server.ts @@ -0,0 +1,41 @@ +/** + * Form-submit proxy — forwards the visitor's POST to the mana-api + * public submit endpoint. Keeps the submission on the same origin as + * the public page so no CORS preflight is needed and the visitor's + * session state (if any) isn't fiddled with. + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getManaApiUrl } from '$lib/api/config'; + +export const POST: RequestHandler = async ({ params, request, getClientAddress }) => { + const { siteSlug, blockId } = params; + if (!siteSlug || !blockId) { + return json({ error: 'siteSlug + blockId required' }, { status: 400 }); + } + + const body = await request.text(); + + // Forward client IP + user-agent so the api can rate-limit + store + // them. SvelteKit's `getClientAddress()` respects the usual proxy + // headers (X-Forwarded-For, Cloudflare's CF-Connecting-IP). + const forwardedIp = getClientAddress(); + const userAgent = request.headers.get('user-agent') ?? ''; + + const upstream = await fetch( + `${getManaApiUrl()}/api/v1/website/public/submit/${encodeURIComponent(siteSlug)}/${encodeURIComponent(blockId)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Forwarded-For': forwardedIp, + 'User-Agent': userAgent, + }, + body, + } + ); + + const data = (await upstream.json().catch(() => ({}))) as Record; + return json(data, { status: upstream.status }); +}; diff --git a/packages/website-blocks/src/form/Form.svelte b/packages/website-blocks/src/form/Form.svelte new file mode 100644 index 000000000..938d17c58 --- /dev/null +++ b/packages/website-blocks/src/form/Form.svelte @@ -0,0 +1,221 @@ + + +
+
+ {#if block.props.title} +

{block.props.title}

+ {/if} + {#if block.props.description} +

{block.props.description}

+ {/if} + + {#if submitted} +
{block.props.successMessage}
+ {:else} +
+ {#each block.props.fields as field (field.name)} + + {/each} + + + + + {#if errorText} +

{errorText}

+ {/if} + + +
+ {/if} +
+
+ + diff --git a/packages/website-blocks/src/form/FormInspector.svelte b/packages/website-blocks/src/form/FormInspector.svelte new file mode 100644 index 000000000..4f1e1d754 --- /dev/null +++ b/packages/website-blocks/src/form/FormInspector.svelte @@ -0,0 +1,287 @@ + + +
+ + + + +
+ + +
+ + + +
+
+ Felder ({block.props.fields.length}) + +
+ + {#each block.props.fields as field, i (i)} +
+
+ #{i + 1} +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ {/each} +
+
+ + diff --git a/packages/website-blocks/src/form/index.ts b/packages/website-blocks/src/form/index.ts new file mode 100644 index 000000000..4ab5af5c9 --- /dev/null +++ b/packages/website-blocks/src/form/index.ts @@ -0,0 +1,19 @@ +import type { BlockSpec } from '../types'; +import Form from './Form.svelte'; +import FormInspector from './FormInspector.svelte'; +import { FormSchema, FORM_DEFAULTS, type FormProps, type FormField } from './schema'; + +export const formBlockSpec: BlockSpec = { + type: 'form', + label: 'Formular', + icon: 'clipboard', + category: 'form', + schema: FormSchema, + schemaVersion: 1, + defaults: FORM_DEFAULTS, + Component: Form, + Inspector: FormInspector, +}; + +export type { FormProps, FormField }; +export { FormSchema, FORM_DEFAULTS }; diff --git a/packages/website-blocks/src/form/schema.ts b/packages/website-blocks/src/form/schema.ts new file mode 100644 index 000000000..25c5d820c --- /dev/null +++ b/packages/website-blocks/src/form/schema.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +export const FormFieldSchema = z.object({ + /** Internal name — key in the submission payload. */ + name: z + .string() + .min(1) + .max(40) + .regex(/^[a-z][a-z0-9_]*$/i), + /** Visible label above the input. */ + label: z.string().min(1).max(120), + /** HTML input type. `textarea` renders `