mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 12:39:39 +02:00
feat(website): M4 — forms + moduleEmbed
Adds two new block types and the server-side infrastructure for untrusted input + cross-module data embedding. Forms: - packages/website-blocks/src/form: declarative fields (text, email, tel, url, textarea, number) with required / maxLength / placeholder per field. Honeypot hidden input in the renderer; public-mode POST to a same-origin SvelteKit proxy that forwards to mana-api. - apps/api: website.submissions table (schema.ts + 0001_submissions.sql) + POST /public/submit/:siteSlug/:blockId. Loads the current published snapshot, finds the form block, validates payload against its declared fields (trim, type check, length cap), rejects honeypot submissions silently, rate-limits per IP (10 / 5 min) in-memory. Unknown keys are dropped — clients can only submit declared fields. - Owner-facing: GET/DELETE /sites/:id/submissions + SubmissionsView component + /(app)/website/[siteId]/submissions route. Shows incoming submissions with status pill + payload preview + delete. - apps/mana/.../routes/s/[siteSlug]/__submit/[blockId]/+server.ts: same-origin proxy so form posts don't trigger CORS and IP / user- agent headers are forwarded via SvelteKit's trusted getClientAddress. M4 first-pass does NOT wire target-module delivery (contacts / notify). Submissions stay in the inbox until owner-side tool handlers land (M4.x). `target` enum is intentionally `['inbox']` only for now. moduleEmbed: - packages/website-blocks/src/moduleEmbed: source dropdown (picture.board | library.entries), max-items, layout (grid | list), optional filter object. The `resolved` field on props is populated at publish time by the editor-side resolver — public renderer reads it directly, no Dexie / API round-trip needed. - apps/mana/.../website/embeds.ts: per-source resolvers. picture.board enforces `isPublic=true`; library.entries respects filter.isFavorite / kind / status so owners can expose a subset (e.g. "my favorites"). - buildSnapshot() walks the tree after assembly and fills in block.props.resolved for every moduleEmbed. Publish slower, public visits fast. No cross-service call at render time. Validation: - pnpm run validate:all: 6/6 gates green - pnpm run check (web): 0 errors, 0 warnings - apps/api type-check: green Apply Postgres with: psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql Plan: docs/plans/website-builder.md (M4 shipped) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79d112657c
commit
57be0f61b1
20 changed files with 1817 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
215
apps/api/src/modules/website/submit.ts
Normal file
215
apps/api/src/modules/website/submit.ts
Normal file
|
|
@ -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<string, { count: number; windowStart: number }>();
|
||||
|
||||
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<FormBlockProps>;
|
||||
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<string, unknown> | 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<string, string> = {};
|
||||
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue