Shop C7: Kundenrouten der Verwaltung (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder)

- routes/shop.js: gemeinsame Pruefung mitShop (Schalter, eigenes Projekt, Funktion setup|active, lesend auch disabled, shopConfig
  bei Bedarf anlegen); GET /status erweitert (Bereitschaft, geoeffnet, Snippet, Vorschau, Stripe ohne Konto-ID, Zahlen);
  Einstellungen, Artikel (mit Bild-Pruefung, Beanspruchen und Aufraeumen), Sortierung, Rechtstexte (Entwurf, Bestaetigen mit Hash),
  Oeffnen/Schliessen, Bild-Upload (octet-stream, 8 MB) und Vorschau; Schreib-Limit 300 je Stunde und Kunde
- services/shop/bilder.js: Artikelbilder nach dem Muster von newsImages.js (1600/480 px WebP, Sidecar, 24-h-Aufraeumen, 40 Uploads/h)
- Kundenansicht: sieben Verwaltungs-Pfade erlaubt und als shop_aenderung protokolliert; Anfrage, Oeffnen, Bestaetigen, Stripe bleiben gesperrt
- Tests: shopRoutes, shopBilder; Erwartungen von kundenansichtGuard, customerViewLog und shopAnfrage angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:52:53 +00:00
1 parent 9d19a2cbad
commit 9722f0a938
10 files changed
+1389 -32

No files matched your search

+10
View File
@@ -24,6 +24,16 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
// Webshop: Artikel, Einstellungen, Rechtstexte-Entwurf und Bilder darf das Team mit dem Kunden ausfuellen (protokolliert). Genau diese
// sieben Pfade, jeder einzeln: Anfrage, Oeffnen, Bestaetigen der Rechtstexte (der Haendler bestaetigt persoenlich), Stripe und
// Erstattungen bleiben gesperrt, ohne dass es einen Eintrag braucht.
['POST', /^\/api\/shop\/produkte\/?$/],
['POST', /^\/api\/shop\/produkte\/sortierung\/?$/],
['PUT', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['DELETE', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['PUT', /^\/api\/shop\/einstellungen\/?$/],
['PUT', /^\/api\/shop\/rechtstexte\/?$/],
['POST', /^\/api\/shop\/bild\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
+405 -26
View File
@@ -1,30 +1,55 @@
import { Router } from 'express'
import express, { Router } from 'express'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { countDocuments, getDocument, listAllDocuments, Query, updateDocument } from '../services/appwriteAdmin.js'
import { config } from '../config.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { withLocks } from '../services/keyedLock.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
import { shopFuerKunde } from '../services/shop/schalter.js'
import { uploadSlot } from './chat.js'
import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js'
import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js'
import {
bildIdsInGebrauch, holeProdukt, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt,
} from '../services/shop/produkte.js'
import {
BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular,
normalisiereRechtstexte, rechtstexteHash, versandUndZahlung, vollstaendig,
} from '../services/shop/rechtstexte.js'
import {
BILD_MAX_BYTES, beanspruche, bildLimitErreicht, bildMeta, bildPfad, loescheBild, setzeBildLimitZurueck, speichereBild,
} from '../services/shop/bilder.js'
/**
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Dieser Stand (Task B1) enthaelt
* die Anfrage: GET /status, POST /anfrage, POST /anfrage/zurueckziehen. Weitere Routen kommen mit C7, D2 und E5.
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und
* Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe (D2) und Bestellungen (E) kommen dazu.
*
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Schreib-Routen sind in der Kundenansicht gesperrt
* (kundenansichtGuard: nicht auf der Erlaubnisliste; hier zusaetzlich 403) und zaehlen gegen 20 Aufrufe je Kunde und Stunde.
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter
* (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an).
* Kundenansicht: Artikel, Einstellungen, Rechtstexte (nur Entwurf speichern) und Bilder darf das Team mit ausfuellen und werden
* protokolliert (kundenansichtGuard); Anfrage, Oeffnen und Bestaetigen der Rechtstexte bleiben dem Kunden vorbehalten (hier 403).
* Schreib-Routen zaehlen gegen 300 Aufrufe je Kunde und Stunde (Anfrage-Routen gegen 20), Bilder zusaetzlich gegen 40 je Stunde.
* Fehler: { error: <Text>, fehler?: { <feld>: Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie.
*/
const router = Router()
const MAX_WRITES_PER_HOUR = 20
const MAX_ANFRAGE_WRITES_PER_HOUR = 20
const MAX_WRITES_PER_HOUR = 300
const PRODUKT_DOC_MAX_JSON = 100000
const BILD_DATEI_RE = /^([a-z0-9-]{1,64})-(l|s)\.webp$/
const MELDUNG = {
projekt: 'Bitte wähle ein Projekt.',
fremd: 'Kein Zugriff auf dieses Projekt.',
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.',
nichtEingerichtet: 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.',
zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.',
bildFehlt: 'Bild nicht gefunden.',
eingabe: 'Die Eingabe fehlt oder ist unlesbar.',
}
/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */
@@ -35,7 +60,12 @@ function fail(res, err) {
return res.status(500).json({ error: 'Interner Serverfehler' })
}
const body = { ...err.body, error: err.message }
if (err.fehler && typeof err.fehler === 'object') body.fehler = err.fehler
if (err.fehler && typeof err.fehler === 'object') {
body.fehler = err.fehler
const erster = Object.values(err.fehler).find((t) => typeof t === 'string')
if (erster) body.error = erster
}
if (typeof err.code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(err.code) && body.code === undefined) body.code = err.code
return res.status(err.status).json(body)
}
@@ -47,11 +77,15 @@ const route = (fn) => async (req, res, next) => {
}
}
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR })
const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetShopRoutesForTests() {
limiter.reset()
anfrageLimiter.reset()
schreibLimiter.reset()
setzeBildLimitZurueck()
}
const nurKunde = (req, res, next) => {
@@ -61,7 +95,7 @@ const nurKunde = (req, res, next) => {
const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' })
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
const raw = req.params?.projectId ?? req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
@@ -70,9 +104,57 @@ router.use((_req, res, next) => {
next()
})
const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt']
const BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen']
/** Stripe-Status fuer den Browser: nur bekannte Felder, nie Konto-ID oder Schluessel. */
function stripeAnsicht(shop) {
const roh = shop?.stripeStatus
const status = roh ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(roh, k)).map((k) => [k, roh[k]])) : null
return { verbunden: Boolean(shop?.stripeAccountId), status }
}
/** Kennzahlen der Uebersicht (nur Anzeige): Artikel und, best effort, Bestellungen. Ein Lesefehler bei den Bestellungen zaehlt als 0. */
async function kennzahlen(shopId, produkte, now = new Date()) {
const zahlen = {
artikel: produkte.length,
aktiveArtikel: produkte.filter((d) => d.aktiv !== false).length,
offeneBestellungen: 0,
bestellungen30: 0,
umsatz30Cent: 0,
}
try {
zahlen.offeneBestellungen = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')])
const seit = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const neu = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.greaterThanEqual('erstelltAt', seit.toISOString())])
for (const o of neu) {
if (!BEZAHLT.includes(o.status)) continue
zahlen.bestellungen30 += 1
if (Number.isInteger(o.summeCent)) zahlen.umsatz30Cent += o.summeCent
}
} catch (err) {
console.warn(`[shop] Kennzahlen der Bestellungen nicht lesbar: ${String(err?.message ?? '').slice(0, 120)}`)
}
return zahlen
}
const einbauSnippet = (siteKey) => `<div data-webklar-shop="${siteKey}"></div>\n<script src="${config.publicBaseUrl}/embed/shop.js" defer></script>`
const vorschauUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}`
const widerrufUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}/widerruf`
async function ticketWoid(ticketId) {
if (!ticketId) return null
try {
return (await getDocument(config.collections.workorders, ticketId))?.woid || null
} catch {
return null
}
}
/**
* Stand des Webshops fuer ein Projekt: { verfuegbar, state, anfrage }. verfuegbar = Schalter fuer diesen Kunden;
* state = Zustand der Funktion (off, wenn es noch kein Dokument gibt); anfrage = anfrageAnsicht oder null. Legt nichts an.
* Stand des Webshops fuer ein Projekt. verfuegbar = Schalter fuer diesen Kunden; state = Zustand der Funktion (off ohne Dokument);
* anfrage = anfrageAnsicht oder null; mit shopConfig dazu: ticketWoid, bereitschaft ({ bereit, punkte }), geoeffnet (verkauft der Shop
* gerade), schalter (Schalter des Kunden), siteKey, snippet, vorschauUrl, stripe ({ verbunden, status }), zahlen. Legt nichts an.
*/
router.get('/status', requireSession, route(async (req, res) => {
const projectId = projectIdOf(req)
@@ -85,18 +167,38 @@ router.get('/status', requireSession, route(async (req, res) => {
}
const verfuegbar = shopFuerKunde(req.session?.email)
const feature = await getFeatureDoc(customerId, projectId, 'shop')
let shop = null
if (feature) {
try {
shop = await getDocument(config.collections.shopConfig, feature.$id)
} catch (err) {
if (!(err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found')) throw err
}
const shopDoc = feature ? await leseShopConfig(feature.$id) : null
const antwort = {
verfuegbar,
state: feature?.state || 'off',
ticketWoid: null,
anfrage: shopDoc ? anfrageAnsicht(shopDoc) : null,
bereitschaft: null,
geoeffnet: false,
schalter: false,
siteKey: feature?.siteKey || null,
snippet: null,
vorschauUrl: null,
stripe: { verbunden: false, status: null },
zahlen: null,
}
res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null })
if (feature && shopDoc) {
const shop = ladeShop(shopDoc)
const produkte = await listeProdukte(shop.id)
const ctx = { doc: feature, shop, project: null }
antwort.ticketWoid = await ticketWoid(feature.ticketId)
antwort.bereitschaft = await bereitschaft(ctx, { produkte })
antwort.geoeffnet = await istGeoeffnet(ctx, { produkte })
antwort.schalter = shop.geoeffnet
antwort.snippet = einbauSnippet(shop.siteKey || feature.siteKey)
antwort.vorschauUrl = vorschauUrl(shop.siteKey || feature.siteKey)
antwort.stripe = stripeAnsicht(shop)
antwort.zahlen = await kennzahlen(shop.id, produkte)
}
res.json(antwort)
}))
router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => {
router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
const anfrage = await stelleAnfrage({
customerId: getSessionCustomerId(req),
projectId: projectIdOf(req),
@@ -106,9 +208,286 @@ router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req,
res.json({ anfrage })
}))
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) })
res.json({ anfrage })
}))
// ---------------------------------------------------------------- Verwaltung (C7)
const stop = (status, text, code, fehlerFelder) =>
Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}), ...(fehlerFelder ? { fehler: fehlerFelder } : {}) })
const eingabeFehler = (fehlerFelder) => stop(400, Object.values(fehlerFelder)[0], undefined, fehlerFelder)
const personLabel = (p) => (p.name ? `${p.name}${p.email ? ` <${p.email}>` : ''}` : p.email || 'Kunde')
/**
* Gemeinsame Pruefung der Verwaltungs-Routen: projectId, eigenes Projekt (403; archiviert nur lesend), Schalter (409
* nicht_verfuegbar), Funktion im Zustand zustaende (Vorgabe: schreibend setup|active, lesend setup|active|disabled; sonst 409
* nicht_eingerichtet) und shopConfig (fehlt es und der Zustand ist setup|active, wird es angelegt).
* @returns {Promise<{ customerId: string, projectId: string, project: object, doc: object, shopDoc: object, shop: object }>}
*/
async function mitShop(req, { schreiben = false, zustaende } = {}) {
const projectId = projectIdOf(req)
if (!projectId) throw stop(400, MELDUNG.projekt)
const customerId = getSessionCustomerId(req)
let project
try {
project = await assertOwnProject(customerId, projectId, { forNewOrder: schreiben })
} catch (err) {
throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err
}
if (!shopFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
const erlaubt = zustaende || (schreiben ? ['setup', 'active'] : ['setup', 'active', 'disabled'])
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc || !erlaubt.includes(doc.state)) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet')
let shopDoc = await leseShopConfig(doc.$id)
if (!shopDoc && ['setup', 'active'].includes(doc.state)) shopDoc = await ensureShopConfig(doc)
if (!shopDoc) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet')
return { customerId, projectId, project, doc, shopDoc, shop: ladeShop(shopDoc) }
}
/** Middleware: mitShop vor dem Einlesen eines Uploads; Ergebnis in req.shopCtx. */
const mitShopSchreibend = route(async (req, _res, next) => {
req.shopCtx = await mitShop(req, { schreiben: true })
next()
})
const protokoll = (req, text) => logIfKundenansicht(req, 'shop_aenderung', text)
// ---- Einstellungen
const einstellungenAntwort = (shop) => ({ einstellungen: shop.einstellungen, version: shop.version, vollstaendig: istVollstaendig(shop.einstellungen) })
router.get('/einstellungen', requireSession, route(async (req, res) => {
const { shop } = await mitShop(req)
res.json(einstellungenAntwort(shop))
}))
router.put('/einstellungen', requireSession, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const gespeichert = await speichereEinstellungen(shop.id, req.body?.einstellungen, { erwarteteVersion: Number.isInteger(req.body?.version) ? req.body.version : undefined })
await protokoll(req, 'Einstellungen gespeichert')
res.json({ einstellungen: gespeichert.einstellungen, version: gespeichert.version, vollstaendig: istVollstaendig(gespeichert.einstellungen) })
}))
// ---- Artikel
/** Artikel fuer den Haendler: alle gespeicherten Felder, dazu id, aktiv, sort und Vorschau-Adressen der Bilder. */
function produktAnsicht(doc, projectId) {
const p = parseProdukt(doc) || {}
const bilder = Array.isArray(p.bilder) ? p.bilder.filter((b) => typeof b === 'string') : []
return {
...p,
id: doc.$id,
name: typeof p.name === 'string' ? p.name : doc.name || '',
aktiv: doc.aktiv !== false,
sort: Number.isInteger(doc.sort) ? doc.sort : 0,
bilder,
bildUrls: bilder.map((id) => ({
id,
klein: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`,
gross: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-l.webp`,
})),
}
}
const bildIdsVon = (produkt) => (Array.isArray(produkt?.bilder) ? produkt.bilder.filter((b) => typeof b === 'string') : [])
/** Bilder, die nach einer Aenderung kein Artikel des Shops mehr verwendet, loeschen. */
async function raeumeBilderAuf(shop, kandidaten) {
if (kandidaten.length === 0) return
const inGebrauch = await bildIdsInGebrauch(shop.id)
for (const id of kandidaten) if (!inGebrauch.has(id)) await loescheBild(shop.siteKey, id)
}
router.get('/produkte', requireSession, route(async (req, res) => {
const { shop, projectId } = await mitShop(req)
const docs = await listeProdukte(shop.id)
res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)), maxArtikel: config.shop.maxArtikel, maxBilder: config.shop.maxBilder })
}))
/** Artikel anlegen (id null) oder aendern: normalisieren, Bilder pruefen und beanspruchen, entfernte Bilder aufraeumen. */
async function speichereArtikel(req, res, id) {
const { shop, customerId, projectId } = await mitShop(req, { schreiben: true })
const { produkt, fehler } = normalisiereProdukt(req.body?.produkt, { einstellungen: shop.einstellungen })
if (fehler) throw eingabeFehler(fehler)
const neueBilder = bildIdsVon(produkt)
const doc = await withLocks([`shop-bilder:${shop.id}`], async () => {
let vorher = []
if (id) {
const alt = await holeProdukt(shop.id, id)
if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).')
vorher = bildIdsVon(parseProdukt(alt))
}
for (const bildId of neueBilder) {
const meta = await bildMeta(shop.siteKey, bildId)
if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' })
}
const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt)
await beanspruche(shop.siteKey, neueBilder)
await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b)))
return gespeichert
})
await protokoll(req, `Artikel ${id ? 'geändert' : 'angelegt'}: ${produkt.name}`)
res.status(id ? 200 : 201).json({ produkt: produktAnsicht(doc, projectId) })
}
router.post('/produkte', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, null)))
router.post('/produkte/sortierung', requireSession, limitWrites, route(async (req, res) => {
const { shop, projectId } = await mitShop(req, { schreiben: true })
const docs = await sortiere(shop.id, req.body?.ids)
await protokoll(req, 'Artikel sortiert')
res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)) })
}))
router.put('/produkte/:id', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, req.params.id)))
router.delete('/produkte/:id', requireSession, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const name = await withLocks([`shop-bilder:${shop.id}`], async () => {
const alt = await holeProdukt(shop.id, req.params.id)
if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).')
await loescheProdukt(shop.id, req.params.id)
await raeumeBilderAuf(shop, bildIdsVon(parseProdukt(alt)))
return alt.name || ''
})
await protokoll(req, `Artikel gelöscht: ${name}`)
res.json({ ok: true })
}))
// ---- Rechtstexte
/** Antwort der Rechtstexte: Entwurf, zuletzt bestaetigte Fassung, Muster zum Zuruecksetzen, erzeugte Texte und Hash des Entwurfs. */
function rechtstexteAntwort(shop) {
const e = shop.einstellungen
const { entwurf, bestaetigt } = shop.rechtstexte
const hash = rechtstexteHash(entwurf, e)
return {
entwurf,
bestaetigt: bestaetigt ? { texte: bestaetigt.texte, at: bestaetigt.at, von: bestaetigt.von, aktuell: bestaetigt.hash === hash } : null,
muster: {
agb: musterAgb(e),
widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: widerrufUrl(shop.siteKey) }),
widerrufsformular: musterWiderrufsformular(e),
},
versandUndZahlung: versandUndZahlung(e),
gewaehrleistung: { bild: GEWAEHRLEISTUNG_BILD, text: gewaehrleistungsHinweis() },
hash,
bestaetigungText: BESTAETIGUNG_TEXT,
vollstaendig: vollstaendig(entwurf),
}
}
/** shopConfig frisch lesen (unter der Sperre) und als geparsten Shop liefern. */
async function frischerShop(shopId) {
return ladeShop(await getDocument(config.collections.shopConfig, shopId))
}
router.get('/rechtstexte', requireSession, route(async (req, res) => {
const { shop } = await mitShop(req)
res.json(rechtstexteAntwort(shop))
}))
router.put('/rechtstexte', requireSession, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const { rechtstexte, fehler } = normalisiereRechtstexte(req.body?.rechtstexte)
if (fehler) throw eingabeFehler(fehler)
const neu = await withLocks([`shop-rt:${shop.id}`], async () => {
const aktuell = await frischerShop(shop.id)
const json = JSON.stringify({ entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt })
if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.')
await updateDocument(config.collections.shopConfig, shop.id, { rechtstexte: json })
return { ...aktuell, rechtstexte: { entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt } }
})
leereCache(shop.siteKey)
await protokoll(req, 'Rechtstexte-Entwurf gespeichert')
res.json(rechtstexteAntwort(neu))
}))
router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const hash = typeof req.body?.hash === 'string' ? req.body.hash : ''
const von = personLabel(person(req))
const neu = await withLocks([`shop-rt:${shop.id}`], async () => {
const aktuell = await frischerShop(shop.id)
const { entwurf } = aktuell.rechtstexte
if (hash === '' || hash !== rechtstexteHash(entwurf, aktuell.einstellungen)) {
throw stop(409, 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', 'texte_geaendert')
}
if (!vollstaendig(entwurf)) throw stop(400, 'Bitte fülle AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz aus, bevor Du bestätigst.')
const at = new Date().toISOString()
const bestaetigt = { texte: { ...entwurf }, hash, at, von }
const json = JSON.stringify({ entwurf, bestaetigt })
if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.')
await updateDocument(config.collections.shopConfig, shop.id, {
rechtstexte: json,
rechtstexteBestaetigtAt: at,
rechtstexteBestaetigtVon: von,
rechtstexteHash: hash,
})
return { ...aktuell, rechtstexte: { entwurf, bestaetigt } }
})
leereCache(shop.siteKey)
res.json(rechtstexteAntwort(neu))
}))
// ---- Shop oeffnen und schliessen
router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
if (typeof req.body?.offen !== 'boolean') throw stop(400, 'Bitte wähle, ob der Shop geöffnet oder geschlossen sein soll.')
const { shop, doc } = await mitShop(req, { schreiben: true })
const produkte = await listeProdukte(shop.id)
const ctx = { doc, shop, project: null }
if (req.body.offen) {
const stand = await bereitschaft(ctx, { produkte })
if (!stand.bereit) throw Object.assign(stop(409, 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', 'nicht_bereit'), { body: { code: 'nicht_bereit', punkte: stand.punkte } })
}
await updateDocument(config.collections.shopConfig, shop.id, { geoeffnet: req.body.offen, geoeffnetAt: new Date().toISOString() })
leereCache(shop.siteKey)
const neu = { ...shop, geoeffnet: req.body.offen }
res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) })
}))
// ---- Bilder
// Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen
router.post(
'/bild',
requireSession,
limitWrites,
mitShopSchreibend,
(req, res, next) => {
if (bildLimitErreicht(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuVieleBilder })
return next()
},
uploadSlot,
express.raw({ type: 'application/octet-stream', limit: BILD_MAX_BYTES }),
route(async (req, res) => {
const { shop, customerId } = req.shopCtx
const bild = await speichereBild({ customerId, shopId: shop.id, siteKey: shop.siteKey, buffer: req.body })
await protokoll(req, 'Bild hochgeladen')
res.status(201).json({ imageId: bild.imageId, w: bild.w, h: bild.h })
})
)
// Vorschau im Portal: nur fuer den Kunden, dem das Projekt gehoert
router.get('/bild/:projectId/:datei', requireSession, route(async (req, res) => {
const { shop } = await mitShop(req)
const treffer = BILD_DATEI_RE.exec(req.params.datei)
const datei = treffer && (await bildMeta(shop.siteKey, treffer[1])) && bildPfad(shop.siteKey, treffer[1], treffer[2])
if (!datei) return res.status(404).json({ error: MELDUNG.bildFehlt })
res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' })
return res.sendFile(datei, (err) => {
if (err && !res.headersSent) res.status(404).json({ error: MELDUNG.bildFehlt })
})
}))
// Fehler beim Einlesen eines Uploads (zu gross, abgebrochen): verstaendlich beantworten, nie Rohtext
router.use((err, _req, res, next) => {
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Bild ist größer als 8 MB.' })
if (err?.status >= 400 && err.status < 500 && res.headersSent === false) return res.status(err.status).json({ error: 'Die Anfrage ist ungültig.' })
return next(err)
})
export default router
+1
View File
@@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [
'termin_einstellungen',
'termin_aktion',
'termin_manuell',
'shop_aenderung',
]
const DETAIL_MAX = 300
+166
View File
@@ -0,0 +1,166 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import sharp from 'sharp'
import { config } from '../../config.js'
/**
* Artikelbilder des Webshops (Plan 2026-10-05-webshop.md, Task C7; verallgemeinert aus services/newsImages.js, das unveraendert
* bleibt). Jedes Bild wird neu kodiert (WebP, laengste Kante 1600 px, dazu Vorschau 480 px); dabei fallen alle Metadaten (GPS,
* Kamera, Drehung) weg. Nur JPEG, PNG und WebP, hoechstens 8 MB.
*
* Ablage: <config.shop.uploadDir>/<siteKey>/<id>-l.webp, <id>-s.webp und Sidecar <id>.json
* { customerId, shopId, createdAt, claimed, w, h }. Der Sidecar wird zuletzt geschrieben: ohne ihn gilt ein Bild als nicht
* vorhanden. Hochgeladene, nie an einen Artikel gebundene Bilder loescht raeumeUnbeanspruchteAuf() nach 24 h.
* Fehler tragen status (400/413/415) und einen deutschen Text.
*/
export const BILD_MAX_BYTES = 8 * 1024 * 1024
const UNBEANSPRUCHT_MS = 24 * 60 * 60 * 1000
const MAX_UPLOADS_PRO_STUNDE = 40
const ID_RE = /^[a-z0-9-]{1,64}$/
const SITE_KEY_RE = /^[a-z2-7]{16}$/
// Hoechstens 40 Megapixel je Bild: ein winziges PNG/WebP kann sonst Gigabytes beim Dekodieren belegen
const MAX_INPUT_PIXELS = 40_000_000
// Fehlertexte von sharp/libvips bei kaputten, unbekannten oder zu grossen Bildern
const DECODE_ERROR_RE = /unsupported image format|pixel limit|corrupt|truncated|invalid|bad seek|bad (?:header|format)|not a|Input (?:buffer|file|Buffer)|Vips|heif|jpeg|png|webp|premature end|decode/i
const FORMATE = new Set(['jpeg', 'png', 'webp'])
const fehler = (status, text) => Object.assign(new Error(text), { status })
const idOk = (id) => typeof id === 'string' && ID_RE.test(id)
const keyOk = (siteKey) => typeof siteKey === 'string' && SITE_KEY_RE.test(siteKey)
const wurzel = () => path.resolve(config.shop.uploadDir)
const ordner = (siteKey) => path.join(wurzel(), siteKey)
const sidecar = (siteKey, id) => path.join(ordner(siteKey), `${id}.json`)
/** Pfad zu einer Bilddatei ('l' gross, 's' Vorschau) oder null bei unsauberem siteKey, unsauberer ID oder Groesse. */
export function bildPfad(siteKey, imageId, groesse) {
if (!keyOk(siteKey) || !idOk(imageId)) return null
if (groesse !== 'l' && groesse !== 's') return null
return path.join(ordner(siteKey), `${imageId}-${groesse}.webp`)
}
/**
* Bild kodieren und ablegen.
* @returns {Promise<{ imageId: string, w: number, h: number }>}
*/
export async function speichereBild({ customerId, shopId, siteKey, buffer }) {
if (!keyOk(siteKey)) throw fehler(400, 'Ungültiger Shop.')
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw fehler(415, 'Bitte lade ein Foto hoch (JPEG, PNG oder WebP).')
if (buffer.length > BILD_MAX_BYTES) throw fehler(413, 'Das Bild ist größer als 8 MB.')
const nichtUnterstuetzt = () => fehler(415, 'Dieses Dateiformat können wir nicht verwenden. Bitte lade ein JPEG, PNG oder WebP hoch.')
let gross, klein
try {
const opts = { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS }
const meta = await sharp(buffer, opts).metadata()
if (!FORMATE.has(meta.format)) throw nichtUnterstuetzt()
// rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten
gross = await sharp(buffer, opts).rotate()
.resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 }).toBuffer({ resolveWithObject: true })
// Vorschau aus dem schon verkleinerten Ergebnis (spart eine Dekodierung des Originals)
klein = await sharp(gross.data)
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 75 }).toBuffer()
} catch (err) {
if (err.status) throw err
if (DECODE_ERROR_RE.test(String(err && err.message))) throw nichtUnterstuetzt()
console.error(`[shop] Bild-Verarbeitung fehlgeschlagen: ${String(err?.message ?? '').slice(0, 200)}`)
throw err
}
const imageId = randomUUID()
const { width: w, height: h } = gross.info
await mkdir(ordner(siteKey), { recursive: true })
await writeFile(bildPfad(siteKey, imageId, 'l'), gross.data)
await writeFile(bildPfad(siteKey, imageId, 's'), klein)
await schreibeSidecar(siteKey, imageId, { customerId, shopId, createdAt: new Date().toISOString(), claimed: false, w, h })
return { imageId, w, h }
}
async function schreibeSidecar(siteKey, imageId, meta) {
const datei = sidecar(siteKey, imageId)
await writeFile(`${datei}.tmp`, JSON.stringify(meta))
await rename(`${datei}.tmp`, datei)
}
/** Sidecar eines Bildes { customerId, shopId, createdAt, claimed, w, h } oder null, wenn es das Bild nicht gibt. */
export async function bildMeta(siteKey, imageId) {
if (!keyOk(siteKey) || !idOk(imageId)) return null
try {
const meta = JSON.parse(await readFile(sidecar(siteKey, imageId), 'utf8'))
return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : null
} catch {
return null
}
}
/** Markiert Bilder als an einen Artikel gebunden (werden nicht mehr aufgeraeumt). Unbekannte IDs werden uebergangen. */
export async function beanspruche(siteKey, ids) {
for (const id of Array.isArray(ids) ? ids : []) {
const meta = await bildMeta(siteKey, id)
if (!meta || meta.claimed === true) continue
await schreibeSidecar(siteKey, id, { ...meta, claimed: true })
}
}
/** Loescht ein Bild (beide Groessen und Sidecar); fehlt es, passiert nichts. */
export async function loescheBild(siteKey, imageId) {
if (!keyOk(siteKey) || !idOk(imageId)) return
await Promise.all([
rm(bildPfad(siteKey, imageId, 'l'), { force: true }),
rm(bildPfad(siteKey, imageId, 's'), { force: true }),
rm(sidecar(siteKey, imageId), { force: true }),
rm(`${sidecar(siteKey, imageId)}.tmp`, { force: true }),
])
}
/** Loescht alle Bilder eines Shops (Ordner samt Inhalt), z. B. beim Entfernen der Shop-Daten. */
export async function loescheAlleBilder(siteKey) {
if (!keyOk(siteKey)) return
await rm(ordner(siteKey), { recursive: true, force: true })
}
/** Loescht nicht beanspruchte Bilder, die aelter als 24 h sind (Sidecar ohne Datum gilt als alt). Gibt die Anzahl zurueck. */
export async function raeumeUnbeanspruchteAuf(now = new Date()) {
const jetzt = now instanceof Date ? now.getTime() : Number(now)
const shops = await readdir(wurzel()).catch(() => [])
let entfernt = 0
for (const siteKey of shops) {
if (!keyOk(siteKey)) continue
const dateien = await readdir(ordner(siteKey)).catch(() => [])
for (const datei of dateien) {
if (!datei.endsWith('.json')) continue
const id = datei.slice(0, -5)
if (!idOk(id)) continue
const meta = await bildMeta(siteKey, id)
if (!meta || meta.claimed === true) continue
const erstellt = Date.parse(meta.createdAt)
if (Number.isFinite(erstellt) && jetzt - erstellt <= UNBEANSPRUCHT_MS) continue
await loescheBild(siteKey, id)
entfernt++
}
}
return entfernt
}
// Missbrauchsschutz: Uploads je Kunde und Stunde (im Speicher, reicht fuer einen Prozess). Jeder Aufruf zaehlt.
const uploads = new Map()
export function bildLimitErreicht(customerId) {
const jetzt = Date.now()
for (const [key, zeiten] of uploads) {
const frisch = zeiten.filter((t) => jetzt - t < 60 * 60 * 1000)
if (frisch.length === 0) uploads.delete(key)
else if (frisch.length !== zeiten.length) uploads.set(key, frisch)
}
const liste = uploads.get(customerId) || []
liste.push(jetzt)
uploads.set(customerId, liste)
return liste.length > MAX_UPLOADS_PRO_STUNDE
}
/** Setzt den Upload-Zaehler zurueck (Tests). */
export function setzeBildLimitZurueck() {
uploads.clear()
}
+1 -1
View File
@@ -295,7 +295,7 @@ export function normalisiereProdukt(eingabe, { einstellungen } = {}) {
}
/** Gespeicherten JSON-Text sicher lesen; null bei kaputten Daten. */
function parseProdukt(doc) {
export function parseProdukt(doc) {
try {
const p = JSON.parse(doc?.produkt)
return istObjekt(p) ? p : null