import { createHash, timingSafeEqual } from 'node:crypto' import express, { Router } from 'express' import { config } from '../config.js' import { analyseDomains } from '../services/analyse.js' import { MELDUNG, bestaetige, fehlerMit, fehlerSammler, halte, istObjekt, pruefeEingabe, pruefeSlot, sageAbGast, verschiebeGast, vorschau, widerrufeGast, widerrufeOhneLink, zeitenFuerToken, } from '../services/termine/ablauf.js' import { imZeitraum } from '../services/termine/buchungen.js' import { brauchtZustimmungVorzeitig, emailAdresse, oeffentlicheEinstellung } from '../services/termine/einstellungen.js' import { icsKalender } from '../services/termine/ics.js' import { kontextPerSiteKey } from '../services/termine/kontext.js' import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, darfG01, darfHaltung, darfWiderrufsversuch, } from '../services/termine/limits.js' import { GESUNDHEIT_HINWEIS, ZUSTIMMUNG_ERINNERUNG, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_VORZEITIG, formularHinweis, widerrufsbelehrung, } from '../services/termine/rechtstexte.js' import { belegungAus, freieTage, freieZeiten } from '../services/termine/slots.js' import { termineAus } from '../services/termine/schalter.js' import { buchungsNummer, formToken, pruefeFormToken } from '../services/termine/token.js' import { datumLang, monatInfo, zuUtc } from '../services/termine/zeit.js' /** * Oeffentliche Schnittstelle des Terminplaners: /api/public/termine/v1 (Spezifikation 2026-10-02-terminplaner-design.md, * Abschnitt 8). Ohne Session, ohne Captcha; der Schutz besteht aus Herkunftspruefung (CORS), Honeypot und Formular-Token, * Grenzen im Speicher (services/termine/limits.js) und einer Mindestantwortzeit. * * - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem * globalen express.json (2 MB): so liest nie der grosse Parser den Rumpf einer oeffentlichen Anfrage. Alles unterhalb des * Pfades beantwortet dieser Router selbst. Antworten sind immer JSON { ... } oder { fehler: { : Text }, code? }; * Rohtexte von Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar"). * - Antworten enthalten nie customerId, projectId, featureId, $id, Ticket- oder Stripe-IDs: nur Leistungs-/Ressourcen-IDs * der Einstellung und die Buchungsnummer. Die Geschaeftsregeln stehen in services/termine/*; hier wird nur gelesen, * geprueft, begrenzt und uebersetzt. * - Unbekannter, inaktiver, abgeschalteter siteKey oder Modus off: immer 200 { aktiv: false } (GET und POST unter * /:siteKey). Die Token-Seiten (/termin/*) bleiben fuer abgeschaltete Funktionen nutzbar (Absagen, Widerruf), bei Modus * off antworten sie wie bei einem unbekannten Link, ohne Abfrage. * - CORS: GET fuer alle (*); POST und Preflight nur von Seiten des Betriebs (Hosts des Projekts, analyseDomains), des * Portals (Host von config.publicBaseUrl) oder gleicher Herkunft, die Token-Seiten nur von den beiden letzten. Eine * fremde oder fehlende Origin ist 403. Fuer einen unbekannten siteKey laesst sich nichts pruefen: dort { aktiv: false }. * Meldungen ohne Daten (429, 503, aktiv:false) tragen `*`, damit das Widget sie lesen kann. * - Buchung (POST /:siteKey/buchungen), in dieser Reihenfolge: Grenze je IP (jeder Versuch zaehlt) => Funktion pruefen => * Herkunft => Honeypot kontakt_url und Formular-Token (zu schnell oder Honeypot: dieselbe Erfolgsantwort, nichts * gespeichert; zu alt oder fremd: 400 "Bitte laden Sie die Seite neu.") => Eingabe und Zeit pruefen (Fehler des Gastes * zuerst, damit keine Grenze etwas verraet) => Grenze je siteKey (429) => Grenze G01 je Adresse (still: dieselbe 201-Antwort, * aber keine Buchung und keine Mail) => halten. "Zu viele offene Termine" und "gehalten" antworten gleich (kein Orakel * fuer Dritte). * - Mindestantwortzeit config.termine.minAntwortMs fuer die Buchung und beide Widerrufe (auch bei Fehlern), gemessen ab * Beginn der Anfrage: verwischt, ob eine Adresse oder Buchung bekannt ist. * - tage und zeiten: 10 Sekunden Speicher-Cache je (siteKey, Parameter); config und alle anderen Antworten ohne ETag. */ const router = Router() const BODY_MAX = 16 * 1024 const TAG_MS = 24 * 60 * 60 * 1000 const CACHE_MS = 10 * 1000 const CACHE_MAX = 10000 const FEED_DATEI_RE = /^([A-Za-z0-9_-]{43})\.ics$/ const FEED_VOR_MS = 7 * TAG_MS const FEED_NACH_MS = 90 * TAG_MS const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } } const TEXT_NICHT_GEFUNDEN = 'Nicht gefunden' const NICHT_GEFUNDEN = { fehler: { _: TEXT_NICHT_GEFUNDEN } } const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } } const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } } const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' const MELDUNG_NEU_LADEN = 'Bitte laden Sie die Seite neu.' // Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). let uhr = () => new Date() export function setzeUhrFuerTests(neueUhr) { uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() } /** Speicher-Cache von tage und zeiten: Schluessel -> { bis (ms), body }; hoechstens CACHE_MAX Eintraege. */ const cache = new Map() export function resetPublicTermineForTests() { cache.clear() uhr = () => new Date() } // ---------------------------------------------------------------- Antworten und Fehler /** Fehler mit Statuscode fuer den Gast (4xx); `fehlerErgebnis` macht daraus die Antwort. */ const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS }) const nichtGefunden = () => fehlerMit(404, { _: TEXT_NICHT_GEFUNDEN }) const nichtErlaubt = () => fehlerMit(403, { _: 'Nicht erlaubt' }) const unbekannterLink = () => fehlerMit(404, { _: MELDUNG.unbekannt }, { code: 'unbekannt' }) const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {}) /** Antwort fuer einen Fehler: Fehler des Gastes (4xx mit `fehler` aus den Diensten) wie sie sind, alles andere 503 ohne Rohtext. */ function fehlerErgebnis(err) { const status = err?.status const fehler = nurTexte(err?.fehler) if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) { const body = { fehler } if (typeof err.code === 'string') body.code = err.code if (istObjekt(err.betrieb)) body.betrieb = nurTexte(err.betrieb) return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } } console.error(`[publicTermine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) return { status: 503, body: NICHT_ERREICHBAR } } /** Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null }. */ function schreibe(req, res, ergebnis, cors = { erlaubt: null }) { const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis const lesen = req.method === 'GET' || req.method === 'HEAD' if (lesen) res.set('Access-Control-Allow-Origin', '*') else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' }) else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*') // Meldungen ohne Daten res.status(status) res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) if (status === 204) return res.end() // Preflight: nur Kopfzeilen const inhalt = typeof body === 'string' ? body : JSON.stringify(body) res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) return res.end(inhalt) } /** * Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert (z. B. nicht serialisierbarer Wert): ein schlichtes 503 * mit fester Meldung, sofern noch nichts gesendet wurde; sonst wird die Antwort beendet. Eine Anfrage bleibt nie haengen und * der async Handler lehnt nie unbehandelt ab (Express 4 faengt das nicht). */ function notantwort(res, err) { try { console.error(`[publicTermine] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) } catch { // das Log darf die Notantwort nicht verhindern } try { if (res.headersSent) return void res.end() const inhalt = JSON.stringify(NICHT_ERREICHBAR) res.statusCode = 503 res.setHeader('Content-Type', 'application/json; charset=utf-8') res.setHeader('Cache-Control', 'no-store') res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII return void res.end(inhalt) } catch { try { res.destroy() } catch { // nichts mehr zu tun } } } /** schreibe, aber mit der Notantwort statt eines Fehlers (fuer Stellen ausserhalb von route). */ function schreibeSicher(req, res, ergebnis, cors) { try { schreibe(req, res, ergebnis, cors) } catch (err) { notantwort(res, err) } } /** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Funktionen (GET kurz im Browser-Cache). */ const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' }) const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) /** * Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe oder * wirft; cors.erlaubt setzt fn, sobald die Herkunft geprueft ist. Mit mindestdauer antwortet auch ein Fehler nicht * schneller als config.termine.minAntwortMs (ab Beginn der Anfrage, auch beim Lesen des Rumpfes). */ const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { try { const cors = { erlaubt: null } let ergebnis try { ergebnis = await fn(req, uhr(), cors) } catch (err) { ergebnis = fehlerErgebnis(err) } if (mindestdauer) { const rest = config.termine.minAntwortMs - (performance.now() - req.termineStart) if (rest > 0) await warte(rest) } schreibe(req, res, ergebnis, cors) } catch (err) { notantwort(res, err) } } // ---------------------------------------------------------------- Herkunft /** * Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project) (Seiten des Betriebs), Host von * config.publicBaseUrl (Gaesteseiten des Portals) oder gleiche Herkunft wie die Anfrage (Host-Header). Ohne project (die * Token-Seiten /termin/*) gelten nur die beiden letzten. Sonst null (fehlende, fremde, unlesbare Origin oder nicht in * kanonischer Schreibweise). */ function erlaubteHerkunft(req, project) { const origin = req.get('origin') if (typeof origin !== 'string' || origin === '') return null let url try { url = new URL(origin) } catch { return null } if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null const erlaubt = new Set([String(req.get('host') || '').toLowerCase()]) try { erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase()) } catch { // Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft } if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain) return erlaubt.has(url.host) ? origin : null } /** Prueft die Herkunft eines POST und merkt sie in cors; sonst 403. */ function pruefeHerkunft(req, cors, project) { const origin = erlaubteHerkunft(req, project) if (!origin) throw nichtErlaubt() cors.erlaubt = origin } // ---------------------------------------------------------------- Beginn: Zeit, Rumpf router.use((req, res, next) => { req.termineStart = performance.now() // Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen). if (Number(req.get('content-length')) > BODY_MAX) { res.set('Connection', 'close') return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) } return next() }) router.use(express.json({ limit: BODY_MAX })) /** Antwort auf einen Preflight (204): erlaubte Methoden, Content-Type, 24 Stunden gueltig. */ const vorabantwort = (origin) => ({ status: 204, body: '', headers: { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '86400', Vary: 'Origin', }, }) // ---------------------------------------------------------------- Token-Seiten (/termin/*) const rumpf = (req) => (istObjekt(req.body) ? req.body : {}) /** * Aufruf mit Token: Herkunft nur Portal und gleiche Herkunft, Grenze je IP (widerruf: zusaetzlich Widerrufe je IP), bei * Modus off wie ein unbekannter Link ohne Abfrage. fn(body, now) liefert den Body der 200-Antwort. */ const terminRoute = (fn, { widerruf = false } = {}) => route( async (req, now, cors) => { pruefeHerkunft(req, cors, null) if (!darfAnfrage(req.ip, now)) throw zuViele(60) if (widerruf && !darfWiderrufsversuch(req.ip, now)) throw zuViele(3600) if (termineAus()) throw unbekannterLink() return { body: await fn(rumpf(req), now) } }, { mindestdauer: widerruf }, ) router.post('/termin/vorschau', terminRoute((b, now) => vorschau(b.token, now))) router.post('/termin/bestaetigen', terminRoute(async (b, now) => ({ ok: true, ...(await bestaetige(b.token, now)) }))) router.post('/termin/absagen', terminRoute(async (b, now) => ({ ok: true, ...(await sageAbGast(b.token, now)) }))) router.post( '/termin/verschieben', terminRoute(async (b, now) => ({ ok: true, ...(await verschiebeGast(b.token, { tag: b.tag, zeit: b.zeit }, now)) })), ) router.post( '/termin/widerruf', terminRoute(async (b, now) => ({ ok: true, ...(await widerrufeGast(b.token, { name: b.name }, now)) }), { widerruf: true }), ) // zeitenFuerToken unterscheidet Monat und Tag daran, welcher Schluessel gesetzt ist: fehlt der gefragte, ist er leer (=> 400) router.post( '/termin/tage', terminRoute((b, now) => zeitenFuerToken(b.token, { monat: typeof b.monat === 'string' ? b.monat : '' }, now)), ) router.post( '/termin/zeiten', terminRoute((b, now) => zeitenFuerToken(b.token, { tag: typeof b.tag === 'string' ? b.tag : '' }, now)), ) router.options( '/termin/:aktion', route(async (req, now, cors) => { pruefeHerkunft(req, cors, null) if (!darfAnfrage(req.ip, now)) throw zuViele(60) return vorabantwort(cors.erlaubt) }), ) // ---------------------------------------------------------------- Kalender-Abo const sha256 = (text) => createHash('sha256').update(text).digest() /** Die Ressource, deren feedToken zum gegebenen passt (Vergleich in konstanter Zeit ueber alle Ressourcen); sonst null. */ function ressourceZuFeedToken(einstellung, token) { const gegeben = sha256(token) let treffer = null for (const r of einstellung.ressourcen || []) { if (typeof r?.feedToken === 'string' && timingSafeEqual(sha256(r.feedToken), gegeben)) treffer = r } return treffer } router.get( '/kalender/:siteKey/:datei', route(async (req, now) => { const { siteKey, datei } = req.params if (!darfAnfrage(req.ip, now)) throw zuViele(60) const format = FEED_DATEI_RE.exec(datei) const ctx = format ? await kontextPerSiteKey(siteKey, now) : null const ressource = ctx ? ressourceZuFeedToken(ctx.einstellung, format[1]) : null if (!ressource) throw nichtGefunden() // ohne Hinweis, was nicht stimmt if (!darfAnfrageSite(siteKey, now)) throw zuViele(60) const e = ctx.einstellung const nowMs = now.getTime() const liste = await imZeitraum(ctx.feature.$id, new Date(nowMs - FEED_VOR_MS), new Date(nowMs + FEED_NACH_MS)) const termine = liste .filter((b) => b.ressourceId === ressource.id && b.status === 'bestaetigt') .sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)) .map((b) => ({ uid: `${b.$id}@termine.webklar.com`, sequenz: Number.isInteger(b.icsSequenz) && b.icsSequenz >= 0 ? b.icsSequenz : 0, start: b.startsAt, ende: b.endsAt, titel: b.quelle === 'sperre' ? b.leistungName || 'Gesperrt' : b.gastName ? `${b.leistungName}: ${b.gastName}` : b.leistungName, ort: b.quelle === 'sperre' ? '' : e.betrieb.adresse, beschreibung: b.quelle === 'sperre' ? '' : `Buchungsnummer: ${buchungsNummer(b.$id)}`, })) return { body: icsKalender(termine, { name: `${e.betrieb.name} – ${ressource.name}`, jetzt: now }), typ: 'text/calendar; charset=utf-8', cache: 'private, max-age=300', // enthaelt Gastnamen: nur im Cache des Abonnenten, nicht in gemeinsamen Caches headers: { 'X-Robots-Tag': 'noindex', 'Referrer-Policy': 'no-referrer' }, } }), ) // ---------------------------------------------------------------- Konfiguration, Tage, Zeiten /** Lesende Abrufe: Grenze je IP, Funktion suchen ({ aktiv: false } wenn es sie nicht gibt), Grenze je siteKey. */ const lesenRoute = (fn) => route(async (req, now) => { const { siteKey } = req.params if (!darfAnfrage(req.ip, now)) throw zuViele(60) const ctx = await kontextPerSiteKey(siteKey, now) if (!ctx) return aktivFalse(req) if (!darfAnfrageSite(siteKey, now)) throw zuViele(60) return fn(ctx, req, now) }) router.get( '/:siteKey/config', lesenRoute((ctx, req, now) => { const e = ctx.einstellung const oeffentlich = oeffentlicheEinstellung(e, now) const texte = { storno: ZUSTIMMUNG_STORNO, erinnerung: ZUSTIMMUNG_ERINNERUNG(e.erinnerung.stundenVorher), vorzeitig: ZUSTIMMUNG_VORZEITIG(e.betrieb.name), gesundheit: GESUNDHEIT_HINWEIS, } // Das Widget zeigt die Belehrung vor "Zahlungspflichtig buchen" (R14); fehlt eine Angabe, wirft sie => 503 statt einer Buchung ohne Belehrung if (oeffentlich.widerruf) texte.widerrufsbelehrung = widerrufsbelehrung(e.betrieb) return { // formMinSekunden (R53): so alt muss das Formular-Token sein, sonst antwortet POST ohne Speichern; das Widget wartet entsprechend body: { aktiv: true, ...oeffentlich, hinweis: formularHinweis(e), texte, formToken: formToken(req.params.siteKey, now), formMinSekunden: config.termine.formMinSekunden }, cache: 'public, max-age=60', // das Formular-Token ist bei jedem Abruf neu: kein ETag } }), ) /** Leistung und Ressource aus der Abfrage (wie das Formular sie annimmt); Fehler gehen an fehl. => { leistung, ressource } oder null */ function leistungUndRessource(e, query, fehl) { const leistung = typeof query.leistung === 'string' ? (e.leistungen || []).find((l) => l.id === query.leistung && l.aktiv) : undefined const aktiv = new Set((e.ressourcen || []).filter((r) => r.aktiv).map((r) => r.id)) const infrage = leistung ? (leistung.ressourcen || []).filter((id) => aktiv.has(id)) : [] if (!leistung || infrage.length === 0) { fehl('leistung', MELDUNG.leistung) return null } const ressource = query.ressource === undefined ? 'egal' : query.ressource if (ressource !== 'egal' && !(typeof ressource === 'string' && infrage.includes(ressource))) { fehl('ressource', MELDUNG.ressource) return null } return { leistung, ressource } } /** Antwort aus dem 10-Sekunden-Speicher oder frisch berechnet. */ async function ausCache(schluessel, nowMs, berechne) { const gemerkt = cache.get(schluessel) if (gemerkt && gemerkt.bis > nowMs) return gemerkt.body const body = await berechne() if (cache.size >= CACHE_MAX) { for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key) if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value) } cache.set(schluessel, { bis: nowMs + CACHE_MS, body }) return body } router.get( '/:siteKey/tage', lesenRoute(async (ctx, req, now) => { const e = ctx.einstellung const { fehler, fehl } = fehlerSammler() const wahl = leistungUndRessource(e, req.query, fehl) const monat = typeof req.query.monat === 'string' ? req.query.monat : '' const info = monatInfo(monat) const von = info ? zuUtc(`${monat}-01`, '00:00') : null const bis = info ? zuUtc(`${info.nachher}-01`, '00:00') : null if (!von || !bis) fehl('monat', MELDUNG.monat) if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) const schluessel = `tage|${req.params.siteKey}|${wahl.leistung.id}|${wahl.ressource}|${monat}` const body = await ausCache(schluessel, now.getTime(), async () => { const imMonat = await imZeitraum(ctx.feature.$id, von, bis) const tage = freieTage({ einstellung: e, leistungId: wahl.leistung.id, ressourceId: wahl.ressource, monat, belegung: belegungAus(imMonat, now), now }) return { tage, monat: info } }) return { body, cache: 'public, max-age=15' } }), ) router.get( '/:siteKey/zeiten', lesenRoute(async (ctx, req, now) => { const e = ctx.einstellung const { fehler, fehl } = fehlerSammler() const wahl = leistungUndRessource(e, req.query, fehl) const tag = typeof req.query.tag === 'string' ? req.query.tag : '' const mittag = zuUtc(tag, '12:00') if (!mittag) fehl('tag', MELDUNG.tag) if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) const schluessel = `zeiten|${req.params.siteKey}|${wahl.leistung.id}|${wahl.ressource}|${tag}` const body = await ausCache(schluessel, now.getTime(), async () => { const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00')) const zeiten = freieZeiten({ einstellung: e, leistungId: wahl.leistung.id, ressourceId: wahl.ressource, tag, belegung: belegungAus(amTag, now), now }) return { zeiten: zeiten.map((z) => z.uhrzeit), datumLang: datumLang(mittag), // Zustimmung zum vorzeitigen Beginn: serverseitig fuer diesen Tag, das Widget rechnet nie selbst vorzeitigNoetig: zeiten.some((z) => brauchtZustimmungVorzeitig(e, wahl.leistung, z.start, now)), } }) return { body, cache: 'no-store' } }), ) // ---------------------------------------------------------------- Buchen und Widerruf (POST /:siteKey/...) /** Maskierte Adresse fuer die Antwort: 'm***@gmail.com' (erstes Zeichen, Rest des Namens verdeckt); ohne Adresse '***'. */ function maskiere(adresse) { const at = typeof adresse === 'string' ? adresse.lastIndexOf('@') : -1 return at < 1 ? '***' : `${[...adresse][0]}***${adresse.slice(at)}` } /** Die Erfolgsantwort der Buchung (auch bei Honeypot, zu schnell, zu viele offene Termine und Mailgrenze). */ const erfolg = (adresse) => ({ status: 201, body: { ok: true, email: maskiere(adresse), minuten: config.termine.holdMinutes } }) /** Nur die Felder des Formulars (kein Durchreichen fremder Schluessel an die Dienste). */ const formularFelder = (b) => ({ leistung: b.leistung, ressource: b.ressource, tag: b.tag, zeit: b.zeit, name: b.name, email: b.email, telefon: b.telefon, nachricht: b.nachricht, zusatz: b.zusatz, erinnerung: b.erinnerung, zustimmungen: b.zustimmungen, }) router.post( '/:siteKey/buchungen', route( async (req, now, cors) => { const { siteKey } = req.params if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600) const ctx = await kontextPerSiteKey(siteKey, now) if (!ctx) return aktivFalse(req) pruefeHerkunft(req, cors, ctx.project) const b = req.body if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG.formular }) // Honeypot (jeder Wert ausser leer) und zu schnell abgeschickt: dieselbe Antwort wie sonst, nichts wird gespeichert const honig = b.kontakt_url const falle = honig !== undefined && honig !== null && !(typeof honig === 'string' && honig.trim() === '') if (falle) return erfolg(emailAdresse(b.email)) const token = pruefeFormToken(siteKey, b.formToken, now) if (token === 'zu_schnell') return erfolg(emailAdresse(b.email)) if (token !== 'ok') throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) // Fehler des Gastes (Eingabe, nicht mehr freie Zeit) vor allen Grenzen, die von der Adresse abhaengen const eingabe = formularFelder(b) const gepruft = pruefeEingabe(ctx.einstellung, eingabe, now) await pruefeSlot(ctx, gepruft, now) if (!darfHaltung(siteKey, now)) throw zuViele(3600) if (!darfG01(ctx.feature.$id, gepruft.email, now)) return erfolg(gepruft.email) // still: keine Buchung, keine Mail const ergebnis = await halte(ctx, eingabe, now) return erfolg(ergebnis.email) }, { mindestdauer: true }, ), ) router.post( '/:siteKey/widerruf', route( async (req, now, cors) => { if (!darfWiderrufsversuch(req.ip, now)) throw zuViele(3600) const ctx = await kontextPerSiteKey(req.params.siteKey, now) if (!ctx) return aktivFalse(req) pruefeHerkunft(req, cors, ctx.project) const b = req.body const erklaerung = istObjekt(b) ? { name: b.name, email: b.email, nummer: b.nummer } : b const { eingang } = await widerrufeOhneLink(ctx, erklaerung, now) return { body: { ok: true, eingang } } }, { mindestdauer: true }, ), ) // Preflight unter /:siteKey/*: erlaubt fuer Herkunft des Betriebs, des Portals und gleiche Herkunft; ohne Funktion nichts zu pruefen router.options( '/:siteKey/:was', route(async (req, now, cors) => { if (!darfAnfrage(req.ip, now)) throw zuViele(60) const ctx = await kontextPerSiteKey(req.params.siteKey, now) if (!ctx) return vorabantwort('*') pruefeHerkunft(req, cors, ctx.project) return vorabantwort(cors.erlaubt) }), ) // ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN })) // Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers router.use((err, req, res, _next) => { if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG }) return schreibeSicher(req, res, fehlerErgebnis(err)) }) export default router