Buchungskalender: öffentliche API für Kalender und Anfrage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7b5b98187
commit
035967f8eb
3 files changed
+522
-7
No files matched your search
@@ -1,17 +1,330 @@
|
||||
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 { 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).
|
||||
* Geruest (Task 5): Task 6 fuellt Konfiguration, Belegung und Anfrage, Task 7 die Buchungsseite.
|
||||
* 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()
|
||||
router.use(express.json({ limit: '16kb' }))
|
||||
|
||||
// Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage
|
||||
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)
|
||||
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)
|
||||
if (bis > bisMax) throw ungueltig({ zeitraum: 'Der Zeitraum liegt zu weit in der Zukunft.' })
|
||||
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 < bis ? verschmelze(await belegteBereiche(u.$id, ab, bis, { 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
|
||||
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)
|
||||
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
|
||||
|
||||
/** Solange nichts gefuellt ist: jede Seite ist (noch) nicht aktiv, ohne zu verraten, ob der Schluessel existiert. */
|
||||
router.get('/:siteKey', (_req, res) => res.json({ aktiv: false }))
|
||||
|
||||
export default router
|
||||
Reference in new issue
Block a user