GET /:siteKey liefert dann die Widerrufsseite (nurWiderruf), POST /:siteKey/widerruf samt Preflight geht durch, solange die Funktion active oder disabled ist. Bestellen, Pruefen, Status und Bilder bleiben gesperrt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
674 lines
30 KiB
JavaScript
674 lines
30 KiB
JavaScript
import express, { Router } from 'express'
|
|
import { config } from '../config.js'
|
|
import { analyseDomains } from '../services/analyse.js'
|
|
import { getDocument } from '../services/appwriteAdmin.js'
|
|
import { bildMeta, bildPfad } from '../services/shop/bilder.js'
|
|
import { abschlussAusstehend, bestellungLesen, ipHashVon, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js'
|
|
import { sessionHolen } from '../services/shop/connect.js'
|
|
import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js'
|
|
import { euro } from '../services/shop/geld.js'
|
|
import { berechne } from '../services/shop/preise.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, pruefeFormToken } from '../services/shop/token.js'
|
|
import { eingangText, erfasseWiderruf } from '../services/shop/widerrufe.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 oder eine Funktion, die nicht active oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Bestellen,
|
|
* Pruefen, Status und Bilder zusaetzlich nur bei Modus on bzw. Pilotmodus mit Besitzer auf der Pilotliste. Eine abgeschaltete
|
|
* Funktion (disabled), Modus off und ein Besitzer ausserhalb der Pilotliste antworten beim Lesen mit
|
|
* { aktiv: true, offen: false, nurWiderruf: true, ... }, und POST /widerruf (samt Preflight) geht weiter: die Widerrufsfunktion
|
|
* ist gesetzliche Pflicht (§ 356a BGB), solange es Bestellungen geben kann.
|
|
* - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals
|
|
* (config.publicBaseUrl) und gleicher Herkunft (Host-Header), Origin in kanonischer Schreibweise; sonst 403. Meldungen ohne
|
|
* Daten (429, 503, aktiv:false) tragen `*`, bei geprueftem POST die Origin.
|
|
* - 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
|
|
if (typeof err.produktId === 'string') body.produktId = err.produktId
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Zugang fuer die Widerrufsfunktion (§ 356a BGB): sie bleibt erreichbar, solange die Funktion active oder disabled ist, auch bei
|
|
* SHOP_ENABLED=off und wenn der Besitzer nicht (mehr) zum Pilotkreis gehoert; die gesetzliche Pflicht laeuft mit den Bestellungen
|
|
* weiter. Bestellen bleibt gesperrt (`bestellbar`). Bei off wird die Pilotliste gar nicht erst gelesen.
|
|
* @returns {Promise<{ ctx: object|null, bestellbar: boolean }>}
|
|
*/
|
|
async function widerrufZugang(siteKey, now) {
|
|
const ctx = await shopPerSiteKey(siteKey, { now })
|
|
if (!ctx) return { ctx: null, bestellbar: false }
|
|
const modus = shopModus()
|
|
const bestellbar = modus === 'on' || (modus === 'pilot' && shopFuerKunde(await besitzerEmail(ctx)))
|
|
return { ctx, bestellbar }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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, bestellbar } = await widerrufZugang(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 (Funktion disabled, SHOP_ENABLED=off oder ausserhalb des Pilotkreises): nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar
|
|
if (ctx.doc.state === 'disabled' || !bestellbar) {
|
|
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,
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
|
|
// ---------------------------------------------------------------- Herkunft, POST-Hilfen
|
|
|
|
const MELDUNG_NEU_LADEN = 'Bitte laden Sie die Seite neu.'
|
|
const nichtErlaubt = () => fehlerMit(403, { _: 'Nicht erlaubt' })
|
|
/** POST-Routen, deren Preflight dieselbe Pruefung bekommt (F3 widerruf; F2 pruefen, bestellung). */
|
|
const POST_ROUTEN = new Set(['widerruf', 'pruefen', 'bestellung'])
|
|
|
|
/**
|
|
* Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project) (Seiten des Betriebs), Host von
|
|
* config.publicBaseUrl (Seiten des Portals) oder gleiche Herkunft wie die Anfrage (Host-Header). Sonst null (fehlende, fremde,
|
|
* unlesbare Origin oder nicht in kanonischer Schreibweise).
|
|
*/
|
|
function erlaubteHerkunft(req, project) {
|
|
const origin = req.get('origin')
|
|
if (typeof origin !== 'string' || origin === '') return null
|
|
let url
|
|
try {
|
|
url = new URL(origin)
|
|
} catch {
|
|
return null
|
|
}
|
|
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null
|
|
const erlaubt = new Set([String(req.get('host') || '').toLowerCase()])
|
|
try {
|
|
erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase())
|
|
} catch {
|
|
// Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft
|
|
}
|
|
if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain)
|
|
return erlaubt.has(url.host) ? origin : null
|
|
}
|
|
|
|
/** Prueft die Herkunft eines POST und merkt sie in cors; sonst 403. */
|
|
function pruefeHerkunft(req, cors, project) {
|
|
const origin = erlaubteHerkunft(req, project)
|
|
if (!origin) throw nichtErlaubt()
|
|
cors.erlaubt = origin
|
|
}
|
|
|
|
/** Antwort auf einen Preflight (204): erlaubte Methoden, Content-Type, 24 Stunden gueltig. */
|
|
const vorabantwort = (origin) => ({
|
|
status: 204,
|
|
body: '',
|
|
headers: {
|
|
'Access-Control-Allow-Origin': origin,
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
'Access-Control-Max-Age': '86400',
|
|
Vary: 'Origin',
|
|
},
|
|
})
|
|
|
|
// Preflight unter /:siteKey/<POST-Route>: erlaubt fuer Herkunft des Betriebs, des Portals und gleiche Herkunft; ohne
|
|
// erreichbaren Shop (unbekannt, off, Pilot) gibt es nichts zu pruefen und nichts zu verraten.
|
|
router.options(
|
|
'/:siteKey/:was',
|
|
route(async (req, now, cors) => {
|
|
if (!POST_ROUTEN.has(req.params.was)) throw nichtGefunden()
|
|
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
|
// Widerruf: auch ohne Bestellmodus erreichbar (siehe widerrufZugang); alle anderen Routen nur bei erreichbarem Shop
|
|
const ctx = req.params.was === 'widerruf' ? (await widerrufZugang(req.params.siteKey, now)).ctx : await erreichbarerShop(req.params.siteKey, now)
|
|
if (!ctx) return vorabantwort('*')
|
|
pruefeHerkunft(req, cors, ctx.project)
|
|
return vorabantwort(cors.erlaubt)
|
|
}),
|
|
)
|
|
|
|
// ---------------------------------------------------------------- Widerruf
|
|
|
|
/**
|
|
* Honigtopf `kontakt_url` (jeder Wert ausser leer) und Formular-Token: Honigtopf oder zu schnell abgeschickt => dieselbe
|
|
* Erfolgsantwort wie sonst, gespeichert wird nichts; fehlendes, fremdes oder altes Token => 400 "Seite neu laden".
|
|
* @returns {boolean} true, wenn nur so getan werden soll (Falle)
|
|
*/
|
|
function istFalle(siteKey, b, now) {
|
|
const honig = b.kontakt_url
|
|
if (honig !== undefined && honig !== null && !(typeof honig === 'string' && honig.trim() === '')) return true
|
|
const token = pruefeFormToken(siteKey, b.formToken, now)
|
|
if (token === 'zu_schnell') return true
|
|
if (token !== 'ok') throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
|
|
return false
|
|
}
|
|
|
|
router.post(
|
|
'/:siteKey/widerruf',
|
|
route(
|
|
async (req, now, cors) => {
|
|
const { siteKey } = req.params
|
|
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
|
const { ctx } = await widerrufZugang(siteKey, now) // active und disabled, unabhaengig von SHOP_ENABLED und Pilotliste: der Widerruf bleibt moeglich
|
|
if (!ctx) return aktivFalse(req)
|
|
pruefeHerkunft(req, cors, ctx.project)
|
|
const b = req.body
|
|
if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
|
|
if (istFalle(siteKey, b, now)) return { body: { ok: true, eingang: eingangText(now) } }
|
|
|
|
let ergebnis
|
|
try {
|
|
ergebnis = await erfasseWiderruf(ctx, { name: b.name, email: b.email, bestellnummer: b.bestellnummer, nachricht: b.nachricht }, { now, ip: req.ip })
|
|
} catch (err) {
|
|
if (err?.status === 429) throw zuViele(3600)
|
|
throw err
|
|
}
|
|
return { body: { ok: true, eingang: ergebnis.eingang } }
|
|
},
|
|
{ mindestdauer: true },
|
|
),
|
|
)
|
|
|
|
// ---------------------------------------------------------------- Pruefen, Bestellung, Status (F2)
|
|
|
|
const PRUEFEN_JE_IP = 60
|
|
const BESTELLUNG_JE_IP = 10
|
|
const BESTELLUNG_FENSTER_MS = 10 * MINUTE_MS
|
|
const BESTELLUNG_JE_SHOP = 300
|
|
const STATUS_JE_IP = 120
|
|
const STUNDE_MS = 60 * MINUTE_MS
|
|
const BESTELL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
|
const BESTELL_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/
|
|
const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.'
|
|
const GESCHLOSSEN = () => fehlerMit(409, { _: GESCHLOSSEN_TEXT }, { code: 'geschlossen' })
|
|
// Fehlercodes aus berechne/legeBestellungAn, die Kaeufer sehen duerfen (Stripe-Fehler haben ebenfalls einen String-code)
|
|
const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'mindestbetrag', 'zu_viele', 'zu_viele_offen', 'zahlung_nicht_moeglich'])
|
|
const KONFLIKT_CODES = new Set(['ausverkauft', 'nicht_verfuegbar', 'nur_abholung'])
|
|
|
|
/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON (wie bei legeBestellungAn). */
|
|
function produktKarte(docs) {
|
|
const karte = new Map()
|
|
for (const doc of docs) {
|
|
let p
|
|
try {
|
|
p = JSON.parse(doc.produkt)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (istObjekt(p)) karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false })
|
|
}
|
|
return karte
|
|
}
|
|
|
|
/** Fehler aus legeBestellungAn fuer den Kaeufer: eigene Fehler (bekannter code) mit ihrem Text, alles andere 503 ohne Rohtext. */
|
|
function bestellFehler(err) {
|
|
if (typeof err?.code === 'string' && KAEUFER_CODES.has(err.code) && Number.isInteger(err.status) && err.status >= 400 && err.status < 600) {
|
|
const extra = { code: err.code, ...(typeof err.produktId === 'string' ? { produktId: err.produktId } : {}), ...(Number.isInteger(err.retryAfter) ? { retryAfter: err.retryAfter } : {}) }
|
|
if (err.status < 500) return fehlerMit(err.status, { _: String(err.message) }, extra)
|
|
return Object.assign(new Error(String(err.message)), { status: 503, fehler: { _: String(err.message) }, ...extra, dienst: true })
|
|
}
|
|
return err
|
|
}
|
|
|
|
// 5xx mit Kaeufertext (zahlung_nicht_moeglich): fehlerErgebnis kennt nur 4xx, also hier eigene Abbildung
|
|
const bestellErgebnis = (err) => {
|
|
const e = bestellFehler(err)
|
|
if (e?.dienst) return { status: 503, body: { fehler: e.fehler, code: e.code } }
|
|
throw e
|
|
}
|
|
|
|
/** Eingabe-Objekt des Kaeufers: nur positionen, lieferart und land (Preise, Namen u. a. werden nie gelesen). */
|
|
const warenkorbEingabe = (b) => ({ positionen: b.positionen, lieferart: b.lieferart, land: b.land })
|
|
|
|
router.post(
|
|
'/:siteKey/pruefen',
|
|
route(async (req, now, cors) => {
|
|
const { siteKey } = req.params
|
|
if (!zaehle(`ip:shop-pruefen:${ipSchluessel(req.ip)}`, PRUEFEN_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
|
const ctx = await erreichbarerShop(siteKey, now)
|
|
if (!ctx) return aktivFalse(req)
|
|
pruefeHerkunft(req, cors, ctx.project)
|
|
const b = req.body
|
|
if (!istObjekt(b)) throw fehlerMit(400, UNGUELTIG.fehler)
|
|
|
|
const docs = await listeProdukte(ctx.shop.id)
|
|
const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: docs }), reserviert(ctx.shop.id, now)])
|
|
if (!offen) throw GESCHLOSSEN()
|
|
const eingabe = warenkorbEingabe(b)
|
|
const ergebnis = berechne({ produkte: produktKarte(docs), ...eingabe, einstellungen: ctx.shop.einstellungen, reserviert: belegt })
|
|
if (!ergebnis.ok) {
|
|
const { code, text, produktId } = ergebnis.fehler
|
|
throw fehlerMit(KONFLIKT_CODES.has(code) ? 409 : 400, { _: text }, { code, ...(produktId ? { produktId } : {}) })
|
|
}
|
|
const widerrufTexte = new Map()
|
|
for (const doc of docs) widerrufTexte.set(doc.$id, produktOeffentlich(doc, { einstellungen: ctx.shop.einstellungen, siteKey })?.widerrufText ?? '')
|
|
return {
|
|
body: {
|
|
ok: true,
|
|
positionen: ergebnis.positionen.map((p) => ({
|
|
produktId: p.produktId,
|
|
varianteId: p.varianteId,
|
|
name: p.name,
|
|
varianteText: p.varianteText,
|
|
menge: p.menge,
|
|
einzelText: p.einzelText,
|
|
summeText: p.summeText,
|
|
widerrufText: widerrufTexte.get(p.produktId) ?? '',
|
|
})),
|
|
texte: ergebnis.texte,
|
|
lieferart: ergebnis.lieferart,
|
|
land: ergebnis.land,
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
|
|
router.post(
|
|
'/:siteKey/bestellung',
|
|
route(
|
|
async (req, now, cors) => {
|
|
const { siteKey } = req.params
|
|
if (!zaehle(`ip:shop-bestellung:${ipSchluessel(req.ip)}`, BESTELLUNG_JE_IP, BESTELLUNG_FENSTER_MS, now)) throw zuViele(600)
|
|
const ctx = await erreichbarerShop(siteKey, now)
|
|
if (!ctx) return aktivFalse(req)
|
|
pruefeHerkunft(req, cors, ctx.project)
|
|
if (!zaehle(`shop-bestellung:${siteKey}`, BESTELLUNG_JE_SHOP, STUNDE_MS, now)) throw zuViele(3600)
|
|
const b = req.body
|
|
if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
|
|
if (istFalle(siteKey, b, now)) return { body: { url: pruefeRueckUrl(b.rueckUrl, ctx) } }
|
|
|
|
try {
|
|
const { url } = await legeBestellungAn(ctx, { ...warenkorbEingabe(b), rueckUrl: b.rueckUrl }, { now, ipHash: ipHashVon(req.ip) })
|
|
return { body: { url } }
|
|
} catch (err) {
|
|
return bestellErgebnis(err)
|
|
}
|
|
},
|
|
{ mindestdauer: true },
|
|
),
|
|
)
|
|
|
|
/** E-Mail-Adresse verdeckt: erstes Zeichen, ***, Domain ('m***@example.com'); leer, wenn es keine gibt. */
|
|
function maskiereEmail(email) {
|
|
const t = typeof email === 'string' ? email.trim() : ''
|
|
const at = t.lastIndexOf('@')
|
|
if (at < 1 || at === t.length - 1) return ''
|
|
return `${t[0]}***@${t.slice(at + 1)}`
|
|
}
|
|
|
|
router.get(
|
|
'/:siteKey/bestellung/:id',
|
|
route(async (req, now) => {
|
|
const { siteKey, id } = req.params
|
|
if (!zaehle(`ip:shop-status:${ipSchluessel(req.ip)}`, STATUS_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
|
const keine = () => fehlerMit(404, { _: 'Bestellung nicht gefunden.' }) // fuer jeden Fehlerfall dieselbe Antwort
|
|
const t = typeof req.query.t === 'string' ? req.query.t : ''
|
|
if (!BESTELL_ID_RE.test(id) || !BESTELL_TOKEN_RE.test(t)) throw keine()
|
|
const ctx = await erreichbarerShop(siteKey, now) // active und disabled
|
|
if (!ctx) throw keine()
|
|
let order = await bestellungLesen(id)
|
|
if (!order || order.shopId !== ctx.shop.id || !tokenPasst(order, t)) throw keine()
|
|
|
|
// Noch offen: einmal bei Stripe nachsehen (der Webhook kann spaeter kommen als die Rueckkehr des Kaeufers); bezahlt, aber mit
|
|
// fehlendem Bestandsabzug oder fehlenden Mails (Teilfehler): schliesseAb holt die Schritte nach
|
|
const accountId = ctx.shop.stripeAccountId
|
|
if ((order.status === 'offen' || abschlussAusstehend(order)) && order.stripeSessionId && accountId) {
|
|
try {
|
|
const session = await sessionHolen(accountId, order.stripeSessionId)
|
|
if (session?.status === 'complete' && session.payment_status === 'paid') {
|
|
await schliesseAb({ accountId, session, now })
|
|
order = (await bestellungLesen(id)) ?? order
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[publicShop] Bestellstatus: Stripe-Abgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`)
|
|
}
|
|
}
|
|
const gesamt = order.daten?.summen?.texte?.gesamt
|
|
return {
|
|
body: {
|
|
status: String(order.status ?? ''),
|
|
bestellnummer: String(order.bestellnummer ?? ''),
|
|
gesamtText: typeof gesamt === 'string' && gesamt !== '' ? gesamt : euro(Number.isInteger(order.summeCent) ? order.summeCent : 0),
|
|
lieferart: order.daten?.lieferart === 'abholung' ? 'abholung' : 'versand',
|
|
email: maskiereEmail(order.kaeuferEmail),
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
|
|
// ---------------------------------------------------------------- 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
|