diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 233f669..916fac7 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -91,6 +91,11 @@ jobs: PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }} PUBLIC_TURNSTILE_SITE_KEY=${{ secrets.PUBLIC_TURNSTILE_SITE_KEY }} TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }} + MATRIX_NOTIFY_URL=${{ secrets.MATRIX_NOTIFY_URL }} + MATRIX_NOTIFY_TOKEN=${{ secrets.MATRIX_NOTIFY_TOKEN }} + MATRIX_NOTIFY_ROOM=${{ secrets.MATRIX_NOTIFY_ROOM }} + CMS_WEBHOOK_SECRET=${{ secrets.CMS_WEBHOOK_SECRET }} + RUSTYCMS_TERMIN_API_KEY=${{ secrets.RUSTYCMS_TERMIN_API_KEY }} ENVEOF - name: Pull and restart container diff --git a/src/lib/server/matrix.ts b/src/lib/server/matrix.ts new file mode 100644 index 0000000..dbdf2c9 --- /dev/null +++ b/src/lib/server/matrix.ts @@ -0,0 +1,31 @@ +import { env } from "$env/dynamic/private"; + +const MATRIX_URL = (env.MATRIX_NOTIFY_URL || "").replace(/\/$/, ""); +const MATRIX_TOKEN = env.MATRIX_NOTIFY_TOKEN || ""; +const MATRIX_ROOM = env.MATRIX_NOTIFY_ROOM || ""; // raw, e.g. !abc:matrix.pm86.de + +/** + * Fire-and-forget Matrix notification into the configured room. + * No-op (silently) when not configured, so local dev / preview never break. + * Sends both plain `body` and an HTML `formatted_body` (Markdown-ish links). + */ +export async function notifyMatrix(text: string, html?: string): Promise { + if (!MATRIX_URL || !MATRIX_TOKEN || !MATRIX_ROOM) return; + const room = encodeURIComponent(MATRIX_ROOM); + const txn = `ww-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const body: Record = { msgtype: "m.text", body: text }; + if (html) { + body.format = "org.matrix.custom.html"; + body.formatted_body = html; + } + try { + await fetch(`${MATRIX_URL}/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}`, { + method: "PUT", + headers: { Authorization: `Bearer ${MATRIX_TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(8_000), + }); + } catch (e) { + console.error("[matrix] notify failed:", e); + } +} diff --git a/src/routes/api/cms-webhook/+server.ts b/src/routes/api/cms-webhook/+server.ts new file mode 100644 index 0000000..b2368e6 --- /dev/null +++ b/src/routes/api/cms-webhook/+server.ts @@ -0,0 +1,78 @@ +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"; + +// Shared secret RustyCMS sends in X-Webhook-Secret (configured per webhook target). +const WEBHOOK_SECRET = privateEnv.CMS_WEBHOOK_SECRET || ""; +// Optional read key to resolve a human title for the message (best-effort). +const READ_KEY = privateEnv.RUSTYCMS_TERMIN_API_KEY || privateEnv.RUSTYCMS_API_KEY || ""; + +const CMS_ADMIN = "https://cms.pm86.de/admin/content"; + +const EVENT_LABEL: Record = { + "content.created": "🆕 neu angelegt", + "content.updated": "✏️ aktualisiert", + "content.deleted": "🗑️ gelöscht", + "schema.created": "🧩 Schema neu", + "schema.updated": "🧩 Schema geändert", +}; + +async function resolveTitle(collection: string, slug: string, environment: string): Promise { + if (!READ_KEY) return null; + const base = (publicEnv.PUBLIC_CMS_URL || "https://cms.pm86.de").replace(/\/$/, ""); + const url = `${base}/api/content/${collection}/${encodeURIComponent(slug)}?_environment=${encodeURIComponent(environment)}`; + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${READ_KEY}` }, + signal: AbortSignal.timeout(6_000), + }); + if (!res.ok) return null; + const data = (await res.json()) as Record; + const t = data.title ?? data.headline ?? data.name; + return typeof t === "string" ? t : null; + } catch { + return null; + } +} + +export const POST: RequestHandler = async ({ request }) => { + if (!WEBHOOK_SECRET || request.headers.get("x-webhook-secret") !== WEBHOOK_SECRET) { + return json({ error: "unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as Record | null; + if (!body || typeof body !== "object") { + return json({ ok: true }); // ignore malformed, don't make CMS retry + } + + const event = typeof body.event === "string" ? body.event : ""; + const collection = typeof body.collection === "string" ? body.collection : ""; + const slug = typeof body.slug === "string" ? body.slug : ""; + const environment = typeof body.environment === "string" ? body.environment : ""; + + // Only chat about content events; schema noise is opt-in elsewhere. + if (!event.startsWith("content.") || !collection || !slug) { + return json({ ok: true }); + } + + const label = EVENT_LABEL[event] ?? event; + const title = await resolveTitle(collection, slug, environment); + const adminUrl = `${CMS_ADMIN}/${collection}/${slug}`; + const titlePart = title ? `„${title}"` : slug; + const draftHint = collection === "calendar_item" && event === "content.created" + ? " — bitte prüfen & veröffentlichen" + : ""; + + await notifyMatrix( + `RustyCMS ${label}: ${collection} ${titlePart}${draftHint}\n${adminUrl}`, + `RustyCMS ${label}: ${collection} ${title ? `„${escapeHtml(title)}"` : `${escapeHtml(slug)}`}${draftHint} — im Admin öffnen`, + ); + + return json({ ok: true }); +}; + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} diff --git a/src/routes/api/contact/+server.ts b/src/routes/api/contact/+server.ts index 7597b67..ff9cf64 100644 --- a/src/routes/api/contact/+server.ts +++ b/src/routes/api/contact/+server.ts @@ -2,6 +2,7 @@ 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, @@ -123,6 +124,9 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { } 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) { diff --git a/src/routes/api/mitmachen/+server.ts b/src/routes/api/mitmachen/+server.ts index 6fa27d3..98472f3 100644 --- a/src/routes/api/mitmachen/+server.ts +++ b/src/routes/api/mitmachen/+server.ts @@ -2,6 +2,7 @@ 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 { cleanText, HONEYPOT_FIELDS, @@ -141,6 +142,9 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { } if (res.ok) { + void notifyMatrix( + `🤝 Neue Mitmachen-Einsendung: „${payload.title}"${payload.contact_name ? ` — ${payload.contact_name}` : ""}${payload.email ? ` (${payload.email})` : ""}`, + ); return json({ ok: true }, { status: 201 }); } if (res.status === 429) { diff --git a/src/routes/api/newsletter/+server.ts b/src/routes/api/newsletter/+server.ts index c6a3ed4..5580735 100644 --- a/src/routes/api/newsletter/+server.ts +++ b/src/routes/api/newsletter/+server.ts @@ -7,6 +7,7 @@ import { LIMITS, validateNewsletter, } from "$lib/components/internal/newsletter-form-validation"; +import { notifyMatrix } from "$lib/server/matrix"; const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? ""; const TURNSTILE_ENABLED = TURNSTILE_SECRET !== ""; @@ -156,6 +157,8 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { ip, ); + void notifyMatrix(`📰 Neue Newsletter-Anmeldung: ${email}${name ? ` (${name})` : ""}`); + return json({ ok: true }, { status: 201 }); }; diff --git a/src/routes/api/stellungnahme/+server.ts b/src/routes/api/stellungnahme/+server.ts index 363e323..1fa66c4 100644 --- a/src/routes/api/stellungnahme/+server.ts +++ b/src/routes/api/stellungnahme/+server.ts @@ -1,6 +1,7 @@ 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"; @@ -97,6 +98,9 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { } 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) { diff --git a/src/routes/api/termin/+server.ts b/src/routes/api/termin/+server.ts new file mode 100644 index 0000000..a45ec94 --- /dev/null +++ b/src/routes/api/termin/+server.ts @@ -0,0 +1,129 @@ +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"; + +const API_KEY = privateEnv.RUSTYCMS_TERMIN_API_KEY || ""; +const HONEYPOT = "website"; +const MIN_CLIENT_AGE_MS = 3_000; + +const ALLOWED_ORIGINS = new Set([ + "https://windwiderstand.de", + "https://www.windwiderstand.de", + "http://localhost:5173", + "http://localhost:4173", +]); + +// Tiny in-memory per-IP rate limit (single-node container). Curbs draft spam. +const RATE_MAX = 5; +const RATE_WINDOW_MS = 10 * 60_000; +const hits = new Map(); +function rateLimited(ip: string): boolean { + const now = Date.now(); + const arr = (hits.get(ip) ?? []).filter((t) => now - t < RATE_WINDOW_MS); + arr.push(now); + hits.set(ip, arr); + return arr.length > RATE_MAX; +} + +function slugify(s: string): string { + return s + .toLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + const origin = request.headers.get("origin") ?? ""; + const referer = request.headers.get("referer") ?? ""; + const refOrigin = referer ? safeOrigin(referer) : ""; + if (!ALLOWED_ORIGINS.has(origin) && !ALLOWED_ORIGINS.has(refOrigin)) { + return json({ error: "Ungültige Anfrage." }, { status: 403 }); + } + if (!API_KEY) { + console.error("[termin] RUSTYCMS_TERMIN_API_KEY not configured"); + return json({ error: "Einreichen derzeit nicht möglich." }, { status: 503 }); + } + + const raw = (await request.json().catch(() => null)) as Record | null; + if (!raw) return json({ error: "Ungültige Anfrage." }, { status: 400 }); + const str = (k: string) => (typeof raw[k] === "string" ? (raw[k] as string) : ""); + + if (str(HONEYPOT).trim() !== "") return json({ ok: true }, { status: 201 }); // bot trap + const age = Number(raw._client_age_ms); + if (!Number.isFinite(age) || age < MIN_CLIENT_AGE_MS) { + return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 }); + } + if (rateLimited(getClientAddress())) { + return json({ error: "Zu viele Einsendungen. Bitte später erneut." }, { status: 429 }); + } + + // Validation + const title = str("title").trim(); + const terminZeit = str("terminZeit").trim(); + const terminEnde = str("terminEnde").trim(); + const locationText = str("locationText").trim(); + const description = str("description").trim(); + const sourceUrl = str("sourceUrl").trim(); + + const errors: Record = {}; + if (title.length < 3 || title.length > 200) errors.title = "Titel fehlt oder zu lang."; + const start = new Date(terminZeit); + if (!terminZeit || Number.isNaN(start.getTime())) errors.terminZeit = "Datum/Uhrzeit ungültig."; + if (terminEnde && Number.isNaN(new Date(terminEnde).getTime())) errors.terminEnde = "Enddatum ungültig."; + if (locationText.length > 300) errors.locationText = "Ort zu lang."; + if (description.length > 5000) errors.description = "Beschreibung zu lang."; + if (sourceUrl && !/^https?:\/\/\S+$/.test(sourceUrl)) errors.sourceUrl = "Link ungültig."; + if (Object.keys(errors).length) return json({ errors }, { status: 422 }); + + // Build draft entry. User-submitted → draft + "eingereicht" tag for admin triage. + let descFull = description; + if (sourceUrl) descFull += `${descFull ? "\n\n" : ""}Quelle: ${sourceUrl}`; + + const slug = `calendar-item-eingereicht-${Date.now().toString(36)}-${slugify(title) || "termin"}`; + const payload: Record = { + _slug: slug, + _status: "draft", + title, + terminZeit: start.toISOString(), + tags: ["eingereicht"], + }; + if (terminEnde) payload.terminEnde = new Date(terminEnde).toISOString(); + if (locationText) payload.location = { text: locationText }; + if (descFull) payload.description = descFull; + + const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, ""); + const cmsEnv = publicEnv.PUBLIC_CMS_ENV || ""; + const url = cmsEnv + ? `${cmsBase}/api/content/calendar_item?_environment=${encodeURIComponent(cmsEnv)}` + : `${cmsBase}/api/content/calendar_item`; + + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` }, + body: JSON.stringify(payload), + }); + } catch (e) { + console.error("[termin] CMS fetch failed:", e); + return json({ error: "Server nicht erreichbar." }, { status: 502 }); + } + + if (res.ok) return json({ ok: true }, { status: 201 }); + const text = await res.text().catch(() => ""); + console.error("[termin] CMS error", res.status, text); + return json({ error: "Fehler beim Einreichen." }, { status: 502 }); +}; + +function safeOrigin(u: string): string { + try { + return new URL(u).origin; + } catch { + return ""; + } +} diff --git a/src/routes/termin-melden/+page.svelte b/src/routes/termin-melden/+page.svelte new file mode 100644 index 0000000..b89b099 --- /dev/null +++ b/src/routes/termin-melden/+page.svelte @@ -0,0 +1,132 @@ + + + + Termin melden – Windwiderstand + + + +
+

Termin melden

+

+ Veranstaltung, Versammlung oder Aktion zum Thema einreichen. Wir prüfen die Angaben und + schalten den Termin anschließend im Kalender frei. +

+ + {#if formState === "sent"} +
+

Danke, eingereicht.

+

Der Termin erscheint im Kalender, sobald wir ihn geprüft und freigegeben haben.

+ +
+ {:else} +
{ e.preventDefault(); submit(); }}> + + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if error} +

{error}

+ {/if} + +

Eingereichte Termine werden vor der Veröffentlichung geprüft.

+ + +
+ {/if} +