News: Fotos verkleinern und Metadaten entfernen (sharp)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8374a570aa
commit
ac7375e889
5 files changed
+815
No files matched your search
@@ -0,0 +1,127 @@
|
||||
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-]+$/
|
||||
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 meta = await sharp(buffer, { failOn: 'error' }).metadata()
|
||||
if (!ACCEPTED_FORMATS.has(meta.format)) throw unsupported()
|
||||
// rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten
|
||||
const base = () => sharp(buffer, { failOn: 'error' }).rotate()
|
||||
large = await base().resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true }).webp({ quality: 80 }).toBuffer({ resolveWithObject: true })
|
||||
small = await base().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer()
|
||||
} catch (err) {
|
||||
if (err.status) throw err
|
||||
throw unsupported()
|
||||
}
|
||||
|
||||
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()
|
||||
const list = (recentUploads.get(customerId) || []).filter((t) => now - t < 60 * 60 * 1000)
|
||||
list.push(now)
|
||||
recentUploads.set(customerId, list)
|
||||
return list.length > MAX_UPLOADS_PER_HOUR
|
||||
}
|
||||
Reference in new issue
Block a user