Files
Webklar-Kundenbereich/server/services/shop/bilder.js
T
ClaudeandClaude Opus 5.5 9722f0a938 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>
2026-10-05 08:52:53 +00:00

167 lines
7.6 KiB
JavaScript

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()
}