From ad0b08f32a63c59780fd5e557c07a12be6a27975 Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:40:24 +0000 Subject: [PATCH] News: Fotos mit Pixelgrenze, sauberer Fehlerzuordnung und mehr Tests Co-Authored-By: Claude Opus 5.5 --- server/services/newsImages.js | 29 ++++++++++++++++++----- test/newsImages.test.js | 43 +++++++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/server/services/newsImages.js b/server/services/newsImages.js index de57a05..39d030a 100644 --- a/server/services/newsImages.js +++ b/server/services/newsImages.js @@ -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 diff --git a/test/newsImages.test.js b/test/newsImages.test.js index 6c92aab..0683ba6 100644 --- a/test/newsImages.test.js +++ b/test/newsImages.test.js @@ -7,7 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises' import sharp from 'sharp' process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'news-images-')) -const { storeNewsImage, claimNewsImage, newsImagePath, purgeUnclaimedNewsImages, newsImageRateLimited, NEWS_IMAGE_MAX_BYTES } = +const { storeNewsImage, claimNewsImage, deleteNewsImage, newsImagePath, purgeUnclaimedNewsImages, newsImageRateLimited, NEWS_IMAGE_MAX_BYTES } = await import('../server/services/newsImages.js') const solid = (width, height) => sharp({ create: { width, height, channels: 3, background: '#c33' } }) @@ -60,12 +60,12 @@ test('pfad_nur_saubere_ids', () => { test('unbenutzte_nach_24h_weg', async () => { const input = await solid(100, 100).png().toBuffer() - const old = await storeNewsImage({ customerId: 'c1', projectId: 'p2', buffer: input }) - const kept = await storeNewsImage({ customerId: 'c1', projectId: 'p2', buffer: input }) - const fresh = await storeNewsImage({ customerId: 'c1', projectId: 'p2', buffer: input }) - await claimNewsImage('p2', kept.imageId) + const old = await storeNewsImage({ customerId: 'c1', projectId: 'p-purge', buffer: input }) + const kept = await storeNewsImage({ customerId: 'c1', projectId: 'p-purge', buffer: input }) + const fresh = await storeNewsImage({ customerId: 'c1', projectId: 'p-purge', buffer: input }) + await claimNewsImage('p-purge', kept.imageId) const backdate = async (id) => { - const f = path.join(process.env.NEWS_UPLOAD_DIR, 'p2', `${id}.json`) + const f = path.join(process.env.NEWS_UPLOAD_DIR, 'p-purge', `${id}.json`) const meta = JSON.parse(await readFile(f, 'utf8')) meta.createdAt = new Date(Date.now() - 25 * 3600 * 1000).toISOString() await writeFile(f, JSON.stringify(meta)) @@ -73,10 +73,33 @@ test('unbenutzte_nach_24h_weg', async () => { await backdate(old.imageId) await backdate(kept.imageId) assert.equal(await purgeUnclaimedNewsImages(), 1) - assert.equal(existsSync(newsImagePath('p2', old.imageId, 'l')), false) - assert.equal(existsSync(newsImagePath('p2', old.imageId, 's')), false) - assert.equal(existsSync(newsImagePath('p2', kept.imageId, 'l')), true) - assert.equal(existsSync(newsImagePath('p2', fresh.imageId, 'l')), true) + assert.equal(existsSync(newsImagePath('p-purge', old.imageId, 'l')), false) + assert.equal(existsSync(newsImagePath('p-purge', old.imageId, 's')), false) + assert.equal(existsSync(newsImagePath('p-purge', kept.imageId, 'l')), true) + assert.equal(existsSync(newsImagePath('p-purge', fresh.imageId, 'l')), true) +}) + +test('zu_viele_pixel_415', async () => { + const input = await solid(8000, 8000).png({ compressionLevel: 9 }).toBuffer() + assert.ok(input.length < NEWS_IMAGE_MAX_BYTES) + await assert.rejects(storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: input }), { status: 415 }) +}) + +test('kaputtes_bild_415', async () => { + const input = (await solid(100, 100).png().toBuffer()).subarray(0, 60) + await assert.rejects(storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: input }), { status: 415 }) +}) + +test('loeschen_entfernt_dateien_und_sidecar', async () => { + const input = await solid(100, 100).png().toBuffer() + const { imageId } = await storeNewsImage({ customerId: 'c1', projectId: 'p-del', buffer: input }) + const sidecar = path.join(process.env.NEWS_UPLOAD_DIR, 'p-del', `${imageId}.json`) + assert.equal(existsSync(sidecar), true) + await deleteNewsImage('p-del', imageId) + assert.equal(existsSync(newsImagePath('p-del', imageId, 'l')), false) + assert.equal(existsSync(newsImagePath('p-del', imageId, 's')), false) + assert.equal(existsSync(sidecar), false) + await deleteNewsImage('p-del', imageId) }) test('rate_limit_20_pro_stunde', () => {