Shop F3: POST /:siteKey/widerruf (Herkunft, Honigtopf, Formular-Token, Mindestantwortzeit), Preflight für POST-Routen, Konfig formMinSekunden und minAntwortMs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf99f659eb
commit
987b184aa1
3 files changed
+500
-2
No files matched your search
@@ -812,6 +812,10 @@ export const config = {
|
||||
maxVarianten: 30,
|
||||
// Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J
|
||||
anonymisierenJahre: 3,
|
||||
// Mindestzeit (Sekunden) zwischen Laden und Absenden des Widerrufs-/Bestellformulars (schneller = Bot)
|
||||
formMinSekunden: nonNegativeNumber(process.env.SHOP_FORM_MIN_SEKUNDEN, 3),
|
||||
// Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle (verwischt Zeitunterschiede). Tests setzen 0.
|
||||
minAntwortMs: nonNegativeNumber(process.env.SHOP_MIN_ANTWORT_MS, 300),
|
||||
// Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten
|
||||
loeschTageNachAbschalten: 90,
|
||||
// Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht
|
||||
|
||||
+114
-2
@@ -1,5 +1,6 @@
|
||||
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 { reserviert } from '../services/shop/bestellungen.js'
|
||||
@@ -8,7 +9,8 @@ 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 { formToken, pruefeFormToken } from '../services/shop/token.js'
|
||||
import { eingangText, erfasseWiderruf } from '../services/shop/widerrufe.js'
|
||||
import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js'
|
||||
|
||||
/**
|
||||
@@ -25,7 +27,9 @@ import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js
|
||||
* - 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 `*`.
|
||||
* - 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.
|
||||
*/
|
||||
@@ -338,6 +342,114 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- 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)
|
||||
const 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 erreichbarerShop(siteKey, now) // active und disabled: 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 },
|
||||
),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Artikelbilder
|
||||
|
||||
router.get(
|
||||
|
||||
Reference in new issue
Block a user