Files
AfiS-Aktensystem/web/lib/files/process.ts
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

226 lines
7.6 KiB
TypeScript

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<void> {
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<Topic> {
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<void> {
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<boolean> {
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<void> {
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<void> {
await db.attachment.update({
where: { id: attachmentId },
data: { scanStatus: 'FAILED', scanMessage: grund },
})
await notifyAttachmentScanned(attachmentId, thema)
}