From 552446b670d75cda8fe450d81d36a60b695f8087 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Fri, 26 Jun 2026 22:31:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(stellungnahme):=20"An=20das=20Team=20sende?= =?UTF-8?q?n"=20=E2=80=94=20Prompt=20landet=20in=20RustyCMS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StellingnahmeGeneratorBlock: Senden-Panel im KI-Block (Name, Email, Vorranggebiet, generierter Prompt) → /api/stellungnahme, Consent + Honeypot - neuer Endpoint /api/stellungnahme spiegelt /api/contact (Origin, Honeypot, client-age, Validierung) → forwarded an CMS forms-Plugin - Privacy-Notice: Senden = ausdrückliche Übermittlung Co-Authored-By: Claude Opus 4.8 (1M context) --- .../blocks/StellingnahmeGeneratorBlock.svelte | 116 +++++++++++++++++- src/lib/iconify-lucide-subset.generated.json | 3 + src/routes/api/stellungnahme/+server.ts | 116 ++++++++++++++++++ 3 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/routes/api/stellungnahme/+server.ts diff --git a/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte b/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte index 2e1442a..0a4af59 100644 --- a/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte +++ b/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte @@ -195,6 +195,7 @@ absenderName, absenderAnschrift, absenderOrt, + absenderEmail, argNotes, openingOverride, closingOverride, @@ -204,7 +205,7 @@ } $effect(() => { - void [selectedSlugs, persoenlicheEingabe, absenderName, absenderAnschrift, absenderOrt, argNotes, openingOverride, closingOverride, step]; + void [selectedSlugs, persoenlicheEingabe, absenderName, absenderAnschrift, absenderOrt, absenderEmail, argNotes, openingOverride, closingOverride, step]; if (mounted) { saveState(); flashSaved(); @@ -241,8 +242,54 @@ let absenderName = $state(""); let absenderAnschrift = $state(""); let absenderOrt = $state(""); + let absenderEmail = $state(""); let showOutput = $state(false); + // ── "An das Team senden" (Prompt zur Weiterverarbeitung in RustyCMS) ────────── + let sendState = $state<"idle" | "sending" | "sent" | "error">("idle"); + let sendError = $state(""); + let sendConsent = $state(false); + let sendHoneypot = $state(""); // muss leer bleiben (Bot-Falle) + let blockMountedAt = 0; + + function isValidEmail(s: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); + } + + async function sendToTeam() { + if (sendState === "sending") return; + sendError = ""; + if (!absenderName.trim()) { sendError = "Bitte oben Ihren Namen eintragen."; return; } + if (!isValidEmail(absenderEmail)) { sendError = "Bitte eine gültige E-Mail-Adresse angeben."; return; } + if (!sendConsent) { sendError = "Bitte der Übermittlung zustimmen."; return; } + sendState = "sending"; + try { + const res = await fetch("/api/stellungnahme", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: absenderName.trim(), + email: absenderEmail.trim(), + vorranggebiet: windArea?.gebiets_nr ?? "", + prompt: buildAiPrompt(), + consent: true, + website: sendHoneypot, + _client_age_ms: blockMountedAt ? Date.now() - blockMountedAt : 0, + }), + }); + if (res.ok) { + sendState = "sent"; + } else { + const d = (await res.json().catch(() => ({}))) as { error?: string; errors?: Record }; + sendError = d.error ?? (d.errors ? Object.values(d.errors)[0] : "") ?? "Senden fehlgeschlagen."; + sendState = "error"; + } + } catch { + sendError = "Server nicht erreichbar."; + sendState = "error"; + } + } + // Per-argument notes (appear in output after bullet points) let argNotes = $state>({}); function setArgNote(slug: string, value: string) { @@ -460,6 +507,10 @@ ${"=".repeat(64)}`; absenderName = ""; absenderAnschrift = ""; absenderOrt = ""; + absenderEmail = ""; + sendState = "idle"; + sendError = ""; + sendConsent = false; showOutput = false; outputText = ""; hasManualEdit = false; @@ -653,6 +704,7 @@ ${outputText} }); onMount(() => { + blockMountedAt = Date.now(); // Restore persisted state try { const saved = localStorage.getItem(storageKey); @@ -663,6 +715,7 @@ ${outputText} if (typeof s.absenderName === "string") absenderName = s.absenderName; if (typeof s.absenderAnschrift === "string") absenderAnschrift = s.absenderAnschrift; if (typeof s.absenderOrt === "string") absenderOrt = s.absenderOrt; + if (typeof s.absenderEmail === "string") absenderEmail = s.absenderEmail; if (s.argNotes && typeof s.argNotes === "object") argNotes = s.argNotes; if (typeof s.openingOverride === "string") openingOverride = s.openingOverride; if (typeof s.closingOverride === "string") closingOverride = s.closingOverride; @@ -734,7 +787,7 @@ ${outputText} - Ihre Daten bleiben auf Ihrem Gerät. Name, Anschrift und alle Eingaben werden nicht an Server übertragen. + Ihre Daten bleiben auf Ihrem Gerät. Name, Anschrift und alle Eingaben werden nicht an Server übertragen. Nur wenn Sie am Ende ausdrücklich „An das Team senden" wählen, werden die dort gezeigten Angaben übermittelt. @@ -1351,6 +1404,65 @@ ${outputText} Als .txt herunterladen + + +
+ {#if sendState === "sent"} +
+ +
+

Vielen Dank, übermittelt.

+

Das Team kann den Prompt jetzt weiterverarbeiten. Sie können das Fenster schließen.

+
+
+ {:else} +
+ +
+
An das Team senden
+

+ Statt selbst zu kopieren: Wir verarbeiten den oben erzeugten Prompt für Sie weiter. + Übermittelt werden Name, E-Mail, Vorranggebiet{windArea?.gebiets_nr ? ` (${windArea.gebiets_nr})` : ""} und der erzeugte Prompt. +

+
+
+ + + + +
+
+ + +
+
+ + +
+
+ + + + {#if sendError} +

{sendError}

+ {/if} + + + {/if} +
diff --git a/src/lib/iconify-lucide-subset.generated.json b/src/lib/iconify-lucide-subset.generated.json index 3cddc50..05aaf5d 100644 --- a/src/lib/iconify-lucide-subset.generated.json +++ b/src/lib/iconify-lucide-subset.generated.json @@ -172,6 +172,9 @@ "sliders-horizontal": { "body": "" }, + "send": { + "body": "" + }, "file-text": { "body": "" }, diff --git a/src/routes/api/stellungnahme/+server.ts b/src/routes/api/stellungnahme/+server.ts new file mode 100644 index 0000000..363e323 --- /dev/null +++ b/src/routes/api/stellungnahme/+server.ts @@ -0,0 +1,116 @@ +import { json } from "@sveltejs/kit"; +import { env as publicEnv } from "$env/dynamic/public"; +import type { RequestHandler } from "./$types"; + +// Honeypot field — must be empty (bot trap) +const HONEYPOT = "website"; +// Bots usually post immediately; require a minimum dwell time +const MIN_CLIENT_AGE_MS = 3_000; +// Prompt can be large (embeds the full draft). CMS body cap is the hard backstop. +const PROMPT_MAX = 60_000; + +const ALLOWED_ORIGINS = new Set([ + "https://windwiderstand.de", + "https://www.windwiderstand.de", + "http://localhost:5173", + "http://localhost:4173", +]); + +function isValidEmail(s: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); +} + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + // Origin/Referer must match — blocks naive cross-site POSTs + const origin = request.headers.get("origin") ?? ""; + const referer = request.headers.get("referer") ?? ""; + const refererOrigin = referer ? safeOrigin(referer) : ""; + if (!ALLOWED_ORIGINS.has(origin) && !ALLOWED_ORIGINS.has(refererOrigin)) { + return json({ error: "Ungültige Anfrage." }, { status: 403 }); + } + + const raw = await request.json().catch(() => null); + if (!raw || typeof raw !== "object") { + return json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + const body = raw as Record; + const str = (k: string): string => (typeof body[k] === "string" ? (body[k] as string) : ""); + + // Honeypot — pretend success so bots don't learn the trap exists + if (str(HONEYPOT).trim() !== "") { + return json({ ok: true }, { status: 201 }); + } + + // Client age + const clientAge = Number(body._client_age_ms); + if (!Number.isFinite(clientAge) || clientAge < MIN_CLIENT_AGE_MS) { + return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 }); + } + + // Validation + const fields = { + name: str("name").trim(), + email: str("email").trim(), + vorranggebiet: str("vorranggebiet").trim(), + prompt: str("prompt"), + }; + const errors: Record = {}; + if (!fields.name) errors.name = "Name fehlt."; + if (!isValidEmail(fields.email)) errors.email = "Ungültige E-Mail."; + if (fields.prompt.trim().length < 50) errors.prompt = "Kein Prompt vorhanden."; + if (fields.prompt.length > PROMPT_MAX) errors.prompt = "Prompt zu lang."; + if (Object.keys(errors).length > 0) { + return json({ errors }, { status: 422 }); + } + + // Forward to CMS forms plugin + const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, ""); + const cmsEnv = publicEnv.PUBLIC_CMS_ENV || ""; + const cmsUrl = cmsEnv + ? `${cmsBase}/api/forms/stellungnahme/submit?_environment=${encodeURIComponent(cmsEnv)}` + : `${cmsBase}/api/forms/stellungnahme/submit`; + + const payload = { + name: fields.name, + email: fields.email, + vorranggebiet: fields.vorranggebiet, + prompt: fields.prompt, + consent_at: new Date().toISOString(), + submitted_via: "windwiderstand.de/api/stellungnahme", + user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200), + }; + + let res: Response; + try { + // No Origin header — already validated here. CMS forms plugin (REQUIRE_ORIGIN=false) accepts it. + res = await fetch(cmsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": getClientAddress(), + }, + body: JSON.stringify(payload), + }); + } catch (e) { + console.error("[stellungnahme] CMS fetch failed:", e); + return json({ error: "Server nicht erreichbar." }, { status: 502 }); + } + + if (res.ok) { + return json({ ok: true }, { status: 201 }); + } + if (res.status === 429) { + return json({ error: "Zu viele Anfragen. Bitte später erneut versuchen." }, { status: 429 }); + } + const text = await res.text().catch(() => ""); + console.error("[stellungnahme] CMS error", res.status, text); + return json({ error: "Fehler beim Senden." }, { status: 502 }); +}; + +function safeOrigin(u: string): string { + try { + return new URL(u).origin; + } catch { + return ""; + } +}