News: Fotos mit Pixelgrenze, sauberer Fehlerzuordnung und mehr Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac7375e889
commit
ad0b08f32a
2 files changed
+56
-16
No files matched your search
@@ -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
|
||||
|
||||
+33
-10
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user