/** * Ö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