Buchungskalender: Abschluss – S7 Buchungslink-Gueltigkeit (90 Tage nach Abreise, 30 Tage nach Absage), tokenHash beim Anonymisieren leeren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17400cac1f
commit
4f9fb6b109
6 files changed
+57
-12
No files matched your search
@@ -9,7 +9,7 @@ import { UkFehler } from '../services/unterkunft/fehler.js'
|
|||||||
import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js'
|
import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js'
|
||||||
import { ID_RE, belegungsStand, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
|
import { ID_RE, belegungsStand, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
|
||||||
import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.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 { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js'
|
||||||
import { heute } from '../services/termine/zeit.js'
|
import { heute } from '../services/termine/zeit.js'
|
||||||
|
|
||||||
@@ -364,11 +364,11 @@ router.use((err, req, res, next) => {
|
|||||||
const BUCHUNG = { mindestdauer: true }
|
const BUCHUNG = { mindestdauer: true }
|
||||||
const ART_RE = /^(rechnung|storno)$/
|
const ART_RE = /^(rechnung|storno)$/
|
||||||
|
|
||||||
/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion und falsches Token dieselbe 404-Antwort. */
|
/** 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) {
|
async function ladeMitToken(ctx, id, token, now) {
|
||||||
if (!ID_RE.test(id) || typeof token !== 'string' || token === '' || token.length > 200) throw nichtGefunden()
|
if (!ID_RE.test(id) || typeof token !== 'string' || token === '' || token.length > 200) throw nichtGefunden()
|
||||||
const a = await ladeAnfrage(id)
|
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
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ router.get(
|
|||||||
route(
|
route(
|
||||||
async (req, now, cors) => {
|
async (req, now, cors) => {
|
||||||
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
|
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
|
// 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' })
|
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: req.query.wk_zahlung === 'ok' })
|
||||||
return { body: await buchungAntwort(ctx, a) }
|
return { body: await buchungAntwort(ctx, a) }
|
||||||
@@ -445,7 +445,7 @@ router.post(
|
|||||||
route(
|
route(
|
||||||
async (req, now, cors) => {
|
async (req, now, cors) => {
|
||||||
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
|
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
|
const b = req.body
|
||||||
if (!istObjekt(b)) throw ungueltig()
|
if (!istObjekt(b)) throw ungueltig()
|
||||||
const { anfrage, rechnung } = await bucheVerbindlich(ctx, a.$id, req.query.t, {
|
const { anfrage, rechnung } = await bucheVerbindlich(ctx, a.$id, req.query.t, {
|
||||||
@@ -473,7 +473,7 @@ router.post(
|
|||||||
route(
|
route(
|
||||||
async (req, now, cors) => {
|
async (req, now, cors) => {
|
||||||
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
|
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 })
|
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: true })
|
||||||
if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig')
|
if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig')
|
||||||
try {
|
try {
|
||||||
@@ -492,7 +492,7 @@ router.get(
|
|||||||
route(
|
route(
|
||||||
async (req, now, cors) => {
|
async (req, now, cors) => {
|
||||||
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
|
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'
|
const art = typeof req.query.art === 'string' ? req.query.art : 'rechnung'
|
||||||
if (!ART_RE.test(art)) throw ungueltig({ art: 'Ungültig.' })
|
if (!ART_RE.test(art)) throw ungueltig({ art: 'Ungültig.' })
|
||||||
const dok = await findeRechnung(a.$id, art)
|
const dok = await findeRechnung(a.$id, art)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import * as mails from './mails.js'
|
|||||||
import { berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js'
|
import { berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js'
|
||||||
import { stelleAus } from './rechnungen.js'
|
import { stelleAus } from './rechnungen.js'
|
||||||
import { aktualisiereAnfrage, ladeAnfrage, findeRechnung, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.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'
|
import * as zahlung from './zahlung.js'
|
||||||
|
|
||||||
export const UEBERGAENGE = {
|
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')
|
if (!pre || pre.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden')
|
||||||
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
|
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
|
||||||
const a = await ladeAnfrage(anfrageId)
|
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') {
|
if (a.status === 'gebucht' || a.status === 'bezahlt') {
|
||||||
return { anfrage: a, rechnung: a.rechnungId ? await ladeRechnung(a.rechnungId) : null, neu: false }
|
return { anfrage: a, rechnung: a.rechnungId ? await ladeRechnung(a.rechnungId) : null, neu: false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ANONYM_JAHRE = () => config.unterkunft.anonymisierenJahre ?? 3
|
|||||||
const RECHNUNG_JAHRE = () => config.unterkunft.rechnungJahre ?? 8
|
const RECHNUNG_JAHRE = () => config.unterkunft.rechnungJahre ?? 8
|
||||||
const MARKER = 'Buchungsdaten gelöscht am '
|
const MARKER = 'Buchungsdaten gelöscht am '
|
||||||
const MARKER_RE = /Buchungsdaten gelöscht am (\d{4}-\d{2}-\d{2})/
|
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 zeitMs = (v) => {
|
||||||
const t = Date.parse(v)
|
const t = Date.parse(v)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { ipSchluessel } from '../termine/limits.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.
|
* 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)
|
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 = () => {
|
export const formMinSekunden = () => {
|
||||||
const wert = config.unterkunft?.formMinSekunden
|
const wert = config.unterkunft?.formMinSekunden
|
||||||
return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN
|
return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN
|
||||||
|
|||||||
@@ -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 () => {
|
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 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) })
|
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 })
|
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
|
// 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.equal(r.gaeste, 1)
|
||||||
assert.deepEqual(gast(a), {})
|
assert.deepEqual(gast(a), {})
|
||||||
assert.ok(holen(a).anonymisiertAt)
|
assert.ok(holen(a).anonymisiertAt)
|
||||||
|
assert.equal(holen(a).tokenHash, '', 'Buchungslink tot')
|
||||||
assert.deepEqual(JSON.parse(holen(a).rechnungsadresse), ADRESSE)
|
assert.deepEqual(JSON.parse(holen(a).rechnungsadresse), ADRESSE)
|
||||||
assert.deepEqual(gast(b), GAST)
|
assert.deepEqual(gast(b), GAST)
|
||||||
const re = aw.docs('ukRechnungen').get('r1')
|
const re = aw.docs('ukRechnungen').get('r1')
|
||||||
|
|||||||
@@ -141,6 +141,26 @@ test('POST buchung vor Ort: gebucht, Rechnung; zweites POST gleiche Antwort ohne
|
|||||||
assert.equal(g.body.rechnungsadresse.ort, 'Berlin')
|
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 () => {
|
test('POST buchung: falsches Token 404, fremde Herkunft 403, Adresse fehlt 400, Frist abgelaufen 410', async () => {
|
||||||
const { id, token } = await freigegeben()
|
const { id, token } = await freigegeben()
|
||||||
const falsch = await post(url(id, 'falsch'), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
|
const falsch = await post(url(id, 'falsch'), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })
|
||||||
|
|||||||
Reference in new issue
Block a user