diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index 0dbc5e9..5b06e0e 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -469,7 +469,7 @@ export type CalendarBlockData = Omit< }; /** Internal component block (_type: "internal_component"). */ -export type InternalComponentId = "form-contact" | "form-mitmachen"; +export type InternalComponentId = "form-contact" | "form-mitmachen" | "form-newsletter"; export interface InternalComponentBlockData { _type?: "internal_component"; diff --git a/src/lib/components/blocks/InternalComponentBlock.svelte b/src/lib/components/blocks/InternalComponentBlock.svelte index 2d6c29c..2f13fe6 100644 --- a/src/lib/components/blocks/InternalComponentBlock.svelte +++ b/src/lib/components/blocks/InternalComponentBlock.svelte @@ -3,6 +3,7 @@ import type { InternalComponentBlockData } from "$lib/block-types"; import ContactFormComponent from "$lib/components/internal/ContactFormComponent.svelte"; import MitmachenFormComponent from "$lib/components/internal/MitmachenFormComponent.svelte"; + import NewsletterFormComponent from "$lib/components/internal/NewsletterFormComponent.svelte"; import FormCard from "$lib/components/internal/FormCard.svelte"; let { block }: { block: InternalComponentBlockData } = $props(); @@ -20,6 +21,10 @@ + {:else if component === "form-newsletter"} + + + {:else}

Unbekannte interne Komponente: {component}

{/if} diff --git a/src/lib/components/internal/NewsletterFormComponent.svelte b/src/lib/components/internal/NewsletterFormComponent.svelte new file mode 100644 index 0000000..89782ce --- /dev/null +++ b/src/lib/components/internal/NewsletterFormComponent.svelte @@ -0,0 +1,265 @@ + + +{#if formState === "success"} +
+

{t(T.newsletter_success_title)}

+

{t(T.newsletter_success_body)}

+
+{:else} +
+ + + +
+ +
+ +
+ +
+ +
+ + {#if showError("consent")} + + {/if} +

{t(T.newsletter_privacy_note)}

+
+ + {#if TURNSTILE_ENABLED} +
+ {/if} + + {#if serverError} + + {/if} + +
+
+
+{/if} diff --git a/src/lib/components/internal/newsletter-form-validation.ts b/src/lib/components/internal/newsletter-form-validation.ts new file mode 100644 index 0000000..e280fb3 --- /dev/null +++ b/src/lib/components/internal/newsletter-form-validation.ts @@ -0,0 +1,51 @@ +/** + * Shared validation for NewsletterFormComponent + /api/newsletter server route. + * Single source of truth for limits and rules. + */ + +export const LIMITS = { + NAME_MAX: 80, + EMAIL_MAX: 200, + MIN_CLIENT_AGE_MS: 3000, +} as const; + +export const HONEYPOT_FIELDS = ["_honeypot", "website", "company"] as const; + +export type NewsletterFields = { + name: string; + email: string; + consent: boolean; +}; + +/** Error as translation-key + optional params (resolved in the UI). */ +export type NewsletterError = { + key: string; + params?: Record; +}; + +export type NewsletterErrors = Partial>; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +function trim(s: unknown): string { + return typeof s === "string" ? s.trim() : ""; +} + +export function validateNewsletter(input: Partial): NewsletterErrors { + const errors: NewsletterErrors = {}; + const email = trim(input.email); + + if (!email) { + errors.email = { key: "newsletter_error_email_required" }; + } else if (email.length > LIMITS.EMAIL_MAX) { + errors.email = { key: "newsletter_error_email_max", params: { n: LIMITS.EMAIL_MAX } }; + } else if (!EMAIL_RE.test(email)) { + errors.email = { key: "newsletter_error_email_invalid" }; + } + + if (input.consent !== true) { + errors.consent = { key: "newsletter_error_consent_required" }; + } + + return errors; +} diff --git a/src/lib/translations.ts b/src/lib/translations.ts index e0a98f7..34f61cb 100644 --- a/src/lib/translations.ts +++ b/src/lib/translations.ts @@ -353,6 +353,22 @@ const TRANSLATION_KEYS = [ "mitmachen_error_files_count", // {{n}} "mitmachen_error_files_size", // {{mb}} "mitmachen_error_files_type", + // Newsletter form (NewsletterFormComponent.svelte). Generische Submit-/ + // Server-Error-Texte werden aus contact_* wiederverwendet. + "newsletter_label_name", + "newsletter_placeholder_name", + "newsletter_label_email", + "newsletter_placeholder_email", + "newsletter_label_consent", + "newsletter_privacy_note", + "newsletter_submit", + "newsletter_submitting", + "newsletter_success_title", + "newsletter_success_body", + "newsletter_error_email_required", + "newsletter_error_email_invalid", + "newsletter_error_email_max", // {{n}} + "newsletter_error_consent_required", ] as const; export type TranslationKey = (typeof TRANSLATION_KEYS)[number]; diff --git a/src/routes/api/newsletter/+server.ts b/src/routes/api/newsletter/+server.ts new file mode 100644 index 0000000..c6a3ed4 --- /dev/null +++ b/src/routes/api/newsletter/+server.ts @@ -0,0 +1,168 @@ +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 { + HONEYPOT_FIELDS, + LIMITS, + validateNewsletter, +} from "$lib/components/internal/newsletter-form-validation"; + +const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? ""; +const TURNSTILE_ENABLED = TURNSTILE_SECRET !== ""; + +// listmonk: server reaches it via the shared docker network in prod +// (http://listmonk:9000); falls back to the public URL for local dev. +const LISTMONK_URL = (privateEnv.LISTMONK_URL || "https://newsletter.windwiderstand.de").replace(/\/$/, ""); +const LISTMONK_LIST_UUID = + privateEnv.LISTMONK_NEWSLETTER_LIST_UUID || "36b49110-bb20-486e-8269-971135e1dee9"; + +async function verifyTurnstile(token: string, ip: string): Promise { + 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("[newsletter] turnstile verify failed:", e); + return false; + } +} + +const ALLOWED_ORIGINS = new Set([ + "https://windwiderstand.de", + "https://www.windwiderstand.de", + "http://localhost:5173", + "http://localhost:4173", +]); + +/** Subscribe to listmonk via its public subscription form endpoint. + * A double-opt-in list makes listmonk send the confirmation mail itself. */ +async function subscribeListmonk(email: string, name: string, ip: string): Promise { + const body = new URLSearchParams(); + body.set("email", email); + if (name) body.set("name", name); + body.append("l", LISTMONK_LIST_UUID); + try { + const res = await fetch(`${LISTMONK_URL}/subscription/form`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Forwarded-For": ip, + }, + body, + redirect: "manual", // success redirects to a confirm page (3xx) + }); + // 2xx = rendered confirm page, 3xx = redirect to confirm page → both OK. + return res.status < 400; + } catch (e) { + console.error("[newsletter] listmonk subscribe failed:", e); + return false; + } +} + +/** Best-effort backup record in the RustyCMS forms plugin. Never blocks signup. */ +async function mirrorToCms(payload: Record, ip: string): Promise { + const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, ""); + const cmsEnv = publicEnv.PUBLIC_CMS_ENV || ""; + const cmsUrl = cmsEnv + ? `${cmsBase}/api/forms/newsletter/submit?_environment=${encodeURIComponent(cmsEnv)}` + : `${cmsBase}/api/forms/newsletter/submit`; + try { + await fetch(cmsUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Forwarded-For": ip }, + body: JSON.stringify(payload), + }); + } catch (e) { + console.error("[newsletter] CMS mirror failed (non-fatal):", e); + } +} + +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; + + // Honeypots — must all be empty. Pretend success so bots don't learn the trap. + for (const hp of HONEYPOT_FIELDS) { + const val = body[hp]; + if (typeof val === "string" && val.trim() !== "") { + 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 }); + } + } + + // Validation + const fields = { + name: typeof body.name === "string" ? body.name : "", + email: typeof body.email === "string" ? body.email : "", + consent: body.consent === true, + }; + const errors = validateNewsletter(fields); + if (Object.keys(errors).length > 0) { + return json({ errors }, { status: 422 }); + } + + const email = fields.email.trim(); + const name = fields.name.trim(); + const ip = getClientAddress(); + + // Primary: listmonk subscription (triggers double opt-in) + const subscribed = await subscribeListmonk(email, name, ip); + if (!subscribed) { + return json({ error: "Anmeldung fehlgeschlagen. Bitte später erneut versuchen." }, { status: 502 }); + } + + // Best-effort backup record in CMS (never blocks the signup) + await mirrorToCms( + { + email, + name, + consent_at: new Date().toISOString(), + submitted_via: "windwiderstand.de/api/newsletter", + user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200), + }, + ip, + ); + + return json({ ok: true }, { status: 201 }); +}; + +function safeOrigin(u: string): string { + try { + return new URL(u).origin; + } catch { + return ""; + } +}