Shop F1: öffentliche Schnittstelle zum Lesen (Konfiguration mit Artikeln, Artikelbilder, Grenzen, Formular-Token)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9722f0a938
commit
98d1e6613d
4 files changed
+868
No files matched your search
@@ -38,6 +38,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
||||
import newsRoutes from './routes/news.js'
|
||||
import publicNewsRoutes from './routes/publicNews.js'
|
||||
import publicTermineRoutes from './routes/publicTermine.js'
|
||||
import publicShopRoutes from './routes/publicShop.js'
|
||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||
import { legalRouter } from './services/legalDocs.js'
|
||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||
@@ -60,6 +61,8 @@ app.use(kundenansichtGuard)
|
||||
// Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und
|
||||
// haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde
|
||||
app.use('/api/public/termine/v1', publicTermineRoutes)
|
||||
// Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser
|
||||
app.use('/api/public/shop/v1', publicShopRoutes)
|
||||
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
|
||||
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
|
||||
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import express, { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { bildMeta, bildPfad } from '../services/shop/bilder.js'
|
||||
import { reserviert } from '../services/shop/bestellungen.js'
|
||||
import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js'
|
||||
import { euro } from '../services/shop/geld.js'
|
||||
import { listeProdukte, parseProdukt, produktOeffentlich } from '../services/shop/produkte.js'
|
||||
import { GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, versandUndZahlung, zahlungsartenText } from '../services/shop/rechtstexte.js'
|
||||
import { shopFuerKunde, shopModus } from '../services/shop/schalter.js'
|
||||
import { formToken } from '../services/shop/token.js'
|
||||
import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js'
|
||||
|
||||
/**
|
||||
* Oeffentliche Schnittstelle des Webshops: /api/public/shop/v1 (Plan 2026-10-05-webshop.md, Block F; Muster
|
||||
* routes/publicTermine.js). Ohne Session, ohne Captcha. Hier steht das Lesen (F1: Konfiguration samt Artikeln, Artikelbilder);
|
||||
* Bestellung, Status und Widerruf (F2/F3) kommen als POST/GET-Routen in dieselbe Datei und nutzen route(), schreibe() und die
|
||||
* Fehlerhelfer.
|
||||
*
|
||||
* - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem
|
||||
* globalen express.json (2 MB). Antworten sind immer JSON { ... } oder { fehler: { <feld|_>: Text }, code? }; Rohtexte von
|
||||
* Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar").
|
||||
* - Antworten enthalten nie customerId, projectId, featureId, $id, Stripe-IDs oder E-Mail-Adressen ausser der oeffentlichen
|
||||
* Anbieter-Adresse des Haendlers.
|
||||
* - Unbekannter siteKey, Modus off, Pilotmodus fuer einen Kunden ausserhalb der Pilotliste oder eine Funktion, die nicht active
|
||||
* oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Eine abgeschaltete Funktion (disabled) antwortet mit
|
||||
* { aktiv: true, offen: false, nurWiderruf: true, ... }, damit Kaeufer den Widerruf weiter erklaeren koennen.
|
||||
* - CORS: GET fuer alle (*). Meldungen ohne Daten (429, 503, aktiv:false) tragen `*`.
|
||||
* - Grenzen: je IP 120 je Minute (darfAnfrage), je Shop 600 je Minute (Schluessel 'shop:<siteKey>'); die Artikelbilder haben
|
||||
* eigene, groessere Zaehler, damit eine Seite mit vielen Bildern die Konfiguration nicht aussperrt.
|
||||
*/
|
||||
|
||||
const router = Router()
|
||||
|
||||
const BODY_MAX = 16 * 1024
|
||||
const MINUTE_MS = 60 * 1000
|
||||
const SHOP_JE_MINUTE = 600
|
||||
const BILD_JE_IP = 600
|
||||
const BILD_JE_SHOP = 6000
|
||||
const BILD_RE = /^([a-z0-9-]{1,64})-(s|l)\.webp$/
|
||||
const MIN_ANTWORT_STANDARD_MS = 300
|
||||
const LAENDER_NAMEN = { DE: 'Deutschland', AT: 'Österreich' }
|
||||
const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit']
|
||||
|
||||
const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } }
|
||||
const NICHT_GEFUNDEN = { fehler: { _: 'Nicht gefunden' } }
|
||||
const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } }
|
||||
const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } }
|
||||
const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.'
|
||||
|
||||
// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter).
|
||||
let uhr = () => new Date()
|
||||
export function setzeUhrFuerTests(neueUhr) {
|
||||
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
|
||||
}
|
||||
|
||||
/** Besitzer-Adressen fuer den Pilotmodus je ctx (ein ctx aus shopPerSiteKey lebt 30 Sekunden): ctx -> Promise<E-Mail|''>. */
|
||||
const besitzer = new WeakMap()
|
||||
export function resetPublicShopForTests() {
|
||||
uhr = () => new Date()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Antworten und Fehler
|
||||
|
||||
/** Fehler mit Statuscode fuer den Besucher (4xx); `fehlerErgebnis` macht daraus die Antwort. */
|
||||
const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra })
|
||||
const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS })
|
||||
const nichtGefunden = () => fehlerMit(404, NICHT_GEFUNDEN.fehler)
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {})
|
||||
|
||||
/** Antwort fuer einen Fehler: Fehler des Besuchers (4xx mit `fehler`) wie sie sind, alles andere 503 ohne Rohtext. */
|
||||
function fehlerErgebnis(err) {
|
||||
const status = err?.status
|
||||
const fehler = nurTexte(err?.fehler)
|
||||
if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) {
|
||||
const body = { fehler }
|
||||
if (typeof err.code === 'string') body.code = err.code
|
||||
return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} }
|
||||
}
|
||||
console.error(`[publicShop] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
return { status: 503, body: NICHT_ERREICHBAR }
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null }
|
||||
* (fuer POST, F2/F3). GET und HEAD tragen immer `*`; Meldungen ohne Daten (429, 503, aktiv:false) auch bei POST.
|
||||
*/
|
||||
function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
|
||||
const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis
|
||||
const lesen = req.method === 'GET' || req.method === 'HEAD'
|
||||
if (lesen) res.set('Access-Control-Allow-Origin', '*')
|
||||
else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' })
|
||||
else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*')
|
||||
res.status(status)
|
||||
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
|
||||
if (status === 204) return res.end()
|
||||
const inhalt = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) })
|
||||
return res.end(inhalt)
|
||||
}
|
||||
|
||||
/** Sendet eine Datei (Ergebnis { datei: absoluter Pfad }); fehlt sie, antwortet 404 wie sonst. */
|
||||
function sendeDatei(req, res, ergebnis) {
|
||||
return new Promise((resolve) => {
|
||||
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' })
|
||||
res.sendFile(
|
||||
ergebnis.datei,
|
||||
{ cacheControl: false, dotfiles: 'deny', headers: { 'Cache-Control': ergebnis.cache, 'X-Content-Type-Options': 'nosniff' } },
|
||||
(err) => {
|
||||
if (err && !res.headersSent) {
|
||||
for (const kopf of ['Cache-Control', 'Content-Type', 'Content-Length', 'Cross-Origin-Resource-Policy']) res.removeHeader(kopf)
|
||||
schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN })
|
||||
} else if (err) res.destroy()
|
||||
resolve()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert: schlichtes 503, sofern noch nichts gesendet wurde. */
|
||||
function notantwort(res, err) {
|
||||
try {
|
||||
console.error(`[publicShop] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
} catch {
|
||||
// das Log darf die Notantwort nicht verhindern
|
||||
}
|
||||
try {
|
||||
if (res.headersSent) return void res.end()
|
||||
const inhalt = JSON.stringify(NICHT_ERREICHBAR)
|
||||
res.statusCode = 503
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII
|
||||
return void res.end(inhalt)
|
||||
} catch {
|
||||
try {
|
||||
res.destroy()
|
||||
} catch {
|
||||
// nichts mehr zu tun
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schreibeSicher(req, res, ergebnis, cors) {
|
||||
try {
|
||||
schreibe(req, res, ergebnis, cors)
|
||||
} catch (err) {
|
||||
notantwort(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Shops (GET kurz im Browser-Cache). */
|
||||
const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' })
|
||||
|
||||
const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const minAntwortMs = () => (Number.isFinite(config.shop?.minAntwortMs) && config.shop.minAntwortMs >= 0 ? config.shop.minAntwortMs : MIN_ANTWORT_STANDARD_MS)
|
||||
|
||||
/**
|
||||
* Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe
|
||||
* (oder { datei, cache } fuer sendeDatei) oder wirft; cors.erlaubt setzt fn, sobald die Herkunft geprueft ist (F2/F3).
|
||||
* Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs (ab Beginn der Anfrage).
|
||||
*/
|
||||
const route = (fn, { mindestdauer = false } = {}) => async (req, res) => {
|
||||
try {
|
||||
const cors = { erlaubt: null }
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await fn(req, uhr(), cors)
|
||||
} catch (err) {
|
||||
ergebnis = fehlerErgebnis(err)
|
||||
}
|
||||
if (mindestdauer) {
|
||||
const rest = minAntwortMs() - (performance.now() - req.shopStart)
|
||||
if (rest > 0) await warte(rest)
|
||||
}
|
||||
if (ergebnis.datei) await sendeDatei(req, res, ergebnis)
|
||||
else schreibe(req, res, ergebnis, cors)
|
||||
} catch (err) {
|
||||
notantwort(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Beginn: Zeit, Rumpf
|
||||
|
||||
router.use((req, res, next) => {
|
||||
req.shopStart = performance.now()
|
||||
// Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen).
|
||||
if (Number(req.get('content-length')) > BODY_MAX) {
|
||||
res.set('Connection', 'close')
|
||||
return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
router.use(express.json({ limit: BODY_MAX }))
|
||||
|
||||
// ---------------------------------------------------------------- Zugang
|
||||
|
||||
/** E-Mail-Adresse des Shop-Besitzers (leer, wenn der Kunde fehlt); je ctx einmal geladen. */
|
||||
function besitzerEmail(ctx) {
|
||||
let gemerkt = besitzer.get(ctx)
|
||||
if (!gemerkt) {
|
||||
gemerkt = getDocument(config.collections.customers, ctx.shop.customerId).then(
|
||||
(kunde) => String(kunde?.email ?? ''),
|
||||
(err) => {
|
||||
if (err?.status === 404) return ''
|
||||
besitzer.delete(ctx) // Fehler nicht merken
|
||||
throw err
|
||||
},
|
||||
)
|
||||
besitzer.set(ctx, gemerkt)
|
||||
}
|
||||
return gemerkt
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Kontext des Shops, wenn er ueber die Schnittstelle erreichbar ist; sonst null. Modus off => null ohne jede Abfrage;
|
||||
* im Pilotmodus muss der Besitzer auf der Pilotliste stehen (SHOP_PILOT_EMAILS).
|
||||
*/
|
||||
async function erreichbarerShop(siteKey, now) {
|
||||
const modus = shopModus()
|
||||
if (modus === 'off') return null
|
||||
const ctx = await shopPerSiteKey(siteKey, { now })
|
||||
if (!ctx) return null
|
||||
if (modus === 'pilot' && !shopFuerKunde(await besitzerEmail(ctx))) return null
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Konfiguration
|
||||
|
||||
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
|
||||
const basisUrl = () => String(config.publicBaseUrl ?? '').replace(/\/+$/, '')
|
||||
|
||||
function anbieterOeffentlich(a) {
|
||||
return { name: a.name, strasse: a.strasse, plz: a.plz, ort: a.ort, land: a.land, email: a.email, telefon: a.telefon }
|
||||
}
|
||||
|
||||
/** Kurzer Versandtext, z. B. 'Versand nach Deutschland 4,90 €, ab 50,00 € versandkostenfrei'. */
|
||||
function versandText(e) {
|
||||
if (!e.versand.aktiv) return e.abholung.aktiv ? 'Nur Abholung' : ''
|
||||
const laender = (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => {
|
||||
const name = LAENDER_NAMEN[code] ?? code
|
||||
const kosten = e.versand.kostenCent?.[code]
|
||||
return Number.isInteger(kosten) ? `${name} ${euro(kosten)}` : name
|
||||
})
|
||||
let t = laender.length > 0 ? `Versand nach ${laender.join(', ')}` : 'Versand'
|
||||
if (Number.isInteger(e.versand.freiAbCent)) t += `, ab ${euro(e.versand.freiAbCent)} versandkostenfrei`
|
||||
return t
|
||||
}
|
||||
|
||||
function steuerHinweisKurz(e) {
|
||||
return e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : 'Alle Preise inkl. MwSt.'
|
||||
}
|
||||
|
||||
/** Der Teil `shop` der Antwort (Anzeige- und Kasseninformationen des Haendlers). */
|
||||
function shopOeffentlich(e) {
|
||||
const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : []
|
||||
return {
|
||||
name: e.shopname,
|
||||
akzent: e.akzent,
|
||||
anbieter: anbieterOeffentlich(e.anbieter),
|
||||
lieferzeit: e.lieferzeit,
|
||||
lieferlaender: e.versand.aktiv ? (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => ({ code, name: LAENDER_NAMEN[code] ?? code })) : [],
|
||||
abholung: { aktiv: e.abholung.aktiv === true, adresse: e.abholung.adresse, hinweis: e.abholung.hinweis },
|
||||
zahlungsarten: zahlungsartenText(e),
|
||||
zahlungsartenListe: ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a)).map((a) => zahlungsartenText({ zahlungsarten: [a] })),
|
||||
versandText: versandText(e),
|
||||
mindestbestellText: Number.isInteger(e.mindestbestellCent) ? `Mindestbestellwert ${euro(e.mindestbestellCent)}` : '',
|
||||
kleinunternehmer: e.kleinunternehmer === true,
|
||||
steuerHinweisKurz: steuerHinweisKurz(e),
|
||||
}
|
||||
}
|
||||
|
||||
/** Die bestaetigten Rechtstexte (Fassung, die Kaeufer sehen) oder null, solange keine Fassung bestaetigt ist. */
|
||||
function rechtstexteOeffentlich(ctx) {
|
||||
const t = oeffentlicheRechtstexte(ctx)
|
||||
if (!t) return null
|
||||
return {
|
||||
agb: t.agb,
|
||||
widerrufsbelehrung: t.widerrufsbelehrung,
|
||||
widerrufsformular: t.widerrufsformular,
|
||||
versandUndZahlung: versandUndZahlung(ctx.shop.einstellungen),
|
||||
impressumUrl: t.impressumUrl,
|
||||
datenschutzUrl: t.datenschutzUrl,
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:siteKey',
|
||||
route(async (req, now) => {
|
||||
const { siteKey } = req.params
|
||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
||||
const ctx = await erreichbarerShop(siteKey, now)
|
||||
if (!ctx) return aktivFalse(req)
|
||||
if (!zaehle(`shop:${siteKey}`, SHOP_JE_MINUTE, MINUTE_MS, now)) throw zuViele(60)
|
||||
|
||||
const e = ctx.shop.einstellungen
|
||||
const widerrufUrl = `${basisUrl()}/shop/${siteKey}/widerruf`
|
||||
const token = formToken(siteKey, now)
|
||||
|
||||
// Abgeschaltet: nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar
|
||||
if (ctx.doc.state === 'disabled') {
|
||||
return {
|
||||
body: {
|
||||
aktiv: true,
|
||||
offen: false,
|
||||
nurWiderruf: true,
|
||||
shop: { name: e.shopname, akzent: e.akzent, anbieter: anbieterOeffentlich(e.anbieter) },
|
||||
widerrufUrl,
|
||||
formToken: token,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const dokumente = await listeProdukte(ctx.shop.id)
|
||||
const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: dokumente }), reserviert(ctx.shop.id, now)])
|
||||
const produkte = []
|
||||
for (const doc of dokumente) {
|
||||
if (doc.aktiv === false) continue
|
||||
const p = produktOeffentlich(doc, { einstellungen: e, reserviert: belegt, siteKey })
|
||||
if (p) produkte.push(p)
|
||||
}
|
||||
const kategorien = [...new Set(produkte.map((p) => text(p.kategorie)).filter(Boolean))]
|
||||
return {
|
||||
body: {
|
||||
aktiv: true,
|
||||
offen,
|
||||
shop: shopOeffentlich(e),
|
||||
kategorien,
|
||||
produkte,
|
||||
rechtstexte: rechtstexteOeffentlich(ctx),
|
||||
gewaehrleistung: { bild: `${basisUrl()}${GEWAEHRLEISTUNG_BILD}`, text: gewaehrleistungsHinweis() },
|
||||
widerrufUrl,
|
||||
formToken: token,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Artikelbilder
|
||||
|
||||
router.get(
|
||||
'/:siteKey/bild/:datei',
|
||||
route(async (req, now) => {
|
||||
const { siteKey, datei } = req.params
|
||||
const ip = ipSchluessel(req.ip)
|
||||
if (!zaehle(`ip:shop-bild:${ip}`, BILD_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
||||
const format = BILD_RE.exec(datei)
|
||||
const ctx = format ? await erreichbarerShop(siteKey, now) : null
|
||||
if (!ctx || ctx.doc.state !== 'active') throw nichtGefunden() // ohne Hinweis, was nicht stimmt
|
||||
if (!zaehle(`shop-bild:${siteKey}`, BILD_JE_SHOP, MINUTE_MS, now)) throw zuViele(60)
|
||||
|
||||
// Das Bild muss zu einem aktiven Artikel dieses Shops gehoeren
|
||||
const [, bildId, groesse] = format
|
||||
const gehoert = (await listeProdukte(ctx.shop.id)).some((doc) => doc.aktiv !== false && (parseProdukt(doc)?.bilder ?? []).includes(bildId))
|
||||
const pfad = gehoert && (await bildMeta(siteKey, bildId)) ? bildPfad(siteKey, bildId, groesse) : null
|
||||
if (!pfad) throw nichtGefunden()
|
||||
return { datei: pfad, cache: 'public, max-age=86400' }
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers
|
||||
|
||||
router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN }))
|
||||
|
||||
// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers
|
||||
router.use((err, req, res, _next) => {
|
||||
if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
|
||||
if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG })
|
||||
return schreibeSicher(req, res, fehlerErgebnis(err))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
/**
|
||||
* Formular-Token des Webshops (Plan 2026-10-05-webshop.md, Block F): "<Ausstellzeit base36>.<HMAC>" je siteKey, wie das
|
||||
* Formular-Token des Terminplaners, aber mit eigenem Schluessel (Beschriftung 'shop-form', abgeleitet aus config.sessionSecret):
|
||||
* ein Terminplaner-Token gilt im Shop nicht und umgekehrt. Das Token beweist nur Alter und Herkunft (kein Geheimnis fuer den
|
||||
* Besucher). Zu schnell abgeschickt (config.shop.formMinSekunden, Standard 3) = Bot, aelter als 2 Stunden = Seite neu laden.
|
||||
*/
|
||||
|
||||
const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000
|
||||
const FORM_TOKEN_MAX_LAENGE = 100
|
||||
const MAC_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
const ZEIT_RE = /^[0-9a-z]{1,13}$/
|
||||
const MIN_STANDARD_SEKUNDEN = 3
|
||||
|
||||
/** Mindestalter in Sekunden; wird bei jedem Aufruf gelesen (Tests aendern den Wert). */
|
||||
export const formMinSekunden = () => {
|
||||
const wert = config.shop?.formMinSekunden
|
||||
return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN
|
||||
}
|
||||
|
||||
function schluessel() {
|
||||
const geheimnis = config.sessionSecret
|
||||
if (typeof geheimnis !== 'string' || geheimnis.length < 32) {
|
||||
throw new Error('Shop-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).')
|
||||
}
|
||||
return createHmac('sha256', geheimnis).update('shop-form').digest()
|
||||
}
|
||||
|
||||
const zeitMs = (now, was) => {
|
||||
const ms = now instanceof Date ? now.getTime() : Number(now)
|
||||
if (!Number.isFinite(ms) || ms < 0) throw new TypeError(`${was}: now ist kein gueltiger Zeitpunkt`)
|
||||
return ms
|
||||
}
|
||||
|
||||
function formMac(siteKey, zeit) {
|
||||
return createHmac('sha256', schluessel()).update(`${siteKey}|${zeit}`).digest('base64url')
|
||||
}
|
||||
|
||||
/** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */
|
||||
export function formToken(siteKey, now = new Date()) {
|
||||
if (typeof siteKey !== 'string' || siteKey === '') throw new TypeError('formToken: siteKey fehlt')
|
||||
const zeit = zeitMs(now, 'formToken').toString(36)
|
||||
return `${zeit}.${formMac(siteKey, zeit)}`
|
||||
}
|
||||
|
||||
/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */
|
||||
export function pruefeFormToken(siteKey, token, now = new Date()) {
|
||||
if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig'
|
||||
if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig'
|
||||
const teile = token.split('.')
|
||||
if (teile.length !== 2) return 'ungueltig'
|
||||
const [zeit, mac] = teile
|
||||
if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig'
|
||||
const erwartet = Buffer.from(formMac(siteKey, zeit))
|
||||
const gegeben = Buffer.from(mac)
|
||||
if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig'
|
||||
|
||||
const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36)
|
||||
if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen'
|
||||
if (alter < formMinSekunden() * 1000) return 'zu_schnell'
|
||||
return 'ok'
|
||||
}
|
||||
Reference in new issue
Block a user