Aufträge L5: öffentliche Link-Routen (ansehen, PDF) mit Ratenbegrenzung, Schalter und Statusprüfung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
576a17bfa4
commit
aca393d564
2 files changed
+211
-3
No files matched your search
@@ -1,10 +1,104 @@
|
||||
/**
|
||||
* Gerüst von Strang L0: öffentliche Bestätigung einer Auftragsbestätigung per Token (ohne Anmeldung, das Token steht
|
||||
* in der Adresse). Mount: /api/auftrag. Kein Sitzungs- und kein Wächter-Eintrag nötig. L5 ergänzt die Ratenbegrenzung
|
||||
* im Arbeitsspeicher (keine IP-Adressen speichern) und GET /:token, GET /:token/pdf, POST /:token/bestaetigen.
|
||||
* Ö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 { GESPERRT_MELDUNG } 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 = 'Dieser Link ist ungültig oder abgelaufen.'
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in new issue
Block a user