import { readFile, writeFile } from 'node:fs/promises' import { db } from '@/lib/db' import { getEnv } from '@/lib/env' import { scanBuffer } from '@/lib/files/clamav' import { absolutePath, ensureDirFor, sha256Of, STORE, storageKeyFor } from '@/lib/files/paths' import { detectType, matchesDeclared } from '@/lib/files/magic' import { reencode } from '@/lib/files/reencode' import { notifyAttachmentScanned } from '@/lib/realtime/publish' /** * Die Prüfkette, in der Reihenfolge aus docs/07-dateien-und-sicherheit.md: * * quarantine/ → Typprüfung → ClamAV → neu kodieren → store/ * * Sie läuft **nach** der Antwort auf den Upload. Der Nutzer sieht den Anhang * sofort mit dem Zustand „Wird geprüft"; kein Fortschrittsbalken wartet auf * einen Virenscanner. Siehe docs/09-performance-und-ux.md. * * Jeder Schritt kann nur in eine Richtung ausgehen: was nicht ausdrücklich * besteht, bleibt in der Quarantäne. Es gibt keinen Pfad, auf dem eine Datei * ungeprüft in `store/` landet. */ export async function runCheckChain( attachmentId: string, options: { /** * Nur für die bewusste Freigabe durch einen Administrator, wenn der * Scanner nicht erreichbar war. Die Typprüfung und die Neukodierung laufen * auch dann — sie sind der eigentliche Schutz. */ skipScan?: boolean } = {}, ): Promise { const anhang = await db.attachment.findUnique({ where: { id: attachmentId }, select: { id: true, storageKey: true, mimeType: true, scanStatus: true }, }) if (!anhang || anhang.scanStatus !== 'PENDING') return try { const inhalt = new Uint8Array(await readFile(absolutePath(anhang.storageKey))) // 1. Typprüfung. Der gemeldete Typ steht bis hierher in mimeType. const erkannt = detectType(inhalt) if (!erkannt.ok) { return blockieren(attachmentId, typFehler(erkannt.reason)) } if (!matchesDeclared(erkannt.type, anhang.mimeType)) { return blockieren( attachmentId, `Angekündigt als ${anhang.mimeType}, erkannt als ${erkannt.type}`, ) } // 2. Virenscanner. if (!options.skipScan) { const scan = await scanBuffer(getEnv().CLAMAV_SOCKET, inhalt) if (!scan.ok) { // Nicht blockiert, sondern unentschieden: die Datei ist nicht als böse // erkannt, sie ist ungeprüft. Ein Administrator kann sie freigeben. return abbrechen(attachmentId, scan.reason) } if (!scan.clean) { return blockieren(attachmentId, `Schadsoftware erkannt: ${scan.signature}`) } } // 3. Neu kodieren. Hier verliert die Datei alles, was nicht Inhalt ist. const neu = await reencode(inhalt, erkannt.type) if (!neu.ok) { if (!options.skipScan) return abbrechen(attachmentId, neu.reason) // Bei der Freigabe von Hand bleibt die Datei so, wie sie ist. Das ist // die Entscheidung, die der Administrator trifft, und sie steht danach // im Zustand der Datei. return freigeben(attachmentId, erkannt.type, { content: inhalt }, { hinweis: `Ohne Virenprüfung und ohne Neukodierung freigegeben: ${neu.reason}`, }) } // 4. Freigeben. await freigeben(attachmentId, erkannt.type, neu.result, { hinweis: options.skipScan ? 'Ohne Virenprüfung freigegeben' : undefined, }) } catch (error) { await abbrechen( attachmentId, error instanceof Error ? error.message : 'Prüfung fehlgeschlagen', ) } } function typFehler(reason: 'unknown-type' | 'not-utf8' | 'empty'): string { if (reason === 'empty') return 'Die Datei ist leer' if (reason === 'not-utf8') return 'Kein gültiges UTF-8' return 'Dateityp nicht erlaubt. Zugelassen sind PNG, JPEG, WebP, PDF und Text' } type Fertig = { content: Uint8Array width?: number height?: number thumb?: Uint8Array blurhash?: string } async function freigeben( attachmentId: string, type: string, neu: Fertig, options: { hinweis?: string } = {}, ): Promise { const sha256 = sha256Of(neu.content) // Gleicher Inhalt schon da: nur verweisen, nicht ein zweites Mal ablegen. const vorhanden = await db.attachment.findFirst({ where: { sha256, scanStatus: 'CLEAN', id: { not: attachmentId } }, select: { storageKey: true, thumbKey: true, blurhash: true, width: true, height: true }, }) let storageKey = vorhanden?.storageKey ?? storageKeyFor(STORE, attachmentId) let thumbKey = vorhanden?.thumbKey ?? null if (!vorhanden) { await writeFile(await ensureDirFor(storageKey), neu.content) if (neu.thumb) { thumbKey = `${storageKey}.thumb` await writeFile(await ensureDirFor(thumbKey), neu.thumb) } } else { storageKey = vorhanden.storageKey } await db.attachment.update({ where: { id: attachmentId }, data: { scanStatus: 'CLEAN', scanMessage: options.hinweis ?? null, mimeType: type, sizeBytes: neu.content.byteLength, sha256, storageKey, thumbKey, blurhash: neu.blurhash ?? vorhanden?.blurhash ?? null, width: neu.width ?? vorhanden?.width ?? null, height: neu.height ?? vorhanden?.height ?? null, }, }) await notifyAttachmentScanned(attachmentId) } /** Endgültig abgelehnt: die Datei ist als gefährlich oder unzulässig erkannt. */ async function blockieren(attachmentId: string, grund: string): Promise { await db.attachment.update({ where: { id: attachmentId }, data: { scanStatus: 'BLOCKED', scanMessage: grund }, }) await notifyAttachmentScanned(attachmentId) } /** Unentschieden: die Prüfung konnte nicht abgeschlossen werden. */ async function abbrechen(attachmentId: string, grund: string): Promise { await db.attachment.update({ where: { id: attachmentId }, data: { scanStatus: 'FAILED', scanMessage: grund }, }) await notifyAttachmentScanned(attachmentId) }