News: Fotos mit Pixelgrenze, sauberer Fehlerzuordnung und mehr Tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 16:40:24 +00:00
1 parent ac7375e889
commit ad0b08f32a
2 files changed
+56 -16

No files matched your search

+23 -6
View File
@@ -16,6 +16,10 @@ 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) {
@@ -43,15 +47,22 @@ export async function storeNewsImage({ customerId, projectId, buffer }) {
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()
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
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()
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
throw unsupported()
if (DECODE_ERROR_RE.test(String(err && err.message))) throw unsupported()
console.error('[news] Foto-Verarbeitung fehlgeschlagen:', err)
throw err
}
const imageId = randomUUID()
@@ -120,7 +131,13 @@ export async function purgeUnclaimedNewsImages(now = new Date()) {
const recentUploads = new Map()
export function newsImageRateLimited(customerId) {
const now = Date.now()
const list = (recentUploads.get(customerId) || []).filter((t) => now - t < 60 * 60 * 1000)
// 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