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: //-l.webp, -s.webp und Sidecar .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() }