import { execFile } from 'node:child_process' import { readFile, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { promisify } from 'node:util' import { encode as encodeBlurhash } from 'blurhash' import sharp from 'sharp' import { THUMB_MAX_EDGE } from '@/lib/files/limits' import type { AllowedType } from '@/lib/files/magic' const run = promisify(execFile) /** * Neu kodieren statt durchreichen. * * Das ist die wirksamste Massnahme der ganzen Kette. Ein Bild, das durch * `sharp` gelaufen ist, besteht nur noch aus Pixeln: EXIF, Farbprofile, * angehängte Nutzlast und alles, was ein Betrachter versehentlich auswerten * könnte, sind weg - nicht gefiltert, sondern nie übernommen. * Siehe docs/07-dateien-und-sicherheit.md. */ export type Reencoded = { content: Uint8Array mimeType: AllowedType width?: number height?: number thumb?: Uint8Array blurhash?: string } export type ReencodeResult = | { ok: true; result: Reencoded } | { ok: false; reason: string } /** Grösser als das kodiert kein Bild, das noch ein Bild sein will. */ const MAX_PIXELS = 50_000_000 export async function reencode( content: Uint8Array, type: AllowedType, ): Promise { if (type === 'text/plain') return reencodeText(content) if (type === 'application/pdf') return sanitisePdf(content) return reencodeImage(content, type) } async function reencodeImage( content: Uint8Array, type: Exclude, ): Promise { try { // `limitInputPixels` schützt gegen die Bildbombe: wenige Kilobyte Datei, // Gigabyte im Speicher. const quelle = sharp(content, { limitInputPixels: MAX_PIXELS, animated: false }) const info = await quelle.metadata() if (!info.width || !info.height) return { ok: false, reason: 'Kein lesbares Bild' } // `.rotate()` ohne Winkel wendet die EXIF-Ausrichtung an und wirft sie // danach weg. Ohne das läge das Bild später auf der Seite. const basis = sharp(content, { limitInputPixels: MAX_PIXELS, animated: false }).rotate() const format = type === 'image/png' ? 'png' : type === 'image/webp' ? 'webp' : 'jpeg' const { data, info: erzeugt } = await basis .clone() .toFormat(format, format === 'jpeg' ? { quality: 88, mozjpeg: true } : {}) .toBuffer({ resolveWithObject: true }) const thumb = await sharp(data, { limitInputPixels: MAX_PIXELS }) .resize(THUMB_MAX_EDGE, THUMB_MAX_EDGE, { fit: 'inside', withoutEnlargement: true }) .toFormat('webp', { quality: 80 }) .toBuffer() return { ok: true, result: { content: data, mimeType: type, width: erzeugt.width, height: erzeugt.height, thumb, blurhash: await blurhashOf(data), }, } } catch (error) { return { ok: false, reason: `Bild nicht neu kodierbar: ${nachricht(error)}` } } } /** * Ein Platzhalterbild aus wenigen Zeichen, damit die Seite nicht springt, * während das Bild lädt. Schlägt es fehl, ist das kein Grund, die Datei * abzulehnen. */ async function blurhashOf(image: Uint8Array): Promise { try { const { data, info } = await sharp(image) .raw() .ensureAlpha() .resize(32, 32, { fit: 'inside' }) .toBuffer({ resolveWithObject: true }) return encodeBlurhash(new Uint8ClampedArray(data), info.width, info.height, 4, 3) } catch { return undefined } } /** * Text auf UTF-8 normalisieren. Eine Stückliste am Anfang fliegt raus, weil * sie beim Anzeigen als sichtbares Zeichen durchschlägt. */ function reencodeText(content: Uint8Array): ReencodeResult { try { const text = new TextDecoder('utf-8', { fatal: true }).decode(content).replace(/^/, '') return { ok: true, result: { content: new TextEncoder().encode(text), mimeType: 'text/plain' }, } } catch { return { ok: false, reason: 'Kein gültiges UTF-8' } } } /** * PDF säubern. * * `--empty --pages datei 1-z --` baut das Dokument aus den Seiten neu auf. * Der ursprüngliche Dokumentkatalog wird dabei nicht übernommen, und mit ihm * verschwinden `OpenAction`, eingebettetes JavaScript und angehängte Dateien - * die drei Wege, auf denen ein PDF etwas tut, statt nur dazustehen. * * Fehlt `qpdf`, wird die Datei **nicht** durchgewinkt. Sie bleibt in der * Quarantäne, und ein Administrator kann sie bewusst freigeben. */ async function sanitisePdf(content: Uint8Array): Promise { const kennung = `afis-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` const quelle = path.join(tmpdir(), `${kennung}-in.pdf`) const ziel = path.join(tmpdir(), `${kennung}-out.pdf`) try { await writeFile(quelle, content) await run('qpdf', ['--decrypt', '--empty', '--pages', quelle, '1-z', '--', ziel], { timeout: 30_000, }) return { ok: true, result: { content: await readFile(ziel), mimeType: 'application/pdf' } } } catch (error) { const grund = (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'qpdf ist nicht installiert, PDF wurde nicht gesäubert' : `PDF nicht säuberbar: ${nachricht(error)}` return { ok: false, reason: grund } } finally { await Promise.all([unlink(quelle).catch(() => undefined), unlink(ziel).catch(() => undefined)]) } } function nachricht(error: unknown): string { if (error instanceof Error) return error.message.split('\n')[0]! return 'unbekannt' }