152 lines
6.7 KiB
JavaScript
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
|
|
}
|