import { access, 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 { caseTopic, notifyAttachmentScanned, userTopic, type Topic } 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, uploadedById: true, caseId: true, entryId: true, documentId: true, }, }) if (!anhang || anhang.scanStatus !== 'PENDING') return const thema = await themaVon(anhang) 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), thema) } if (!matchesDeclared(erkannt.type, anhang.mimeType)) { return blockieren( attachmentId, `Angekündigt als ${anhang.mimeType}, erkannt als ${erkannt.type}`, thema, ) } // 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, thema) } if (!scan.clean) { return blockieren(attachmentId, `Schadsoftware erkannt: ${scan.signature}`, thema) } } // 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, thema) // 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 }, thema, { hinweis: `Ohne Virenprüfung und ohne Neukodierung freigegeben: ${neu.reason}`, }) } // 4. Freigeben. await freigeben(attachmentId, erkannt.type, neu.result, thema, { hinweis: options.skipScan ? 'Ohne Virenprüfung freigegeben' : undefined, }) } catch (error) { await abbrechen( attachmentId, error instanceof Error ? error.message : 'Prüfung fehlgeschlagen', thema, ) } } 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 } /** * Wen das Ergebnis der Prüfung angeht: die Akte, in der die Datei hängt, sonst * der Hochladende selbst. Ohne Thema erführe niemand vom Ausgang, mit dem * falschen erführen es die Falschen. */ async function themaVon(anhang: { uploadedById: string caseId: string | null entryId: string | null documentId: string | null }): Promise { if (anhang.caseId) return caseTopic(anhang.caseId) if (anhang.entryId) { const eintrag = await db.caseEntry.findUnique({ where: { id: anhang.entryId }, select: { caseId: true }, }) if (eintrag) return caseTopic(eintrag.caseId) } if (anhang.documentId) { const doc = await db.document.findUnique({ where: { id: anhang.documentId }, select: { caseId: true }, }) if (doc?.caseId) return caseTopic(doc.caseId) } return userTopic(anhang.uploadedById) } async function freigeben( attachmentId: string, type: string, neu: Fertig, thema: Topic, options: { hinweis?: string } = {}, ): Promise { const sha256 = sha256Of(neu.content) // Gleicher Inhalt schon da: nur verweisen, nicht ein zweites Mal ablegen. // // Der Verweis gilt aber nur, wenn die Datei auch wirklich noch liegt. Eine // Zeile in der Datenbank ist kein Beweis dafür: geht die Ablage verloren — // ein geleertes Verzeichnis, ein nicht eingehängtes Volume —, zeigten sonst // alle künftigen Uploads desselben Inhalts stumm ins Leere. const vorhanden = await db.attachment.findFirst({ where: { sha256, scanStatus: 'CLEAN', id: { not: attachmentId } }, select: { storageKey: true, thumbKey: true, blurhash: true, width: true, height: true }, }) const wiederverwendbar = vorhanden !== null && (await liegtDa(vorhanden.storageKey)) const storageKey = wiederverwendbar ? vorhanden!.storageKey : storageKeyFor(STORE, attachmentId) let thumbKey = wiederverwendbar ? vorhanden!.thumbKey : null if (!wiederverwendbar) { await writeFile(await ensureDirFor(storageKey), neu.content) if (neu.thumb) { thumbKey = `${storageKey}.thumb` await writeFile(await ensureDirFor(thumbKey), neu.thumb) } } 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 ?? (wiederverwendbar ? vorhanden!.blurhash : null), width: neu.width ?? (wiederverwendbar ? vorhanden!.width : null), height: neu.height ?? (wiederverwendbar ? vorhanden!.height : null), }, }) await notifyAttachmentScanned(attachmentId, thema) } /** Liegt die Datei zu diesem Schlüssel wirklich noch da? */ async function liegtDa(storageKey: string): Promise { try { await access(absolutePath(storageKey)) return true } catch { return false } } /** Endgültig abgelehnt: die Datei ist als gefährlich oder unzulässig erkannt. */ async function blockieren(attachmentId: string, grund: string, thema: Topic): Promise { await db.attachment.update({ where: { id: attachmentId }, data: { scanStatus: 'BLOCKED', scanMessage: grund }, }) await notifyAttachmentScanned(attachmentId, thema) } /** Unentschieden: die Prüfung konnte nicht abgeschlossen werden. */ async function abbrechen(attachmentId: string, grund: string, thema: Topic): Promise { await db.attachment.update({ where: { id: attachmentId }, data: { scanStatus: 'FAILED', scanMessage: grund }, }) await notifyAttachmentScanned(attachmentId, thema) }