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: { : 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:'); 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. */ 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