Files
Webklar-Kundenbereich/server/services/newsImages.js
T

152 lines
6.7 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'
/**
* 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: <uploadDir>/<projectId>/<imageId>.webp, <imageId>-s.webp und Sidecar <imageId>.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
}
}
/** Breite und Hoehe eines Bildes des Projekts, null wenn es nicht existiert. */
export async function readNewsImageMeta(projectId, imageId) {
if (!safeId(projectId) || !safeId(imageId)) return null
const meta = await readSidecar(projectId, imageId)
return meta ? { w: meta.w, h: meta.h } : 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
}