ca4dfad8be
- /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>
147 lines
5.0 KiB
TypeScript
147 lines
5.0 KiB
TypeScript
import { json } from "@sveltejs/kit";
|
|
import { env as publicEnv } from "$env/dynamic/public";
|
|
import { env as privateEnv } from "$env/dynamic/private";
|
|
import type { RequestHandler } from "./$types";
|
|
import { notifyMatrix } from "$lib/server/matrix";
|
|
import {
|
|
HONEYPOT_FIELDS,
|
|
LIMITS,
|
|
validateContact,
|
|
} from "$lib/components/internal/contact-form-validation";
|
|
|
|
const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? "";
|
|
const TURNSTILE_ENABLED = TURNSTILE_SECRET !== "";
|
|
|
|
async function verifyTurnstile(token: string, ip: string): Promise<boolean> {
|
|
if (!TURNSTILE_ENABLED) return true;
|
|
if (!token) return false;
|
|
try {
|
|
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token, remoteip: ip }),
|
|
});
|
|
if (!res.ok) return false;
|
|
const data = (await res.json()) as { success?: boolean };
|
|
return data.success === true;
|
|
} catch (e) {
|
|
console.error("[contact] turnstile verify failed:", e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const ALLOWED_ORIGINS = new Set([
|
|
"https://windwiderstand.de",
|
|
"https://www.windwiderstand.de",
|
|
"http://localhost:5173",
|
|
"http://localhost:4173",
|
|
]);
|
|
|
|
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>;
|
|
|
|
// Honeypots — must all be empty
|
|
for (const hp of HONEYPOT_FIELDS) {
|
|
const val = body[hp];
|
|
if (typeof val === "string" && val.trim() !== "") {
|
|
// Pretend success so bots don't learn the trap exists
|
|
return json({ ok: true }, { status: 201 });
|
|
}
|
|
}
|
|
|
|
// Client age — bots usually post immediately
|
|
const clientAge = Number(body._client_age_ms);
|
|
if (!Number.isFinite(clientAge) || clientAge < LIMITS.MIN_CLIENT_AGE_MS) {
|
|
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
|
|
}
|
|
|
|
// Cloudflare Turnstile (skipped when no secret configured)
|
|
if (TURNSTILE_ENABLED) {
|
|
const token = typeof body.cf_turnstile_token === "string" ? body.cf_turnstile_token : "";
|
|
const ok = await verifyTurnstile(token, getClientAddress());
|
|
if (!ok) {
|
|
return json({ error: "captcha" }, { status: 403 });
|
|
}
|
|
}
|
|
|
|
// Full validation
|
|
const fields = {
|
|
name: typeof body.name === "string" ? body.name : "",
|
|
email: typeof body.email === "string" ? body.email : "",
|
|
subject: typeof body.subject === "string" ? body.subject : "",
|
|
message: typeof body.message === "string" ? body.message : "",
|
|
consent: body.consent === true,
|
|
};
|
|
const errors = validateContact(fields);
|
|
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/kontakt/submit?_environment=${encodeURIComponent(cmsEnv)}`
|
|
: `${cmsBase}/api/forms/kontakt/submit`;
|
|
|
|
const payload = {
|
|
name: fields.name.trim(),
|
|
email: fields.email.trim(),
|
|
subject: fields.subject.trim(),
|
|
message: fields.message.trim(),
|
|
consent_at: new Date().toISOString(),
|
|
submitted_via: "windwiderstand.de/api/contact",
|
|
user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200),
|
|
};
|
|
|
|
let res: Response;
|
|
try {
|
|
// No Origin header — we already validated it at this server.
|
|
// CMS forms plugin (REQUIRE_ORIGIN=false) accepts requests without one.
|
|
res = await fetch(cmsUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Forwarded-For": getClientAddress(),
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} catch (e) {
|
|
console.error("[contact] CMS fetch failed:", e);
|
|
return json({ error: "Server nicht erreichbar." }, { status: 502 });
|
|
}
|
|
|
|
if (res.ok) {
|
|
void notifyMatrix(
|
|
`✉️ Neue Kontakt-Nachricht von ${payload.name} (${payload.email})${payload.subject ? `: ${payload.subject}` : ""}`,
|
|
);
|
|
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("[contact] 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 "";
|
|
}
|
|
}
|