import { randomInt } from 'node:crypto' import express, { Router } from 'express' import { config } from '../config.js' import { analyseDomains, analyseHosts } from '../services/analyse.js' import { legeAnfrageAn } from '../services/unterkunft/ablauf.js' import { belegteBereiche } from '../services/unterkunft/belegung.js' import { istTag } from '../services/unterkunft/datum.js' import { UkFehler } from '../services/unterkunft/fehler.js' import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js' import { ID_RE, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' import { formToken, ipHashVon, pruefeFormToken } from '../services/unterkunft/token.js' import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js' import { heute } from '../services/termine/zeit.js' /** * Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md). * Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js). * Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage. Task 7 ergaenzt die Buchungsseite. * * - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals * (config.publicBaseUrl) oder gleicher Herkunft; sonst 403. * - Grenzen: je IP 120 Abrufe je Minute, je Seite 600 je Minute; Anfragen je IP 10 je 10 Minuten und je Seite * config.unterkunft.anfragenJeSiteStunde je Stunde. * - Fehlerform: { fehler: code, felder? }. */ const router = Router() const BODY_MAX = 16 * 1024 const STUNDE_MS = 60 * 60 * 1000 const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789' const STANDARD_AKZENT = '#8a6a3f' const AKZENT_RE = /^#[0-9a-fA-F]{6}$/ let uhr = () => new Date() export function setzeUhrFuerTests(neueUhr) { uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() } // ---------------------------------------------------------------- Antworten und Fehler const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(fehler), { status, fehlerCode: fehler, ...extra }) const zuViele = (retryAfter) => fehlerMit(429, 'zu_viele_anfragen', { retryAfter }) const nichtErlaubt = () => fehlerMit(403, 'nicht_erlaubt') const nichtGefunden = () => fehlerMit(404, 'nicht_gefunden') const ungueltig = (felder) => fehlerMit(400, 'ungueltig', felder ? { felder } : {}) const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) function fehlerErgebnis(err) { if (err instanceof UkFehler && err.status >= 400 && err.status < 500) { const felder = Object.fromEntries(Object.entries(err.felder ?? {}).filter(([, v]) => typeof v === 'string' || typeof v === 'number')) return { status: err.status, body: { fehler: err.code, ...(Object.keys(felder).length ? { felder } : {}) } } } if (typeof err?.fehlerCode === 'string' && err.status >= 400 && err.status < 500) { const body = { fehler: err.fehlerCode } if (err.felder) body.felder = err.felder return { status: err.status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } } console.error(`[publicUnterkunft] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) return { status: 503, body: { fehler: 'nicht_erreichbar' } } } /** Schreibt { status, body, cache, headers }; GET/HEAD tragen immer `*`, POST nur mit geprueftem cors.erlaubt (Meldungen ohne Daten auch `*`). */ 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', '*') res.status(status) res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) if (status === 204) return res.end() const inhalt = typeof body === 'string' ? body : JSON.stringify(body) res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) return res.end(inhalt) } function notantwort(res, err) { try { console.error(`[publicUnterkunft] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) if (res.headersSent) return void res.end() const inhalt = JSON.stringify({ fehler: '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)) return void res.end(inhalt) } catch { try { res.destroy() } catch { // nichts mehr zu tun } } } const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const minAntwortMs = () => (Number.isFinite(config.unterkunft?.minAntwortMs) && config.unterkunft.minAntwortMs >= 0 ? config.unterkunft.minAntwortMs : 300) /** * 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 minAntwortMs. */ 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 = minAntwortMs() - (performance.now() - req.ukStart) if (rest > 0) await warte(rest) } schreibe(req, res, ergebnis, cors) } catch (err) { notantwort(res, err) } } // ---------------------------------------------------------------- Beginn: Zeit, Rumpf router.use((req, res, next) => { req.ukStart = performance.now() if (Number(req.get('content-length')) > BODY_MAX) { res.set('Connection', 'close') return schreibe(req, res, { status: 413, body: { fehler: 'zu_gross' } }) } return next() }) router.use(express.json({ limit: BODY_MAX })) // ---------------------------------------------------------------- Herkunft /** * Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project), Host von config.publicBaseUrl * oder gleiche Herkunft wie die Anfrage. Sonst null. */ 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 } function pruefeHerkunft(req, cors, project) { const origin = erlaubteHerkunft(req, project) if (!origin) throw nichtErlaubt() cors.erlaubt = origin } 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', }, }) // Preflight: ohne erreichbare Seite gibt es nichts zu pruefen und nichts zu verraten. router.options( '/:siteKey/:was', route(async (req, now, cors) => { if (req.params.was !== 'anfrage') throw nichtGefunden() if (!darfAnfrage(req.ip, now)) throw zuViele(60) const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) if (!ctx) return vorabantwort('*') pruefeHerkunft(req, cors, ctx.project) return vorabantwort(cors.erlaubt) }), ) // ---------------------------------------------------------------- Konfiguration const aktivFalse = () => ({ body: { aktiv: false }, cache: 'public, max-age=15' }) function datenschutzUrl(project) { if (!project) return null const hosts = analyseHosts(project) // [Vorschau-Subdomain, Live-Domain]: die Live-Domain, wenn es eine gibt const host = hosts[hosts.length - 1] return host ? `https://${host}/datenschutz` : null } router.get( '/:siteKey', route(async (req, now) => { if (!darfAnfrage(req.ip, now)) throw zuViele(60) const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) if (!ctx) return aktivFalse() if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) const e = ctx.einstellungen ?? {} const unterkuenfte = await listeUnterkuenfte(ctx.featureId, { nurAktive: true }) return { body: { aktiv: true, anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) }, akzentfarbe: AKZENT_RE.test(e.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT, formToken: formToken(req.params.siteKey, now), unterkuenfte: unterkuenfte.map((u) => ({ id: u.$id, name: u.name, maxPersonen: u.maxPersonen, mindestNaechte: u.mindestNaechte, checkIn: u.checkIn, checkOut: u.checkOut, })), }, } }), ) // ---------------------------------------------------------------- Belegung function monateDazu(tag, n) { const [j, m, t] = tag.split('-').map(Number) const d = new Date(Date.UTC(j, m - 1 + n, 1)) const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate() d.setUTCDate(Math.min(t, letzter)) return d.toISOString().slice(0, 10) } /** Fasst sich beruehrende oder ueberlappende Bereiche (nach von sortiert) zusammen; die Quelle wird nicht ausgegeben. */ function verschmelze(bereiche) { const aus = [] for (const b of bereiche) { const letzter = aus[aus.length - 1] if (letzter && b.von <= letzter.bis) { if (b.bis > letzter.bis) letzter.bis = b.bis } else aus.push({ von: b.von, bis: b.bis }) } return aus } router.get( '/:siteKey/belegung', route(async (req, now) => { if (!darfAnfrage(req.ip, now)) throw zuViele(60) const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) if (!ctx) return aktivFalse() if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) if (typeof req.query.unterkunft === 'string' && !ID_RE.test(req.query.unterkunft)) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' }) const { unterkunft, von, bis } = req.query if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis < von) { throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' }) } const tag = heute(now) const bisMax = monateDazu(tag, config.unterkunft.horizontMonate) const bisBis = bis > bisMax ? bisMax : bis // Fenster des Widgets (UTC-Datum des Browsers) darf nie an einem Tag Abweichung scheitern const u = await ladeUnterkunft(unterkunft) if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden() const ab = von < tag ? tag : von const belegt = ab < bisBis ? verschmelze(await belegteBereiche(u.$id, ab, bisBis, { now })) : [] return { body: { belegt, heute: tag, bisMax } } }), ) // ---------------------------------------------------------------- Anfrage function scheinNummer() { let n = 'A-' for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)] return n } const text = (wert) => (typeof wert === 'string' ? wert : '') router.post( '/:siteKey/anfrage', route( async (req, now, cors) => { const { siteKey } = req.params if (!darfAnfrage(req.ip, now)) throw zuViele(60) const ctx = await unterkunftPerSiteKey(siteKey, { now }) if (!ctx) return { body: { aktiv: false } } pruefeHerkunft(req, cors, ctx.project) if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600) const b = req.body if (!istObjekt(b)) throw ungueltig() // Honigtopf gefuellt oder zu schnell abgeschickt: dieselbe Erfolgsantwort, gespeichert und gesendet wird nichts. if (b.kontakt_url !== undefined && b.kontakt_url !== null && b.kontakt_url !== '') return { status: 201, body: { nummer: scheinNummer() } } const pruefung = pruefeFormToken(siteKey, b.formToken, now) if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } } if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden') if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600) if (!ID_RE.test(text(b.unterkunft))) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' }) const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen const anfrage = await legeAnfrageAn( ctx, { unterkunftId: text(b.unterkunft), von: b.von, bis: b.bis, personen, gast: { name: text(b.name), firma: text(b.firma), email: text(b.email), telefon: text(b.telefon), nachricht: text(b.nachricht) }, }, { now, ipHash: ipHashVon(req.ip) }, ) return { status: 201, body: { nummer: anfrage.nummer } } }, { mindestdauer: true }, ), ) // Rumpf nicht lesbar (kaputtes JSON): 400 in der Fehlerform der Schnittstelle router.use((err, req, res, next) => { if (err?.type === 'entity.parse.failed' || err?.type === 'entity.too.large') { return schreibe(req, res, { status: err.type === 'entity.too.large' ? 413 : 400, body: { fehler: err.type === 'entity.too.large' ? 'zu_gross' : 'ungueltig' } }) } return next(err) }) // Task 7: GET|POST /:siteKey/buchung/:id, POST /:siteKey/buchung/:id/zahlung, GET /:siteKey/buchung/:id/rechnung.pdf export default router