Ein Token-Tausch durch die Erinnerung zwischen Pruefung und Sperre ergibt 404 wie bei einem ungueltigen Link; das Portal ist nicht betroffen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
119 lines
4.6 KiB
JavaScript
119 lines
4.6 KiB
JavaScript
/**
|
||
* Öffentliche Link-Routen einer Auftragsbestätigung (Übersicht V5, Strang L5): ansehen, PDF, bestätigen – ohne
|
||
* Anmeldung, das Token steht im Link (Fragment `#t=`, die Seite schickt es hierher). Mount: /api/auftrag.
|
||
* Ratenbegrenzung nur im Arbeitsspeicher (GET 120/h, POST 10/h je IP); nichts von der Anfrage wird gespeichert oder
|
||
* protokolliert. Ungültige, unbekannte, abgelaufene Links und abgeschalteter Schalter antworten gleich (404).
|
||
*/
|
||
import { Router } from 'express'
|
||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||
import { findAuftragByTokenHash } from '../services/auftraege/auftragStore.js'
|
||
import { lesePdf } from '../services/auftraege/ablage.js'
|
||
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
|
||
import { bestaetigeAuftrag, GESPERRT_MELDUNG, UNGUELTIGER_LINK } from '../services/auftraege/bestaetigung.js'
|
||
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
|
||
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
|
||
import { hashToken } from '../services/auftraege/token.js'
|
||
|
||
const UNGUELTIG = UNGUELTIGER_LINK
|
||
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
|
||
const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.'
|
||
|
||
const echteUhr = () => new Date()
|
||
let uhr = echteUhr
|
||
const jetztMs = () => uhr().getTime()
|
||
const getLimiter = createWriteLimiter({ max: 120, now: jetztMs })
|
||
const postLimiter = createWriteLimiter({ max: 10, now: jetztMs })
|
||
const nachIp = (req) => String(req.ip)
|
||
|
||
/** Nur für Tests: feste Uhr der Routen (`null` = echte Uhr). */
|
||
export function setzeUhrFuerTests(fn) {
|
||
uhr = typeof fn === 'function' ? fn : echteUhr
|
||
}
|
||
|
||
/** Nur für Tests: Zähler leeren und echte Uhr. */
|
||
export function resetAuftragRoutesForTests() {
|
||
getLimiter.reset()
|
||
postLimiter.reset()
|
||
uhr = echteUhr
|
||
}
|
||
|
||
const router = Router()
|
||
router.use((_req, res, next) => {
|
||
res.set({
|
||
'Cache-Control': 'no-store',
|
||
'X-Robots-Tag': 'noindex',
|
||
'X-Content-Type-Options': 'nosniff',
|
||
'Referrer-Policy': 'no-referrer',
|
||
})
|
||
next()
|
||
})
|
||
|
||
/** Gemeinsamer Vorlauf der Link-Routen; bei `null` ist die Antwort (404/410) schon gesendet. */
|
||
async function ladeNachToken(req, res) {
|
||
const nichtGueltig = () => {
|
||
res.status(404).json({ error: UNGUELTIG })
|
||
return null
|
||
}
|
||
const hash = hashToken(req.params.token)
|
||
if (hash === null) return nichtGueltig()
|
||
const a = await findAuftragByTokenHash(hash)
|
||
if (!a) return nichtGueltig()
|
||
if (!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })) return nichtGueltig()
|
||
if (a.status === 'entwurf') return nichtGueltig()
|
||
if (GESPERRT_MELDUNG[a.status]) {
|
||
res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
|
||
return null
|
||
}
|
||
const bis = a.tokenGueltigBis ? Date.parse(a.tokenGueltigBis) : Number.NaN
|
||
if (!Number.isFinite(bis) || jetztMs() > bis) return nichtGueltig()
|
||
return a
|
||
}
|
||
|
||
/** AB-PDF aus der Ablage senden (inline); fehlt die Datei, 404 `PDF nicht gefunden.`. */
|
||
export async function sendePdf(res, auftrag) {
|
||
let buffer
|
||
try {
|
||
buffer = await lesePdf(auftrag.pdfDatei)
|
||
} catch (err) {
|
||
if (err instanceof AuftragError) throw err
|
||
console.error(`[auftraege] PDF zu ${auftrag.nummer} nicht lesbar: ${err.code || err.message}`)
|
||
throw new AuftragError(404, 'PDF nicht gefunden.')
|
||
}
|
||
const nummer = String(auftrag.nummer || 'Auftragsbestaetigung').replace(/[^A-Za-z0-9-]/g, '') || 'Auftragsbestaetigung'
|
||
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` })
|
||
res.end(buffer)
|
||
}
|
||
|
||
router.get('/:token', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
|
||
try {
|
||
const a = await ladeNachToken(req, res)
|
||
if (a) res.json({ auftrag: oeffentlicheAnsicht(a) })
|
||
} catch (err) {
|
||
sendeFehler(res, 'auftrag', err)
|
||
}
|
||
})
|
||
|
||
router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
|
||
try {
|
||
const a = await ladeNachToken(req, res)
|
||
if (a) await sendePdf(res, a)
|
||
} catch (err) {
|
||
sendeFehler(res, 'auftrag', err)
|
||
}
|
||
})
|
||
|
||
router.post('/:token/bestaetigen', postLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
|
||
try {
|
||
const a = await ladeNachToken(req, res)
|
||
if (!a) return
|
||
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
||
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
|
||
const neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() })
|
||
res.json({ auftrag: oeffentlicheAnsicht(neu) })
|
||
} catch (err) {
|
||
sendeFehler(res, 'auftrag', err)
|
||
}
|
||
})
|
||
|
||
export default router
|