Files
Webklar-Kundenbereich/server/routes/publicTermine.js
T
ClaudeandClaude Sonnet 5.5 bd65c9dc58 Terminplaner: Abschluss – Server (Widerruf auch bei Anfragen, Gastdaten löschen vollständig, formMinSekunden)
- R55: Widerrufsbelehrung, Anschrift-Pflicht und Knopf "Vertrag widerrufen" gelten für jede
  Verbraucher-Buchung mit sichtbarer Leistung, auch Anfragen (zeigtWiderruf); der Nachweis
  hält die Belehrung auch bei Anfragen fest. Damit bleibt die Annahme einer Anfrage nie mehr
  still ohne G02.
- "Gastdaten löschen" leert auch absageGrund und löscht noch nicht versandte Mails der Buchung
  (neu: verwerfeWartende in der Warteschlange).
- R53: GET /:siteKey/config nennt formMinSekunden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

585 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: { <feld|_>: 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