Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz
Router /api/public/termine/v1 (config, tage, zeiten, buchungen, widerruf, Token-Seiten /termin/*, Kalender-Abo) mit eigenem 16-KB-Parser und Fehlermiddleware, CORS nach Herkunft, Honeypot und Formular-Token, Grenzen je IP (IPv6 je /64), siteKey und Adresse in limits.js, Mindestantwortzeit (TERMINE_MIN_ANTWORT_MS) und 10-Sekunden-Cache fuer tage/zeiten. Vor dem globalen express.json eingehaengt; /api/public/ ist vom Kundenansicht-Waechter ausgenommen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
43ac05aa06
commit
2b0c05b8e9
10 files changed
+1928
-7
No files matched your search
@@ -388,6 +388,9 @@ export const config = {
|
|||||||
mailsProStunde: 200,
|
mailsProStunde: 200,
|
||||||
// Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
|
// Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
|
||||||
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
|
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
|
||||||
|
// Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob
|
||||||
|
// eine Adresse oder Buchung bekannt ist (Zeitunterschied). Tests setzen 0.
|
||||||
|
minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300),
|
||||||
},
|
},
|
||||||
|
|
||||||
invoiceNinja: {
|
invoiceNinja: {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import adminEmployeesRoutes from './routes/admin/employees.js'
|
|||||||
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
||||||
import newsRoutes from './routes/news.js'
|
import newsRoutes from './routes/news.js'
|
||||||
import publicNewsRoutes from './routes/publicNews.js'
|
import publicNewsRoutes from './routes/publicNews.js'
|
||||||
|
import publicTermineRoutes from './routes/publicTermine.js'
|
||||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||||
import { analyseLegalRouter } from './services/analyseLegal.js'
|
import { analyseLegalRouter } from './services/analyseLegal.js'
|
||||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||||
@@ -54,6 +55,9 @@ app.use(sessionMiddleware())
|
|||||||
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen
|
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen
|
||||||
// Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften
|
// Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften
|
||||||
app.use(kundenansichtGuard)
|
app.use(kundenansichtGuard)
|
||||||
|
// Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und
|
||||||
|
// haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde
|
||||||
|
app.use('/api/public/termine/v1', publicTermineRoutes)
|
||||||
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
|
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
|
||||||
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
|
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
|
||||||
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
|
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export const KUNDENANSICHT_ALLOWED = [
|
|||||||
['POST', /^\/api\/auth\/login\/?$/],
|
['POST', /^\/api\/auth\/login\/?$/],
|
||||||
]
|
]
|
||||||
|
|
||||||
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
|
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen); oeffentliche Schnittstellen
|
||||||
const PASS_PREFIXES = ['/api/team/', '/api/admin/']
|
// (/api/public/: Pinnwand, Terminplaner fuer Gaeste) kennen keine Sitzung und ignorieren ein Kundenansicht-Cookie im Browser
|
||||||
|
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/public/']
|
||||||
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
|
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
|
||||||
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
|
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** { 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) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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 schreibe(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 {
|
||||||
|
body: { aktiv: true, ...oeffentlich, hinweis: formularHinweis(e), texte, formToken: formToken(req.params.siteKey, now) },
|
||||||
|
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) => schreibe(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 schreibe(req, res, { status: 413, body: ZU_GROSS })
|
||||||
|
if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibe(req, res, { status: 400, body: UNGUELTIG })
|
||||||
|
return schreibe(req, res, fehlerErgebnis(err))
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isIPv4, isIPv6 } from 'node:net'
|
||||||
import { pruefeNow, pruefeText } from './util.js'
|
import { pruefeNow, pruefeText } from './util.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,9 +9,12 @@ import { pruefeNow, pruefeText } from './util.js'
|
|||||||
* Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht).
|
* Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht).
|
||||||
*
|
*
|
||||||
* Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt).
|
* Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt).
|
||||||
* Mails des Widerrufs (Aufgabe 13): darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende
|
* Mails des Widerrufs: darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende
|
||||||
* Buchung, ein Treffer bekommt seine Mails immer. Die Grenzen je IP, siteKey und G01 ergaenzt
|
* Buchung, ein Treffer bekommt seine Mails immer.
|
||||||
* Aufgabe 16 in diesem Modul.
|
* Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe
|
||||||
|
* je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01
|
||||||
|
* (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist
|
||||||
|
* ein ganzes /64, die Adresse darin wechselt beliebig.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MAX_SCHLUESSEL = 10000
|
const MAX_SCHLUESSEL = 10000
|
||||||
@@ -23,6 +27,20 @@ const G08_JE_FUNKTION = 20
|
|||||||
/** K21 "nicht zugeordnet": je Funktion und Stunde. */
|
/** K21 "nicht zugeordnet": je Funktion und Stunde. */
|
||||||
const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 30
|
const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 30
|
||||||
|
|
||||||
|
const MINUTE_MS = 60 * 1000
|
||||||
|
|
||||||
|
/** Lesende Abrufe und Aufrufe mit Token: je IP und Minute, je siteKey und Minute. */
|
||||||
|
const ANFRAGEN_JE_IP = 120
|
||||||
|
const ANFRAGEN_JE_SITE = 600
|
||||||
|
/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */
|
||||||
|
const BUCHUNGEN_JE_IP = 10
|
||||||
|
const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS
|
||||||
|
const HALTUNGEN_JE_SITE = 60
|
||||||
|
/** Widerrufserklaerungen: je IP und Stunde. */
|
||||||
|
const WIDERRUFE_JE_IP = 10
|
||||||
|
/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */
|
||||||
|
const G01_JE_ADRESSE = 5
|
||||||
|
|
||||||
/** schluessel -> { start (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. */
|
/** schluessel -> { start (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. */
|
||||||
const zaehler = new Map()
|
const zaehler = new Map()
|
||||||
|
|
||||||
@@ -80,6 +98,74 @@ export function darfK21NichtZugeordnet(featureId, now) {
|
|||||||
return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now)
|
return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- IP, siteKey, G01
|
||||||
|
|
||||||
|
const acht = (gruppen) => gruppen.length === 8 && gruppen.every((g) => /^[0-9a-f]{1,4}$/.test(g))
|
||||||
|
|
||||||
|
/** Die acht Gruppen einer IPv6-Adresse (ohne Zonenkennung, Kurzform "::" und eingebettete IPv4 aufgeloest) oder null. */
|
||||||
|
function ipv6Gruppen(adresse) {
|
||||||
|
let s = adresse.split('%')[0]
|
||||||
|
const v4 = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(s)
|
||||||
|
if (v4) {
|
||||||
|
const [a, b, c, d] = v4.slice(1).map(Number)
|
||||||
|
s = `${s.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`
|
||||||
|
}
|
||||||
|
const teile = s.split('::')
|
||||||
|
if (teile.length > 2) return null
|
||||||
|
const links = teile[0] === '' ? [] : teile[0].split(':')
|
||||||
|
const rechts = teile.length === 2 && teile[1] !== '' ? teile[1].split(':') : []
|
||||||
|
const gruppen = teile.length === 2 ? [...links, ...Array(Math.max(0, 8 - links.length - rechts.length)).fill('0'), ...rechts] : links
|
||||||
|
return acht(gruppen) ? gruppen : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaehlschluessel einer Client-Adresse (req.ip): IPv4 unveraendert, IPv4 in IPv6 (::ffff:a.b.c.d) als IPv4, IPv6 je /64
|
||||||
|
* (die ersten vier Gruppen, vierstellig); fehlt die Adresse oder ist sie unlesbar: 'unbekannt' (alle teilen sich einen Zaehler).
|
||||||
|
*/
|
||||||
|
export function ipSchluessel(ip) {
|
||||||
|
const s = typeof ip === 'string' ? ip.trim().toLowerCase() : ''
|
||||||
|
if (isIPv4(s)) return s
|
||||||
|
if (!isIPv6(s)) return 'unbekannt'
|
||||||
|
const gruppen = ipv6Gruppen(s)
|
||||||
|
if (!gruppen) return 'unbekannt'
|
||||||
|
const werte = gruppen.map((g) => parseInt(g, 16))
|
||||||
|
// ::ffff:a.b.c.d (Gruppen 0-4 null, Gruppe 5 = ffff) ist ein IPv4-Client hinter einem Dual-Stack-Socket
|
||||||
|
if (werte.slice(0, 5).every((w) => w === 0) && werte[5] === 0xffff) {
|
||||||
|
return `${werte[6] >> 8}.${werte[6] & 255}.${werte[7] >> 8}.${werte[7] & 255}`
|
||||||
|
}
|
||||||
|
return `v6:${werte.slice(0, 4).map((w) => w.toString(16).padStart(4, '0')).join(':')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf diese IP (IPv6 je /64) jetzt einen lesenden Abruf oder Token-Aufruf machen? Hoechstens 120 je Minute. */
|
||||||
|
export function darfAnfrage(ip, now) {
|
||||||
|
return zaehle(`anfrage-ip:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf diese Funktion (siteKey) jetzt einen lesenden Abruf beantworten? Hoechstens 600 je Minute fuer alle zusammen. */
|
||||||
|
export function darfAnfrageSite(siteKey, now) {
|
||||||
|
return zaehle(`anfrage-site:${siteKey}`, ANFRAGEN_JE_SITE, MINUTE_MS, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf diese IP (IPv6 je /64) jetzt ein Buchungsformular abschicken? Hoechstens 10 je 10 Minuten (jeder Versuch zaehlt). */
|
||||||
|
export function darfBuchungsversuch(ip, now) {
|
||||||
|
return zaehle(`buchung-ip:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */
|
||||||
|
export function darfHaltung(siteKey, now) {
|
||||||
|
return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf diese IP (IPv6 je /64) jetzt eine Widerrufserklaerung abschicken? Hoechstens 10 je Stunde. */
|
||||||
|
export function darfWiderrufsversuch(ip, now) {
|
||||||
|
return zaehle(`widerruf-ip:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */
|
||||||
|
export function darfG01(featureId, adresse, now) {
|
||||||
|
return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now)
|
||||||
|
}
|
||||||
|
|
||||||
/** Leert alle Zaehler (Tests). */
|
/** Leert alle Zaehler (Tests). */
|
||||||
export function resetTermineLimitsForTests() {
|
export function resetTermineLimitsForTests() {
|
||||||
zaehler.clear()
|
zaehler.clear()
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ const MOUNTS = [
|
|||||||
['/api/abo', (await import('../server/routes/abo.js')).default],
|
['/api/abo', (await import('../server/routes/abo.js')).default],
|
||||||
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
|
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
|
||||||
]
|
]
|
||||||
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS) })
|
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
|
||||||
|
const oeffentlich = (await import('../server/routes/publicTermine.js')).default
|
||||||
|
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS), '/api/public/termine/v1': oeffentlich })
|
||||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||||
|
|
||||||
seedFixtures(aw)
|
seedFixtures(aw)
|
||||||
@@ -210,3 +212,29 @@ test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder
|
|||||||
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
|
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
|
||||||
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
|
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Terminplaner (Aufgabe 16): Gaeste buchen ohne Sitzung; ein Kundenansicht-Cookie im selben Browser (Mitarbeiter testet die
|
||||||
|
// Website des Kunden) darf ihre POSTs weder sperren noch beenden
|
||||||
|
test('/api/public/ ist ausgenommen: Kundenansicht-Cookie sperrt oeffentliche POSTs nicht und beendet keine Ansicht', async (t) => {
|
||||||
|
const vorher = config.termine.minAntwortMs
|
||||||
|
config.termine.minAntwortMs = 0 // sonst wartet jede Buchungsantwort 300 ms
|
||||||
|
t.after(() => (config.termine.minAntwortMs = vorher))
|
||||||
|
const url = '/api/public/termine/v1/abcdefghijklmnop/buchungen'
|
||||||
|
const faelle = [
|
||||||
|
['Kundenansicht mit Kennung', kv()],
|
||||||
|
['Kundenansicht ohne Kennung (sonst 409)', { cookie: kundenansichtCookie() }],
|
||||||
|
['fremde Kennung (sonst 409)', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } }],
|
||||||
|
['abgelaufene Ansicht (sonst 401)', kv({ viewExpiresAt: Date.now() - 1000 })],
|
||||||
|
['alte Ansicht ohne viewId (sonst 401)', { cookie: viewAsCookie('c1') }],
|
||||||
|
]
|
||||||
|
for (const [was, opts] of faelle) {
|
||||||
|
for (const pfad of [url, url.replace('/api/public/', '/API/Public/')]) {
|
||||||
|
const res = await call('POST', pfad, { ...opts, body: {} })
|
||||||
|
assert.equal(res.status, 200, `${was} ${pfad}`)
|
||||||
|
assert.deepEqual(res.body, { aktiv: false }, `${was} ${pfad}`) // Modus off: die Schnittstelle antwortet selbst
|
||||||
|
assert.equal(clears(res, config.cookieName), false, `${was}: das Portal-Cookie bleibt`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ausserhalb von /api/public/ bleibt die Sperre
|
||||||
|
assert.equal((await call('POST', '/api/chat/read', kv())).status, 403)
|
||||||
|
})
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
|||||||
|
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import http from 'node:http'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import express from 'express'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { schemaKeys } from './support/schema.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||||
|
|
||||||
|
// Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlermiddleware mit: hier haengt er an einer nackten
|
||||||
|
// express()-App ohne globalen Parser und ohne eigene Fehlerbehandlung. Was beim Gast ankommt, kommt nur vom Router.
|
||||||
|
const aw = await startAppwriteMock({
|
||||||
|
schema: {
|
||||||
|
portalFeatures: schemaKeys('portalFeatures'),
|
||||||
|
termineConfig: schemaKeys('termineConfig'),
|
||||||
|
termineBookings: schemaKeys('termineBookings'),
|
||||||
|
termineClaims: schemaKeys('termineClaims'),
|
||||||
|
termineOutbox: schemaKeys('termineOutbox'),
|
||||||
|
},
|
||||||
|
datetimes: TERMINE_DATETIMES,
|
||||||
|
defaultLimit: 25,
|
||||||
|
})
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||||
|
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
||||||
|
const { resetKontextForTests } = await import('../server/services/termine/kontext.js')
|
||||||
|
const { default: router, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use('/api/public/termine/v1', router)
|
||||||
|
const server = http.createServer(app)
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const PORT = server.address().port
|
||||||
|
after(() => Promise.all([new Promise((resolve) => server.close(resolve)), aw.close()]))
|
||||||
|
|
||||||
|
const SITE_KEY = 'abcdefghijklmnop'
|
||||||
|
const PFAD = `/api/public/termine/v1/${SITE_KEY}/buchungen`
|
||||||
|
const MODUS_VORHER = config.termine.mode
|
||||||
|
const MIN_ANTWORT_VORHER = config.termine.minAntwortMs
|
||||||
|
const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } }
|
||||||
|
const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTermineLimitsForTests()
|
||||||
|
resetKontextForTests()
|
||||||
|
resetPublicTermineForTests()
|
||||||
|
config.termine.mode = 'pilot'
|
||||||
|
config.termine.minAntwortMs = 0
|
||||||
|
mock.method(console, 'error', () => {})
|
||||||
|
})
|
||||||
|
afterEach(() => mock.restoreAll())
|
||||||
|
after(() => {
|
||||||
|
config.termine.mode = MODUS_VORHER
|
||||||
|
config.termine.minAntwortMs = MIN_ANTWORT_VORHER
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Roher Aufruf mit Chunks (ohne content-length) oder festem Body; => { status, text, headers }. */
|
||||||
|
function roh({ pfad = PFAD, method = 'POST', headers = {}, chunks = [] } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: '127.0.0.1', port: PORT, path: pfad, method, headers }, (res) => {
|
||||||
|
let text = ''
|
||||||
|
res.setEncoding('utf8')
|
||||||
|
res.on('data', (d) => (text += d))
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, text, headers: res.headers }))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
for (const chunk of chunks) req.write(chunk)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('20 KB ohne content-length (chunked): 413 vom Router-Parser, ohne Rohtext', async () => {
|
||||||
|
const json = `{"name":"${'x'.repeat(20 * 1024)}"}`
|
||||||
|
const teile = json.match(/[\s\S]{1,2000}/g)
|
||||||
|
const res = await roh({ headers: { 'content-type': 'application/json', 'transfer-encoding': 'chunked' }, chunks: teile })
|
||||||
|
assert.equal(res.status, 413)
|
||||||
|
assert.deepEqual(JSON.parse(res.text), ZU_GROSS)
|
||||||
|
assert.match(res.headers['content-type'], /^application\/json/)
|
||||||
|
assert.equal(res.headers['cache-control'], 'no-store')
|
||||||
|
assert.ok(!res.text.includes('xxxx'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('content-length ueber 16 384: 413 vorab, auch fuer einen Body, der gar nicht mehr geschickt wird', async () => {
|
||||||
|
const res = await roh({ headers: { 'content-type': 'application/json', 'content-length': '16385' }, chunks: [] }).catch((err) => err)
|
||||||
|
// der Server antwortet, bevor der Rumpf kommt; der Client darf dabei zurueckgesetzt werden
|
||||||
|
if (res instanceof Error) assert.match(res.code, /ECONNRESET|EPIPE|ECONNABORTED/)
|
||||||
|
else {
|
||||||
|
assert.equal(res.status, 413)
|
||||||
|
assert.deepEqual(JSON.parse(res.text), ZU_GROSS)
|
||||||
|
}
|
||||||
|
const gross = await fetch(`http://127.0.0.1:${PORT}${PFAD}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ a: 'y'.repeat(16400) }) })
|
||||||
|
assert.equal(gross.status, 413)
|
||||||
|
assert.deepEqual(await gross.json(), ZU_GROSS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('genau 16 384 Bytes sind noch erlaubt', async () => {
|
||||||
|
const kopf = '{"kontakt_url":"'
|
||||||
|
const ende = '"}'
|
||||||
|
const fuell = 'z'.repeat(16384 - kopf.length - ende.length)
|
||||||
|
const body = kopf + fuell + ende
|
||||||
|
assert.equal(Buffer.byteLength(body), 16384)
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}${PFAD}`, { method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://salon.de' }, body })
|
||||||
|
// nicht 413: der Router liest den Rumpf; der siteKey ist hier unbekannt (nichts gespeichert), also { aktiv: false }
|
||||||
|
assert.equal(res.status, 200)
|
||||||
|
assert.deepEqual(await res.json(), { aktiv: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kaputtes JSON: 400 mit fester Meldung, nie der Text des Parsers', async () => {
|
||||||
|
for (const body of ['{"name": ', '{"a":}', 'nicht json', '{"a":1}}', '[1,2', "{'a':1}"]) {
|
||||||
|
const res = await roh({ headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(body)) }, chunks: [body] })
|
||||||
|
assert.equal(res.status, 400, body)
|
||||||
|
assert.deepEqual(JSON.parse(res.text), UNGUELTIG, body)
|
||||||
|
assert.doesNotMatch(res.text, /Unexpected|JSON|position|token|SyntaxError|at /i, body)
|
||||||
|
}
|
||||||
|
// dasselbe auf den Token-Seiten
|
||||||
|
const token = await roh({ pfad: '/api/public/termine/v1/termin/vorschau', headers: { 'content-type': 'application/json' }, chunks: ['{"token":'] })
|
||||||
|
assert.equal(token.status, 400)
|
||||||
|
assert.deepEqual(JSON.parse(token.text), UNGUELTIG)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unbekannte Kodierung, falscher Zeichensatz und abgebrochene Anfragen: 4xx als { fehler }, kein Stacktrace', async () => {
|
||||||
|
const gzip = await roh({ headers: { 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': '2' }, chunks: ['{}'] })
|
||||||
|
assert.equal(gzip.status, 400)
|
||||||
|
assert.deepEqual(JSON.parse(gzip.text), UNGUELTIG)
|
||||||
|
const zeichensatz = await roh({ headers: { 'content-type': 'application/json; charset=iso-8859-1', 'content-length': '2' }, chunks: ['{}'] })
|
||||||
|
assert.equal(zeichensatz.status, 400)
|
||||||
|
assert.deepEqual(JSON.parse(zeichensatz.text), UNGUELTIG)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('interner Fehler: 503 { fehler } ohne err.message, auch ohne Fehlerbehandlung der App', async () => {
|
||||||
|
const RAW = 'Nachbau kaputt: geheimer Rohtext'
|
||||||
|
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: RAW })
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v1/${SITE_KEY}/config`)
|
||||||
|
assert.equal(res.status, 503)
|
||||||
|
const text = await res.text()
|
||||||
|
assert.deepEqual(JSON.parse(text), { fehler: { _: 'Gerade nicht erreichbar' } })
|
||||||
|
assert.ok(!text.includes('Nachbau') && !text.includes('geheimer'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Router und App: der Router beantwortet alles unter seinem Pfad selbst (404 als { fehler }), die App darunter sieht nichts', async () => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v1/irgendwas/unbekannt`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"a":1}' })
|
||||||
|
assert.equal(res.status, 404)
|
||||||
|
assert.deepEqual(await res.json(), { fehler: { _: 'Nicht gefunden' } })
|
||||||
|
// ausserhalb des Pfades antwortet Express selbst
|
||||||
|
const aussen = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v2/x`)
|
||||||
|
assert.equal(aussen.status, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Reihenfolge in server/index.js: kundenansichtGuard, dann der Router der oeffentlichen Schnittstelle, dann der globale express.json', () => {
|
||||||
|
const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
|
||||||
|
const guard = quelle.indexOf('app.use(kundenansichtGuard)')
|
||||||
|
const oeffentlich = quelle.indexOf("app.use('/api/public/termine/v1'")
|
||||||
|
const global = quelle.indexOf('app.use(express.json(')
|
||||||
|
assert.ok(guard >= 0 && oeffentlich >= 0 && global >= 0, 'alle drei Aufrufe gefunden')
|
||||||
|
assert.equal(quelle.indexOf("app.use('/api/public/termine/v1'", oeffentlich + 1), -1, 'nur einmal eingehaengt')
|
||||||
|
assert.ok(guard < oeffentlich, 'erst der Waechter der Kundenansicht')
|
||||||
|
assert.ok(oeffentlich < global, 'vor dem globalen Parser (2 MB)')
|
||||||
|
assert.match(quelle, /import publicTermineRoutes from '\.\/routes\/publicTermine\.js'/)
|
||||||
|
})
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
import { test, beforeEach } from 'node:test'
|
import { test, beforeEach } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { zaehle, zaehleAlle, darfG08, darfK21NichtZugeordnet, resetTermineLimitsForTests } from '../server/services/termine/limits.js'
|
import {
|
||||||
|
zaehle,
|
||||||
|
zaehleAlle,
|
||||||
|
darfG08,
|
||||||
|
darfK21NichtZugeordnet,
|
||||||
|
darfAnfrage,
|
||||||
|
darfAnfrageSite,
|
||||||
|
darfBuchungsversuch,
|
||||||
|
darfHaltung,
|
||||||
|
darfWiderrufsversuch,
|
||||||
|
darfG01,
|
||||||
|
ipSchluessel,
|
||||||
|
resetTermineLimitsForTests,
|
||||||
|
} from '../server/services/termine/limits.js'
|
||||||
|
|
||||||
// Grenzen im Speicher: festes Fenster ab dem ersten Ereignis, alle-oder-keine bei mehreren Regeln, Obergrenze der Schluessel.
|
// Grenzen im Speicher: festes Fenster ab dem ersten Ereignis, alle-oder-keine bei mehreren Regeln, Obergrenze der Schluessel.
|
||||||
const T0 = new Date('2026-10-05T08:00:00.000Z')
|
const T0 = new Date('2026-10-05T08:00:00.000Z')
|
||||||
const STUNDE = 60 * 60 * 1000
|
const STUNDE = 60 * 60 * 1000
|
||||||
const TAG = 24 * STUNDE
|
const TAG = 24 * STUNDE
|
||||||
|
const MINUTE = 60 * 1000
|
||||||
const plus = (ms) => new Date(T0.getTime() + ms)
|
const plus = (ms) => new Date(T0.getTime() + ms)
|
||||||
|
|
||||||
beforeEach(() => resetTermineLimitsForTests())
|
beforeEach(() => resetTermineLimitsForTests())
|
||||||
@@ -81,3 +95,75 @@ test('resetTermineLimitsForTests leert alles', () => {
|
|||||||
resetTermineLimitsForTests()
|
resetTermineLimitsForTests()
|
||||||
assert.equal(zaehle('a', 1, TAG, T0), true)
|
assert.equal(zaehle('a', 1, TAG, T0), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Aufgabe 16: IP, siteKey, G01
|
||||||
|
|
||||||
|
test('ipSchluessel: IPv4 bleibt, IPv4-in-IPv6 wird IPv4, IPv6 zaehlt je /64 (auch in anderer Schreibweise)', () => {
|
||||||
|
assert.equal(ipSchluessel('203.0.113.7'), '203.0.113.7')
|
||||||
|
assert.equal(ipSchluessel('::ffff:203.0.113.7'), '203.0.113.7')
|
||||||
|
assert.equal(ipSchluessel('::FFFF:203.0.113.7'), '203.0.113.7')
|
||||||
|
const a = ipSchluessel('2001:db8:1:2::1')
|
||||||
|
assert.equal(ipSchluessel('2001:0db8:0001:0002:ffff:ffff:ffff:ffff'), a, 'dieselbe /64, ausgeschrieben')
|
||||||
|
assert.equal(ipSchluessel('2001:DB8:1:2:abcd::9'), a, 'dieselbe /64, Grossbuchstaben und Kurzform')
|
||||||
|
assert.equal(ipSchluessel('2001:db8:1:2:0:0:0:0'), a)
|
||||||
|
assert.notEqual(ipSchluessel('2001:db8:1:3::1'), a, 'andere /64')
|
||||||
|
assert.notEqual(ipSchluessel('2001:db8:2:2::1'), a)
|
||||||
|
assert.equal(ipSchluessel('fe80::1%eth0'), ipSchluessel('fe80::2'), 'Zonenkennung faellt weg')
|
||||||
|
assert.equal(ipSchluessel('::1'), ipSchluessel('0:0:0:0:ffff:ffff:ffff:ffff'), 'Loopback ist die /64 ::')
|
||||||
|
assert.notEqual(ipSchluessel('203.0.113.7'), ipSchluessel('203.0.113.8'))
|
||||||
|
assert.notEqual(ipSchluessel('203.0.113.7'), a, 'IPv4 und IPv6 stossen nie zusammen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ipSchluessel: fehlende oder unlesbare Adresse ergibt einen festen Ersatzschluessel (nie einen Fehler)', () => {
|
||||||
|
for (const wert of [undefined, null, '', ' ', 'kaputt', '1.2.3', '999.1.1.1', 42, {}]) {
|
||||||
|
assert.equal(ipSchluessel(wert), 'unbekannt', JSON.stringify(wert))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('darfAnfrage: 120 je IP und Minute (IPv6 je /64); darfAnfrageSite: 600 je siteKey und Minute', () => {
|
||||||
|
assert.equal(Array.from({ length: 125 }, () => darfAnfrage('203.0.113.7', T0)).filter(Boolean).length, 120)
|
||||||
|
assert.equal(darfAnfrage('203.0.113.8', T0), true, 'andere IP')
|
||||||
|
assert.equal(darfAnfrage('203.0.113.7', plus(MINUTE)), true, 'nach einer Minute wieder')
|
||||||
|
|
||||||
|
resetTermineLimitsForTests()
|
||||||
|
const erlaubt = Array.from({ length: 125 }, (_, i) => darfAnfrage(`2001:db8:1:2::${i + 1}`, T0))
|
||||||
|
assert.equal(erlaubt.filter(Boolean).length, 120, 'alle Adressen der /64 teilen sich 120')
|
||||||
|
assert.equal(darfAnfrage('2001:db8:1:3::1', T0), true)
|
||||||
|
|
||||||
|
resetTermineLimitsForTests()
|
||||||
|
assert.equal(Array.from({ length: 605 }, () => darfAnfrageSite('abcdefghijklmnop', T0)).filter(Boolean).length, 600)
|
||||||
|
assert.equal(darfAnfrageSite('bcdefghijklmnopq', T0), true)
|
||||||
|
assert.equal(darfAnfrageSite('abcdefghijklmnop', plus(MINUTE)), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 60 je siteKey und Stunde', () => {
|
||||||
|
assert.equal(Array.from({ length: 12 }, () => darfBuchungsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
|
||||||
|
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE - 1)), false)
|
||||||
|
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE)), true)
|
||||||
|
assert.equal(darfBuchungsversuch('2001:db8::1', T0) && darfBuchungsversuch('2001:db8:0:0:1::1', T0), true, 'getrennt von der IPv4')
|
||||||
|
|
||||||
|
resetTermineLimitsForTests()
|
||||||
|
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9::${i + 1}`, T0), true)
|
||||||
|
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9:1::${i + 1}`, T0), true)
|
||||||
|
assert.equal(darfBuchungsversuch('2001:db8:9:9:ffff::1', T0), false, 'elf Versuche aus einer /64')
|
||||||
|
|
||||||
|
resetTermineLimitsForTests()
|
||||||
|
assert.equal(Array.from({ length: 65 }, () => darfHaltung('abcdefghijklmnop', T0)).filter(Boolean).length, 60)
|
||||||
|
assert.equal(darfHaltung('bcdefghijklmnopq', T0), true)
|
||||||
|
assert.equal(darfHaltung('abcdefghijklmnop', plus(STUNDE)), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('darfWiderrufsversuch: 10 je IP und Stunde', () => {
|
||||||
|
assert.equal(Array.from({ length: 12 }, () => darfWiderrufsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
|
||||||
|
assert.equal(darfWiderrufsversuch('203.0.113.8', T0), true)
|
||||||
|
assert.equal(darfWiderrufsversuch('203.0.113.7', plus(STUNDE)), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren sich nicht', () => {
|
||||||
|
assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, true, true, false, false])
|
||||||
|
assert.equal(darfG01('f2', 'max@example.com', T0), true, 'andere Funktion')
|
||||||
|
assert.equal(darfG01('f1', 'anna@example.com', T0), true, 'andere Adresse')
|
||||||
|
assert.equal(darfG01('f1', 'max@example.com', plus(TAG - 1)), false)
|
||||||
|
assert.equal(darfG01('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder')
|
||||||
|
assert.equal(darfG08('f1', 'max@example.com', T0), true, 'G08 zaehlt getrennt von G01')
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ const TERMINE_ENV = [
|
|||||||
'TERMINE_ENABLED',
|
'TERMINE_ENABLED',
|
||||||
'TERMINE_PILOT_EMAILS',
|
'TERMINE_PILOT_EMAILS',
|
||||||
'TERMINE_FORM_MIN_SECONDS',
|
'TERMINE_FORM_MIN_SECONDS',
|
||||||
|
'TERMINE_MIN_ANTWORT_MS',
|
||||||
'APPWRITE_COLLECTION_TERMINE_CONFIG',
|
'APPWRITE_COLLECTION_TERMINE_CONFIG',
|
||||||
'APPWRITE_COLLECTION_TERMINE_BOOKINGS',
|
'APPWRITE_COLLECTION_TERMINE_BOOKINGS',
|
||||||
'APPWRITE_COLLECTION_TERMINE_CLAIMS',
|
'APPWRITE_COLLECTION_TERMINE_CLAIMS',
|
||||||
@@ -47,6 +48,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
|
|||||||
zelleMin: 5,
|
zelleMin: 5,
|
||||||
mailsProStunde: 200,
|
mailsProStunde: 200,
|
||||||
formMinSekunden: 3,
|
formMinSekunden: 3,
|
||||||
|
minAntwortMs: 300,
|
||||||
})
|
})
|
||||||
assert.equal(fresh.collections.termineConfig, 'termineConfig')
|
assert.equal(fresh.collections.termineConfig, 'termineConfig')
|
||||||
assert.equal(fresh.collections.termineBookings, 'termineBookings')
|
assert.equal(fresh.collections.termineBookings, 'termineBookings')
|
||||||
@@ -59,6 +61,7 @@ test('Konfiguration: Umgebungsvariablen werden gelesen und bereinigt', async ()
|
|||||||
TERMINE_ENABLED: ' Pilot ',
|
TERMINE_ENABLED: ' Pilot ',
|
||||||
TERMINE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ',
|
TERMINE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ',
|
||||||
TERMINE_FORM_MIN_SECONDS: '0',
|
TERMINE_FORM_MIN_SECONDS: '0',
|
||||||
|
TERMINE_MIN_ANTWORT_MS: '0',
|
||||||
APPWRITE_COLLECTION_TERMINE_CONFIG: 'tConfig',
|
APPWRITE_COLLECTION_TERMINE_CONFIG: 'tConfig',
|
||||||
APPWRITE_COLLECTION_TERMINE_BOOKINGS: 'tBookings',
|
APPWRITE_COLLECTION_TERMINE_BOOKINGS: 'tBookings',
|
||||||
APPWRITE_COLLECTION_TERMINE_CLAIMS: 'tClaims',
|
APPWRITE_COLLECTION_TERMINE_CLAIMS: 'tClaims',
|
||||||
@@ -67,6 +70,7 @@ test('Konfiguration: Umgebungsvariablen werden gelesen und bereinigt', async ()
|
|||||||
assert.equal(fresh.termine.mode, 'pilot')
|
assert.equal(fresh.termine.mode, 'pilot')
|
||||||
assert.deepEqual(fresh.termine.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com'])
|
assert.deepEqual(fresh.termine.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com'])
|
||||||
assert.equal(fresh.termine.formMinSekunden, 0)
|
assert.equal(fresh.termine.formMinSekunden, 0)
|
||||||
|
assert.equal(fresh.termine.minAntwortMs, 0)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[fresh.collections.termineConfig, fresh.collections.termineBookings, fresh.collections.termineClaims, fresh.collections.termineOutbox],
|
[fresh.collections.termineConfig, fresh.collections.termineBookings, fresh.collections.termineClaims, fresh.collections.termineOutbox],
|
||||||
['tConfig', 'tBookings', 'tClaims', 'tOutbox'],
|
['tConfig', 'tBookings', 'tClaims', 'tOutbox'],
|
||||||
@@ -83,6 +87,11 @@ test('Konfiguration: leerer oder unbekannter Schalter ist off, ungueltige Sekund
|
|||||||
assert.equal(fresh.termine.formMinSekunden, 3, `TERMINE_FORM_MIN_SECONDS=${JSON.stringify(value)}`)
|
assert.equal(fresh.termine.formMinSekunden, 3, `TERMINE_FORM_MIN_SECONDS=${JSON.stringify(value)}`)
|
||||||
}
|
}
|
||||||
assert.equal((await freshConfig({ TERMINE_FORM_MIN_SECONDS: '7' })).termine.formMinSekunden, 7)
|
assert.equal((await freshConfig({ TERMINE_FORM_MIN_SECONDS: '7' })).termine.formMinSekunden, 7)
|
||||||
|
for (const value of ['', 'abc', '-1', 'NaN']) {
|
||||||
|
const fresh = await freshConfig({ TERMINE_MIN_ANTWORT_MS: value })
|
||||||
|
assert.equal(fresh.termine.minAntwortMs, 300, `TERMINE_MIN_ANTWORT_MS=${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
|
assert.equal((await freshConfig({ TERMINE_MIN_ANTWORT_MS: '750' })).termine.minAntwortMs, 750)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('termineModus: leer und unbekannt sind off, pilot und on bleiben', () => {
|
test('termineModus: leer und unbekannt sind off, pilot und on bleiben', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user