From 4f9fb6b1097a9deb70347fd165c11d6c9449bf82 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 15:26:12 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20Abschluss=20=E2=80=93=20S7?= =?UTF-8?q?=20Buchungslink-Gueltigkeit=20(90=20Tage=20nach=20Abreise,=2030?= =?UTF-8?q?=20Tage=20nach=20Absage),=20tokenHash=20beim=20Anonymisieren=20?= =?UTF-8?q?leeren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/publicUnterkunft.js | 16 +++++++-------- server/services/unterkunft/ablauf.js | 4 ++-- server/services/unterkunft/aufbewahrung.js | 2 +- server/services/unterkunft/token.js | 24 ++++++++++++++++++++++ test/unterkunftAufbewahrung.test.js | 3 ++- test/unterkunftBuchung.test.js | 20 ++++++++++++++++++ 6 files changed, 57 insertions(+), 12 deletions(-) diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index 868c84f..c383f47 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -9,7 +9,7 @@ import { UkFehler } from '../services/unterkunft/fehler.js' import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js' import { ID_RE, belegungsStand, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.js' -import { formToken, ipHashVon, pruefeFormToken, tokenPasst } from '../services/unterkunft/token.js' +import { formToken, ipHashVon, linkGueltig, pruefeFormToken, tokenPasst } from '../services/unterkunft/token.js' import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js' import { heute } from '../services/termine/zeit.js' @@ -364,11 +364,11 @@ router.use((err, req, res, next) => { const BUCHUNG = { mindestdauer: true } const ART_RE = /^(rechnung|storno)$/ -/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion und falsches Token dieselbe 404-Antwort. */ -async function ladeMitToken(ctx, id, token) { +/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion, falsches Token und abgelaufenen Link (token.linkGueltig) dieselbe 404-Antwort. */ +async function ladeMitToken(ctx, id, token, now) { if (!ID_RE.test(id) || typeof token !== 'string' || token === '' || token.length > 200) throw nichtGefunden() const a = await ladeAnfrage(id) - if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token)) throw nichtGefunden() + if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token) || !linkGueltig(a, now)) throw nichtGefunden() return a } @@ -431,7 +431,7 @@ router.get( route( async (req, now, cors) => { const ctx = await buchungsKontext(req, now, cors, { schreiben: false }) - let a = await ladeMitToken(ctx, req.params.id, req.query.t) + let a = await ladeMitToken(ctx, req.params.id, req.query.t, now) // Rueckkehr von Stripe vor dem Webhook (oder Webhook verloren): einmal bei Stripe nachsehen a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: req.query.wk_zahlung === 'ok' }) return { body: await buchungAntwort(ctx, a) } @@ -445,7 +445,7 @@ router.post( route( async (req, now, cors) => { const ctx = await buchungsKontext(req, now, cors, { schreiben: true }) - const a = await ladeMitToken(ctx, req.params.id, req.query.t) + const a = await ladeMitToken(ctx, req.params.id, req.query.t, now) const b = req.body if (!istObjekt(b)) throw ungueltig() const { anfrage, rechnung } = await bucheVerbindlich(ctx, a.$id, req.query.t, { @@ -473,7 +473,7 @@ router.post( route( async (req, now, cors) => { const ctx = await buchungsKontext(req, now, cors, { schreiben: true }) - let a = await ladeMitToken(ctx, req.params.id, req.query.t) + let a = await ladeMitToken(ctx, req.params.id, req.query.t, now) a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: true }) if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig') try { @@ -492,7 +492,7 @@ router.get( route( async (req, now, cors) => { const ctx = await buchungsKontext(req, now, cors, { schreiben: false }) - const a = await ladeMitToken(ctx, req.params.id, req.query.t) + const a = await ladeMitToken(ctx, req.params.id, req.query.t, now) const art = typeof req.query.art === 'string' ? req.query.art : 'rechnung' if (!ART_RE.test(art)) throw ungueltig({ art: 'Ungültig.' }) const dok = await findeRechnung(a.$id, art) diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index 6a88583..45c5269 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -9,7 +9,7 @@ import * as mails from './mails.js' import { berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js' import { stelleAus } from './rechnungen.js' import { aktualisiereAnfrage, ladeAnfrage, findeRechnung, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.js' -import { neuesToken, tokenPasst } from './token.js' +import { linkGueltig, neuesToken, tokenPasst } from './token.js' import * as zahlung from './zahlung.js' export const UEBERGAENGE = { @@ -194,7 +194,7 @@ export async function bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse if (!pre || pre.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden') const r = await withLocks(withLocksKey(pre.unterkunftId), async () => { const a = await ladeAnfrage(anfrageId) - if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token)) throw new UkFehler(404, 'nicht_gefunden') + if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token) || !linkGueltig(a, now)) throw new UkFehler(404, 'nicht_gefunden') if (a.status === 'gebucht' || a.status === 'bezahlt') { return { anfrage: a, rechnung: a.rechnungId ? await ladeRechnung(a.rechnungId) : null, neu: false } } diff --git a/server/services/unterkunft/aufbewahrung.js b/server/services/unterkunft/aufbewahrung.js index ca377d4..6ca54f1 100644 --- a/server/services/unterkunft/aufbewahrung.js +++ b/server/services/unterkunft/aufbewahrung.js @@ -27,7 +27,7 @@ const ANONYM_JAHRE = () => config.unterkunft.anonymisierenJahre ?? 3 const RECHNUNG_JAHRE = () => config.unterkunft.rechnungJahre ?? 8 const MARKER = 'Buchungsdaten gelöscht am ' const MARKER_RE = /Buchungsdaten gelöscht am (\d{4}-\d{2}-\d{2})/ -const ANFRAGE_ANONYM = { gast: {}, nachrichtVermieter: '', ipHash: '' } +const ANFRAGE_ANONYM = { gast: {}, nachrichtVermieter: '', ipHash: '', tokenHash: '' } // tokenHash leer: der Buchungslink ist mit der Anonymisierung tot const zeitMs = (v) => { const t = Date.parse(v) diff --git a/server/services/unterkunft/token.js b/server/services/unterkunft/token.js index 061696c..9ffa1f7 100644 --- a/server/services/unterkunft/token.js +++ b/server/services/unterkunft/token.js @@ -1,6 +1,8 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { config } from '../../config.js' import { ipSchluessel } from '../termine/limits.js' +import { heute } from '../termine/zeit.js' +import { plusTage } from './datum.js' /** * Tokens des Buchungskalenders. Buchungslink-Token: 32 Byte zufaellig (base64url), gespeichert wird nur der SHA-256-Hash. @@ -28,6 +30,28 @@ export function tokenPasst(hash, token) { return erwartet.length === gegeben.length && timingSafeEqual(erwartet, gegeben) } +const LINK_TAGE_NACH_ABREISE = 90 +const LINK_TAGE_NACH_ABSAGE = 30 +const TAG_RE = /^\d{4}-\d{2}-\d{2}/ + +/** + * Gilt der Buchungslink noch? Bis 90 Tage nach der Abreise (bis, einschliesslich dieses Tages, Berliner Tag); bei abgelehnten und abgelaufenen + * Anfragen (nicht zustande gekommen) nur 30 Tage nach dem Statuswechsel. Danach antwortet die Buchungsseite wie bei einem unbekannten Link (404). + * Ohne lesbares Datum gilt der Link (kein Grund, ihn zu sperren). + */ +export function linkGueltig(anfrage, now = new Date()) { + const heuteTag = heute(now instanceof Date ? now : new Date(Number(now))) + const status = anfrage?.status + if (status === 'abgelehnt' || status === 'abgelaufen') { + const seit = String((status === 'abgelehnt' ? anfrage.abgelehntAt : anfrage.abgelaufenAt) || anfrage.$updatedAt || '') + if (!TAG_RE.test(seit)) return true + return heuteTag <= plusTage(heute(new Date(seit)), LINK_TAGE_NACH_ABSAGE) + } + const bis = String(anfrage?.bis ?? '') + if (!TAG_RE.test(bis)) return true + return heuteTag <= plusTage(bis.slice(0, 10), LINK_TAGE_NACH_ABREISE) +} + export const formMinSekunden = () => { const wert = config.unterkunft?.formMinSekunden return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN diff --git a/test/unterkunftAufbewahrung.test.js b/test/unterkunftAufbewahrung.test.js index 0a1d89d..9e89fd6 100644 --- a/test/unterkunftAufbewahrung.test.js +++ b/test/unterkunftAufbewahrung.test.js @@ -95,7 +95,7 @@ test('Unverbindliche Anfragen: nach 6 Monaten anonymisiert, knapp davor nicht; s test('Gebuchte Aufenthalte: Gast weg 3 Jahre nach Jahresende der Abreise (Berlin); Rechnungsadresse und Rechnung bleiben unveraendert', async () => { const inhalt = JSON.stringify({ gast: ADRESSE, summe: 12345 }) - const a = anfrage({ status: 'bezahlt', bis: '2022-12-31', von: '2022-12-28', $createdAt: vor(1500), rechnungsadresse: JSON.stringify(ADRESSE), rechnungId: 'r1' }) + const a = anfrage({ status: 'bezahlt', bis: '2022-12-31', von: '2022-12-28', $createdAt: vor(1500), rechnungsadresse: JSON.stringify(ADRESSE), rechnungId: 'r1', tokenHash: 'abc123' }) const b = anfrage({ status: 'gebucht', bis: '2023-01-02', von: '2022-12-30', $createdAt: vor(1500), rechnungsadresse: JSON.stringify(ADRESSE) }) aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f1', anfrageId: a, art: 'rechnung', nummer: 'R-1', datum: '2022-12-20', bruttoCent: 12345, inhalt }) // 31.12.2025 23:30 Berlin: noch Jahr 2025 -> nichts @@ -106,6 +106,7 @@ test('Gebuchte Aufenthalte: Gast weg 3 Jahre nach Jahresende der Abreise (Berlin assert.equal(r.gaeste, 1) assert.deepEqual(gast(a), {}) assert.ok(holen(a).anonymisiertAt) + assert.equal(holen(a).tokenHash, '', 'Buchungslink tot') assert.deepEqual(JSON.parse(holen(a).rechnungsadresse), ADRESSE) assert.deepEqual(gast(b), GAST) const re = aw.docs('ukRechnungen').get('r1') diff --git a/test/unterkunftBuchung.test.js b/test/unterkunftBuchung.test.js index 74b3b74..1abb570 100644 --- a/test/unterkunftBuchung.test.js +++ b/test/unterkunftBuchung.test.js @@ -141,6 +141,26 @@ test('POST buchung vor Ort: gebucht, Rechnung; zweites POST gleiche Antwort ohne assert.equal(g.body.rechnungsadresse.ort, 'Berlin') }) +test('Buchungslink: gilt bis 90 Tage nach Abreise, abgelehnt/abgelaufen 30 Tage nach Statuswechsel, danach 404; Seite und PDF', async () => { + const { id, token } = await freigegeben('2026-11-10', '2026-11-12') + assert.equal((await send('GET', url(id, token))).status, 200) + setzeUhrFuerTests(() => new Date('2027-02-10T12:00:00Z')) // 90 Tage nach 12.11.2026 = 10.02.2027 + assert.equal((await send('GET', url(id, token))).status, 200) + setzeUhrFuerTests(() => new Date('2027-02-11T12:00:00Z')) + for (const r of [await send('GET', url(id, token)), await send('GET', url(id, token, '/rechnung.pdf'))]) { + assert.equal(r.status, 404) + assert.deepEqual(r.body, { fehler: 'nicht_gefunden' }) + } + assert.equal((await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })).status, 404) + // abgelehnt: 30 Tage nach dem Statuswechsel + setzeUhrFuerTests(() => NOW) + const g = await freigegeben('2026-12-10', '2026-12-12') + await sp.aktualisiereAnfrage(g.id, { status: 'abgelehnt', abgelehntAt: NOW.toISOString() }) + assert.equal((await send('GET', url(g.id, g.token))).status, 200) + setzeUhrFuerTests(() => new Date(NOW.getTime() + 31 * 86400000)) + assert.equal((await send('GET', url(g.id, g.token))).status, 404) +}) + test('POST buchung: falsches Token 404, fremde Herkunft 403, Adresse fehlt 400, Frist abgelaufen 410', async () => { const { id, token } = await freigegeben() const falsch = await post(url(id, 'falsch'), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })