- 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>
167 lines
7.6 KiB
JavaScript
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()
|
|
}
|