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
|
||||
|
||||
Reference in new issue
Block a user