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' /** * News-Fotos: Kundenfotos fuer die Pinnwand. Jedes Bild wird neu kodiert (WebP, laengste Kante * 1600 px, dazu Vorschau 480 px); dabei fallen alle Metadaten (GPS, Kamera, Drehung) weg. * * Ablage: //.webp, -s.webp und Sidecar .json. * Hochgeladene, nie an einen Beitrag gebundene Bilder loescht purgeUnclaimedNewsImages() nach 24 h. */ export const NEWS_IMAGE_MAX_BYTES = 10 * 1024 * 1024 const UNCLAIMED_TTL_MS = 24 * 60 * 60 * 1000 const MAX_UPLOADS_PER_HOUR = 20 const SAFE_ID_RE = /^[a-z0-9-]+$/ // 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 ACCEPTED_FORMATS = new Set(['jpeg', 'png', 'webp', 'heif']) function httpError(status, message) { return Object.assign(new Error(message), { status }) } const safeId = (id) => typeof id === 'string' && id.length > 0 && id.length <= 64 && SAFE_ID_RE.test(id) const root = () => config.news.uploadDir const projectDir = (projectId) => path.join(root(), projectId) /** Pfad zu einer Bilddatei ('l' gross, 's' Vorschau) oder null bei unsauberen IDs. */ export function newsImagePath(projectId, imageId, size) { if (!safeId(projectId) || !safeId(imageId)) return null if (size !== 'l' && size !== 's') return null return path.join(projectDir(projectId), size === 's' ? `${imageId}-s.webp` : `${imageId}.webp`) } const sidecarPath = (projectId, imageId) => path.join(projectDir(projectId), `${imageId}.json`) export async function storeNewsImage({ customerId, projectId, buffer }) { if (!safeId(projectId)) throw httpError(400, 'Ungültiges Projekt.') if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw httpError(415, 'Bitte lade ein Foto hoch (JPEG, PNG, WebP oder HEIC).') if (buffer.length > NEWS_IMAGE_MAX_BYTES) throw httpError(413, 'Das Foto ist größer als 10 MB.') const unsupported = () => httpError(415, 'Dieses Dateiformat können wir nicht verwenden. Bitte lade ein JPEG, PNG oder WebP hoch.') let large, small try { const opts = { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS } const meta = await sharp(buffer, opts).metadata() if (!ACCEPTED_FORMATS.has(meta.format)) throw unsupported() // rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten large = 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) small = await sharp(large.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 unsupported() console.error('[news] Foto-Verarbeitung fehlgeschlagen:', err) throw err } const imageId = randomUUID() const { width: w, height: h } = large.info const dir = projectDir(projectId) await mkdir(dir, { recursive: true }) await writeFile(newsImagePath(projectId, imageId, 'l'), large.data) await writeFile(newsImagePath(projectId, imageId, 's'), small) // Sidecar zuletzt: ohne ihn gilt das Bild als nicht vorhanden await writeFile(sidecarPath(projectId, imageId), JSON.stringify({ customerId, projectId, createdAt: new Date().toISOString(), claimed: false, w, h })) return { imageId, w, h } } async function readSidecar(projectId, imageId) { try { return JSON.parse(await readFile(sidecarPath(projectId, imageId), 'utf8')) } catch { return null } } /** Markiert ein Bild als an einen Beitrag gebunden (wird nicht mehr aufgeraeumt). */ export async function claimNewsImage(projectId, imageId) { if (!safeId(projectId) || !safeId(imageId)) return const meta = await readSidecar(projectId, imageId) if (!meta || meta.claimed) return meta.claimed = true const file = sidecarPath(projectId, imageId) await writeFile(`${file}.tmp`, JSON.stringify(meta)) await rename(`${file}.tmp`, file) } export async function deleteNewsImage(projectId, imageId) { if (!safeId(projectId) || !safeId(imageId)) return await Promise.all([ rm(newsImagePath(projectId, imageId, 'l'), { force: true }), rm(newsImagePath(projectId, imageId, 's'), { force: true }), rm(sidecarPath(projectId, imageId), { force: true }), ]) } /** Loescht nicht beanspruchte Bilder, die aelter als 24 h sind. Gibt die Anzahl zurueck. */ export async function purgeUnclaimedNewsImages(now = new Date()) { const nowMs = now instanceof Date ? now.getTime() : Number(now) const projects = await readdir(root()).catch(() => []) let removed = 0 for (const projectId of projects) { if (!safeId(projectId)) continue const files = await readdir(projectDir(projectId)).catch(() => []) for (const f of files) { if (!f.endsWith('.json')) continue const imageId = f.slice(0, -5) if (!safeId(imageId)) continue const meta = await readSidecar(projectId, imageId) if (!meta || meta.claimed) continue const created = Date.parse(meta.createdAt) if (Number.isFinite(created) && nowMs - created <= UNCLAIMED_TTL_MS) continue await deleteNewsImage(projectId, imageId) removed++ } } return removed } // Missbrauchsschutz: Uploads je Kunde und Stunde (im Speicher, reicht fuer einen Prozess) const recentUploads = new Map() export function newsImageRateLimited(customerId) { const now = Date.now() // Abgelaufene Eintraege aller Kunden entfernen, damit die Map nicht waechst for (const [key, times] of recentUploads) { const live = times.filter((t) => now - t < 60 * 60 * 1000) if (live.length === 0) recentUploads.delete(key) else if (live.length !== times.length) recentUploads.set(key, live) } const list = recentUploads.get(customerId) || [] list.push(now) recentUploads.set(customerId, list) return list.length > MAX_UPLOADS_PER_HOUR }