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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
226 lines
7.6 KiB
TypeScript
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)
|
|
}
|