Files
windwiderstand/src/routes/api/stellungnahme/+server.ts
T
Peter Meier ca4dfad8be
Deploy / verify (push) Successful in 1m32s
Deploy / deploy (push) Successful in 1m54s
feat(termine+matrix): Nutzer-Termin-Einreichung (Draft) + Matrix-Notifications
- /termin-melden: öffentliches Formular → /api/termin legt calendar_item als
  Draft an (scoped Write-Key), Admin bestätigt per draft→published
- /api/cms-webhook: empfängt RustyCMS-Webhooks (X-Webhook-Secret) → Matrix
- src/lib/server/matrix.ts: notifyMatrix() Helper
- Form-Proxies (stellungnahme/contact/newsletter/mitmachen) pingen Matrix
- deploy.yml: Matrix/Webhook/Termin-Key Env-Vars

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:32:36 +02:00

121 lines
4.2 KiB
TypeScript

import { json } from "@sveltejs/kit";
import { env as publicEnv } from "$env/dynamic/public";
import type { RequestHandler } from "./$types";
import { notifyMatrix } from "$lib/server/matrix";
// 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<string, unknown>;
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<string, string> = {};
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) {
void notifyMatrix(
`📄 Neue Stellungnahme-Einsendung: ${fields.name} (${fields.email})${fields.vorranggebiet ? `, Gebiet ${fields.vorranggebiet}` : ""} — Forms-Plugin „stellungnahme"`,
);
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 "";
}
}