Phase 5 abgeschlossen: Dateien mit vollständiger Prüfkette
Upload → quarantine/ → Typprüfung → ClamAV → Neukodierung → store/. Es gibt keinen Pfad, auf dem eine Datei ungeprüft in store/ landet, und keinen Schalter, der die Kette abschaltet. Die Typprüfung liest Magic Bytes aus einer eigenen Signaturtabelle statt aus file-type: bei einer Positivliste aus fünf Formaten hilft es nicht, hundert erkennen zu können. Die Signatur muss an Position 0 stehen - ein Vorspann vor %PDF- ist der klassische Weg, eine Datei für zwei Programme zugleich lesbar zu machen. Wer trotzdem durchkommt, kommt als text/plain durch und wird unter der Endung des erkannten Typs ausgeliefert, nicht unter der hochgeladenen. Drei Ausgänge statt zwei: BLOCKED heisst "ist gefährlich", FAILED heisst "wissen wir nicht". Nur FAILED lässt sich freigeben, und die Freigabe wiederholt die Kette ohne den Scanner - Typprüfung und Neukodierung laufen dabei mit. Ein fehlender clamd macht damit keine Datei sauber; ein Test hält das fest. Auslieferung nur über /api/files/[id], mit Sichtbarkeitsprüfung der zugehörigen Akte, Protokoll ab Stufe 3 und den harten Kopfzeilen aus docs/07. Vorschaubilder dürfen inline, weil sie serverseitig als WebP neu kodiert wurden. Das Lichtbild im Aktenblatt ist damit gefüllt: der Feldwert ist die Kennung eines geprüften Anhangs, serverseitig zu einer Adresse aufgelöst. /api/events liefert attachment.scanned über pg_notify - die Grundlage, auf der Phase 6 den Aktenchat aufsetzt. Die Sichtbarkeit wird je Ereignis geprüft, sonst verriete schon die Kennung die Existenz. Nebenbei riss das Bündelbudget (121 kB gegen 120 erlaubte): lib/i18n/de.ts war ein Objekt, und eine Client-Komponente zieht die ganze Datei mit. Die Texte stehen jetzt in sechs Dateien nach Bereichen, de.ts setzt sie nur zusammen. Grösste Route wieder bei 118 kB, und sie wächst nicht mehr mit jedem neuen Bereich mit. 133 Tests im Web-Paket, 15 in der Datenbank, 28 e2e.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import net from 'node:net'
|
||||
|
||||
/**
|
||||
* Anbindung an clamd über den Unix-Socket im geteilten Volume.
|
||||
*
|
||||
* Es wird **INSTREAM** benutzt, nicht SCAN mit einem Pfad: dann muss der
|
||||
* Scanner die Datei nicht sehen können, und die Anbindung funktioniert auch,
|
||||
* wenn clamd anderswo läuft. Das Protokoll ist simpel — Länge als 4 Byte,
|
||||
* Nutzdaten, dann eine Null-Länge als Abschluss.
|
||||
*/
|
||||
|
||||
export type ScanResult =
|
||||
| { ok: true; clean: true }
|
||||
| { ok: true; clean: false; signature: string }
|
||||
/** Scanner nicht erreichbar, Zeitüberschreitung, unverständliche Antwort. */
|
||||
| { ok: false; reason: string }
|
||||
|
||||
const CHUNK = 64 * 1024
|
||||
|
||||
export function scanBuffer(
|
||||
socketPath: string,
|
||||
content: Uint8Array,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<ScanResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
let antwort = ''
|
||||
|
||||
const socket = net.createConnection(socketPath)
|
||||
const done = (result: ScanResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
socket.setTimeout(timeoutMs)
|
||||
socket.on('timeout', () => done({ ok: false, reason: 'Zeitüberschreitung beim Scannen' }))
|
||||
socket.on('error', (error) =>
|
||||
done({ ok: false, reason: `clamd nicht erreichbar: ${error.message}` }),
|
||||
)
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.write('zINSTREAM\0')
|
||||
for (let offset = 0; offset < content.length; offset += CHUNK) {
|
||||
const teil = content.subarray(offset, offset + CHUNK)
|
||||
const laenge = Buffer.alloc(4)
|
||||
laenge.writeUInt32BE(teil.length, 0)
|
||||
socket.write(laenge)
|
||||
socket.write(teil)
|
||||
}
|
||||
socket.write(Buffer.from([0, 0, 0, 0]))
|
||||
})
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
antwort += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
socket.on('close', () => {
|
||||
const text = antwort.replace(/\0/g, '').trim()
|
||||
if (text.endsWith('OK')) return done({ ok: true, clean: true })
|
||||
if (text.endsWith('FOUND')) {
|
||||
const signature = text.replace(/^stream:\s*/, '').replace(/\s*FOUND$/, '')
|
||||
return done({ ok: true, clean: false, signature })
|
||||
}
|
||||
// "size limit exceeded", "ERROR" oder gar nichts.
|
||||
done({ ok: false, reason: text || 'keine Antwort von clamd' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt clamd über den Unix-Socket. Der Socket liegt im geteilten Volume
|
||||
* clamav_socket, siehe compose.yaml. Antwort auf zPING ist "PONG".
|
||||
*/
|
||||
export function pingClamav(socketPath: string, timeoutMs = 2000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (ok: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket.destroy()
|
||||
resolve(ok)
|
||||
}
|
||||
|
||||
const socket = net.createConnection(socketPath)
|
||||
socket.setTimeout(timeoutMs)
|
||||
socket.on('connect', () => socket.write('zPING\0'))
|
||||
socket.on('data', (chunk) => done(chunk.toString().includes('PONG')))
|
||||
socket.on('timeout', () => done(false))
|
||||
socket.on('error', () => done(false))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AllowedType } from '@/lib/files/magic'
|
||||
|
||||
/**
|
||||
* Der Name, unter dem eine Datei ausgeliefert wird.
|
||||
*
|
||||
* Die Endung folgt dem **erkannten** Typ, nicht dem hochgeladenen Namen. Das
|
||||
* fängt den Fall ab, dass eine Datei zwei Formate zugleich bedient: wird sie
|
||||
* als Text erkannt, kommt sie als `.txt` an und landet in keinem
|
||||
* PDF-Betrachter, auch wenn sie ursprünglich `bericht.pdf` hiess.
|
||||
*
|
||||
* Alles, was eine Kopfzeile zerlegen könnte, fliegt raus. Der vollständige,
|
||||
* ursprüngliche Name steht in `filename*` daneben.
|
||||
*/
|
||||
const ENDUNGEN: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'application/pdf': 'pdf',
|
||||
'text/plain': 'txt',
|
||||
}
|
||||
|
||||
export function deliveryFileName(originalName: string, mimeType: AllowedType | string): string {
|
||||
const endung = ENDUNGEN[mimeType]
|
||||
const sauber = originalName
|
||||
.replace(/[\r\n"\\]/g, '')
|
||||
.replace(/[^\w.\- ]/g, '_')
|
||||
.trim()
|
||||
.slice(0, 100)
|
||||
|
||||
const basis = (endung ? sauber.replace(/\.[A-Za-z0-9]{1,8}$/, '') : sauber) || 'datei'
|
||||
return endung ? `${basis}.${endung}` : basis
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Grenzen für Uploads, aus docs/07-dateien-und-sicherheit.md.
|
||||
*
|
||||
* Ohne Abhängigkeiten, damit die Oberfläche dieselben Zahlen anzeigen kann,
|
||||
* die der Server durchsetzt — geprüft wird ausschliesslich serverseitig.
|
||||
*/
|
||||
|
||||
/** 25 MB pro Datei. */
|
||||
export const MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
|
||||
/** 500 MB Kontingent je Nutzer, über alle Anhänge. */
|
||||
export const MAX_USER_BYTES = 500 * 1024 * 1024
|
||||
|
||||
/** 20 Uploads pro Minute und Nutzer. */
|
||||
export const MAX_UPLOADS_PER_MINUTE = 20
|
||||
export const RATE_WINDOW_MS = 60_000
|
||||
|
||||
/** Längste Kante eines Vorschaubildes. */
|
||||
export const THUMB_MAX_EDGE = 480
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB`
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Typprüfung über Magic Bytes.
|
||||
*
|
||||
* **Die Endung wird nie angesehen**, und der vom Browser gemeldete Typ nur, um
|
||||
* ihn gegen den erkannten zu prüfen. Siehe CLAUDE.md, harte Regel 4.
|
||||
*
|
||||
* Bewusst eine eigene Signaturtabelle statt `file-type`: die Liste der
|
||||
* erlaubten Typen ist kurz und geschlossen, und was nicht darin steht, wird
|
||||
* abgelehnt. Eine Bibliothek, die hundert Formate erkennt, hilft bei einer
|
||||
* Positivliste nicht — sie erweitert nur die Angriffsfläche und die Menge
|
||||
* dessen, was aktuell gehalten werden muss. Der eigentliche Schutz ist ohnehin
|
||||
* die Neukodierung danach, siehe `process.ts`.
|
||||
*
|
||||
* Ausdrücklich verboten, auch wenn es bequem wäre: SVG (führt Skripte aus),
|
||||
* HTML, ZIP und andere Archive, Office-Dokumente, alles Ausführbare. Sie
|
||||
* fallen automatisch durch, weil sie nicht in der Tabelle stehen.
|
||||
*/
|
||||
|
||||
export const ALLOWED_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
] as const
|
||||
|
||||
export type AllowedType = (typeof ALLOWED_TYPES)[number]
|
||||
|
||||
export function isAllowedType(value: string): value is AllowedType {
|
||||
return (ALLOWED_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Wie viele Bytes vom Anfang für die Erkennung reichen. */
|
||||
export const MAGIC_WINDOW = 32
|
||||
|
||||
type Signature = {
|
||||
type: AllowedType
|
||||
/** Bytes ab Position 0, `null` an einer Stelle heisst „beliebig". */
|
||||
bytes: (number | null)[]
|
||||
/** Zusätzliche Bedingung, etwa der Containertyp bei WebP. */
|
||||
extra?: (buffer: Uint8Array) => boolean
|
||||
}
|
||||
|
||||
const ascii = (text: string): number[] => [...text].map((c) => c.charCodeAt(0))
|
||||
|
||||
const SIGNATURES: Signature[] = [
|
||||
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||
// JFIF, Exif und die übrigen JPEG-Varianten beginnen alle so.
|
||||
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
||||
{
|
||||
// RIFF-Container. Ohne die zweite Prüfung wäre auch eine WAV-Datei ein Treffer.
|
||||
type: 'image/webp',
|
||||
bytes: ascii('RIFF'),
|
||||
extra: (buffer) => ascii('WEBP').every((byte, index) => buffer[8 + index] === byte),
|
||||
},
|
||||
// Die Spezifikation erlaubt Müll vor %PDF-. Hier nicht: ein Vorspann ist der
|
||||
// klassische Weg, eine Datei für zwei Programme gleichzeitig lesbar zu machen.
|
||||
{ type: 'application/pdf', bytes: ascii('%PDF-') },
|
||||
]
|
||||
|
||||
export type Detection =
|
||||
| { ok: true; type: AllowedType }
|
||||
| { ok: false; reason: 'unknown-type' | 'not-utf8' | 'empty' }
|
||||
|
||||
/**
|
||||
* Erkennt den Typ am Inhalt.
|
||||
*
|
||||
* Reine Textdateien haben keine Signatur. Sie werden deshalb zuletzt geprüft
|
||||
* und nur dann angenommen, wenn der ganze Inhalt gültiges UTF-8 ohne
|
||||
* Steuerzeichen ist — sonst wäre `text/plain` das Schlupfloch, durch das jedes
|
||||
* Binärformat passt.
|
||||
*/
|
||||
export function detectType(content: Uint8Array): Detection {
|
||||
if (content.length === 0) return { ok: false, reason: 'empty' }
|
||||
|
||||
for (const signature of SIGNATURES) {
|
||||
const passt = signature.bytes.every(
|
||||
(byte, index) => byte === null || content[index] === byte,
|
||||
)
|
||||
if (passt && (signature.extra?.(content) ?? true)) {
|
||||
return { ok: true, type: signature.type }
|
||||
}
|
||||
}
|
||||
|
||||
return looksLikeText(content) ? { ok: true, type: 'text/plain' } : { ok: false, reason: 'unknown-type' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gültiges UTF-8, keine Nullbytes, keine Steuerzeichen ausser Tabulator,
|
||||
* Zeilenumbruch und Wagenrücklauf.
|
||||
*/
|
||||
export function looksLikeText(content: Uint8Array): boolean {
|
||||
for (const byte of content) {
|
||||
if (byte === 0x09 || byte === 0x0a || byte === 0x0d) continue
|
||||
if (byte < 0x20 || byte === 0x7f) return false
|
||||
}
|
||||
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(content)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der gemeldete Typ muss zum erkannten passen.
|
||||
*
|
||||
* Nicht, weil der gemeldete etwas wert wäre — er kommt vom Browser und ist frei
|
||||
* wählbar —, sondern weil eine Abweichung ein Hinweis ist: wer eine PNG-Datei
|
||||
* hochlädt und sie als PDF ankündigt, hat etwas vor.
|
||||
*/
|
||||
export function matchesDeclared(detected: AllowedType, declared: string | null): boolean {
|
||||
if (!declared) return true
|
||||
const normalisiert = declared.split(';')[0]!.trim().toLowerCase()
|
||||
if (normalisiert === '' || normalisiert === 'application/octet-stream') return true
|
||||
return normalisiert === detected
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, rename } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { getEnv } from '@/lib/env'
|
||||
|
||||
/**
|
||||
* Ablage der Dateien.
|
||||
*
|
||||
* Zwei Verzeichnisse unter `FILES_DIR`: `quarantine/` nimmt alles auf, was
|
||||
* gerade erst angekommen ist, `store/` nur, was die Prüfkette bestanden hat.
|
||||
* Ausgeliefert wird ausschliesslich aus `store/`. Siehe docs/07.
|
||||
*
|
||||
* Der Dateiname ist die Kennung des Anhangs, nie der Name, den der Nutzer
|
||||
* mitgeschickt hat — der steht in der Datenbank und wird erst beim Ausliefern
|
||||
* wieder gesetzt. Ein Pfad, der aus einer Eingabe entsteht, ist eine
|
||||
* Einladung.
|
||||
*/
|
||||
|
||||
export const QUARANTINE = 'quarantine'
|
||||
export const STORE = 'store'
|
||||
|
||||
/**
|
||||
* Zwei Ebenen aus den ersten vier Zeichen der Kennung. Ein Verzeichnis mit
|
||||
* zehntausend Einträgen ist auf keinem Dateisystem eine gute Idee, und die
|
||||
* Kennungen sind UUIDv7, also am Anfang zeitlich sortiert.
|
||||
*/
|
||||
export function storageKeyFor(bereich: typeof QUARANTINE | typeof STORE, id: string): string {
|
||||
const sauber = id.replace(/[^a-zA-Z0-9-]/g, '')
|
||||
return path.posix.join(bereich, sauber.slice(0, 2), sauber.slice(2, 4), sauber)
|
||||
}
|
||||
|
||||
/** Absoluter Pfad zu einem Ablageschlüssel. Verlässt `FILES_DIR` nie. */
|
||||
export function absolutePath(storageKey: string): string {
|
||||
const wurzel = path.resolve(getEnv().FILES_DIR)
|
||||
const ziel = path.resolve(wurzel, storageKey)
|
||||
// Doppelt gemoppelt: der Schlüssel kommt aus der Datenbank, nicht aus einer
|
||||
// Eingabe. Aber ein Verzeichniswechsel wäre der teuerste Fehler hier.
|
||||
if (ziel !== wurzel && !ziel.startsWith(wurzel + path.sep)) {
|
||||
throw new Error('Ablageschlüssel zeigt aus dem Dateiverzeichnis heraus')
|
||||
}
|
||||
return ziel
|
||||
}
|
||||
|
||||
export async function ensureDirFor(storageKey: string): Promise<string> {
|
||||
const ziel = absolutePath(storageKey)
|
||||
await mkdir(path.dirname(ziel), { recursive: true })
|
||||
return ziel
|
||||
}
|
||||
|
||||
/** Verschiebt aus der Quarantäne in die Ablage. */
|
||||
export async function moveToStore(from: string, to: string): Promise<void> {
|
||||
const ziel = await ensureDirFor(to)
|
||||
await rename(absolutePath(from), ziel)
|
||||
}
|
||||
|
||||
export function sha256Of(content: Uint8Array): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db.attachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { scanStatus: 'FAILED', scanMessage: grund },
|
||||
})
|
||||
await notifyAttachmentScanned(attachmentId)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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<ReencodeResult> {
|
||||
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<AllowedType, 'text/plain' | 'application/pdf'>,
|
||||
): Promise<ReencodeResult> {
|
||||
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<string | undefined> {
|
||||
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<ReencodeResult> {
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { AccessError, must } from '@/lib/errors'
|
||||
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
import { canEditDocument } from '@/lib/access/documentWrite'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { loadVisibleDocument } from '@/lib/documents/service'
|
||||
import { visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import {
|
||||
MAX_FILE_BYTES,
|
||||
MAX_UPLOADS_PER_MINUTE,
|
||||
MAX_USER_BYTES,
|
||||
RATE_WINDOW_MS,
|
||||
} from '@/lib/files/limits'
|
||||
import { ensureDirFor, QUARANTINE, sha256Of, storageKeyFor } from '@/lib/files/paths'
|
||||
import { runCheckChain } from '@/lib/files/process'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
import type { Prisma } from '@afis/db'
|
||||
|
||||
/**
|
||||
* Anhänge: annehmen, auflisten, ausliefern.
|
||||
*
|
||||
* Die Prüfkette selbst steht in `process.ts`. Hier steht, wer was anhängen
|
||||
* darf und welche Grenzen gelten.
|
||||
*/
|
||||
|
||||
/** Wohin der Anhang gehört. Genau eine der drei Formen. */
|
||||
export type AttachmentOwner =
|
||||
| { kind: 'case'; caseNumber: string }
|
||||
| { kind: 'entry'; entryId: string }
|
||||
| { kind: 'document'; documentNumber: string }
|
||||
|
||||
const KOPFDATEN = {
|
||||
id: true,
|
||||
originalName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
scanStatus: true,
|
||||
scanMessage: true,
|
||||
thumbKey: true,
|
||||
blurhash: true,
|
||||
width: true,
|
||||
height: true,
|
||||
uploadedById: true,
|
||||
createdAt: true,
|
||||
caseId: true,
|
||||
entryId: true,
|
||||
documentId: true,
|
||||
} satisfies Prisma.AttachmentSelect
|
||||
|
||||
/**
|
||||
* Prüft, ob der Nutzer an diesen Ort etwas anhängen darf, und liefert die
|
||||
* Fremdschlüssel dafür.
|
||||
*
|
||||
* Es ist dieselbe Berechtigung wie fürs Schreiben am Besitzer: wer einen
|
||||
* Eintrag verfassen darf, darf ihm auch eine Datei beilegen.
|
||||
*/
|
||||
async function aufloesen(
|
||||
p: EffectivePermissions,
|
||||
owner: AttachmentOwner,
|
||||
): Promise<{ caseId?: string; entryId?: string; documentId?: string; label: string }> {
|
||||
if (owner.kind === 'case') {
|
||||
const akte = await loadVisibleCase(p, owner.caseNumber)
|
||||
if (!akte) throw new AccessError('not-found')
|
||||
must(canEditCase(p, akte, 'entry.create'))
|
||||
return { caseId: akte.id, label: akte.caseNumber }
|
||||
}
|
||||
|
||||
if (owner.kind === 'document') {
|
||||
const doc = await loadVisibleDocument(p, owner.documentNumber)
|
||||
if (!doc) throw new AccessError('not-found')
|
||||
must(canEditDocument(p, doc))
|
||||
return { documentId: doc.id, label: doc.documentNumber }
|
||||
}
|
||||
|
||||
// Ein Eintrag ist nur über seine Akte erreichbar, und die Sichtbarkeit des
|
||||
// Eintrags schliesst die der Akte mit ein.
|
||||
const eintrag = await db.caseEntry.findFirst({
|
||||
where: { AND: [visibleEntryWhere(p), { id: owner.entryId }] },
|
||||
select: { id: true, case: { select: { caseNumber: true, status: true, classification: true } } },
|
||||
})
|
||||
if (!eintrag) throw new AccessError('not-found')
|
||||
must(canEditCase(p, eintrag.case, 'entry.create'))
|
||||
return { entryId: eintrag.id, label: eintrag.case.caseNumber }
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine Datei an und legt sie in der Quarantäne ab.
|
||||
*
|
||||
* Der Datensatz entsteht **sofort** mit `PENDING`, damit die Oberfläche ihn
|
||||
* zeigen kann, während die Prüfung läuft. Der gemeldete Typ wandert
|
||||
* unverändert in `mimeType` und wird von der Prüfkette überschrieben — bis
|
||||
* dahin ist er nichts als eine Behauptung des Browsers.
|
||||
*/
|
||||
export async function acceptUpload(
|
||||
p: EffectivePermissions,
|
||||
input: {
|
||||
owner: AttachmentOwner
|
||||
fileName: string
|
||||
declaredType: string | null
|
||||
content: Uint8Array
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
if (input.content.byteLength === 0) throw new AccessError('file-empty')
|
||||
if (input.content.byteLength > MAX_FILE_BYTES) throw new AccessError('file-too-large')
|
||||
|
||||
const [verbraucht, letzteMinute] = await Promise.all([
|
||||
db.attachment.aggregate({
|
||||
where: { uploadedById: p.userId, scanStatus: { not: 'BLOCKED' } },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
db.attachment.count({
|
||||
where: {
|
||||
uploadedById: p.userId,
|
||||
createdAt: { gte: new Date(Date.now() - RATE_WINDOW_MS) },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
if ((verbraucht._sum.sizeBytes ?? 0) + input.content.byteLength > MAX_USER_BYTES) {
|
||||
throw new AccessError('quota-exceeded')
|
||||
}
|
||||
if (letzteMinute >= MAX_UPLOADS_PER_MINUTE) throw new AccessError('rate-limited')
|
||||
|
||||
const ziel = await aufloesen(p, input.owner)
|
||||
|
||||
return db.$transaction(async (tx) => {
|
||||
const anhang = await tx.attachment.create({
|
||||
data: {
|
||||
caseId: ziel.caseId ?? null,
|
||||
entryId: ziel.entryId ?? null,
|
||||
documentId: ziel.documentId ?? null,
|
||||
// Nur zur Anzeige und für den Download-Namen. Der Pfad auf der Platte
|
||||
// entsteht aus der Kennung, nie aus diesem Namen.
|
||||
originalName: input.fileName.slice(0, 200),
|
||||
mimeType: (input.declaredType ?? 'application/octet-stream').slice(0, 100),
|
||||
sizeBytes: input.content.byteLength,
|
||||
sha256: sha256Of(input.content),
|
||||
storageKey: '',
|
||||
uploadedById: p.userId,
|
||||
scanStatus: 'PENDING',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const storageKey = storageKeyFor(QUARANTINE, anhang.id)
|
||||
await writeFile(await ensureDirFor(storageKey), input.content)
|
||||
await tx.attachment.update({ where: { id: anhang.id }, data: { storageKey } })
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'attachment.upload',
|
||||
targetType: 'Attachment',
|
||||
targetId: anhang.id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: {
|
||||
owner: ziel.label,
|
||||
originalName: input.fileName.slice(0, 200),
|
||||
sizeBytes: input.content.byteLength,
|
||||
},
|
||||
})
|
||||
|
||||
return anhang
|
||||
})
|
||||
}
|
||||
|
||||
/** Die Anhänge eines Besitzers, durch die Sichtbarkeitsprüfung gefiltert. */
|
||||
export async function listAttachments(
|
||||
p: EffectivePermissions,
|
||||
owner: { caseId: string } | { entryId: string } | { documentId: string },
|
||||
) {
|
||||
return db.attachment.findMany({
|
||||
where: { AND: [visibleAttachmentWhere(p), owner] },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 100,
|
||||
select: KOPFDATEN,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt einen Anhang zum Ausliefern.
|
||||
*
|
||||
* Nur was die Prüfkette bestanden hat oder von einem Administrator bewusst
|
||||
* freigegeben wurde. Alles andere gibt es für die Auslieferung nicht — auch
|
||||
* nicht mit Recht `admin`, denn die Datei liegt dann noch in der Quarantäne.
|
||||
*/
|
||||
export async function loadDeliverable(p: EffectivePermissions, id: string) {
|
||||
const anhang = await db.attachment.findFirst({
|
||||
where: { AND: [visibleAttachmentWhere(p), { id }, { scanStatus: 'CLEAN' }] },
|
||||
select: {
|
||||
id: true,
|
||||
originalName: true,
|
||||
mimeType: true,
|
||||
storageKey: true,
|
||||
thumbKey: true,
|
||||
sizeBytes: true,
|
||||
caseId: true,
|
||||
entryId: true,
|
||||
documentId: true,
|
||||
},
|
||||
})
|
||||
return anhang
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine Datei frei, deren Prüfung nicht abgeschlossen werden konnte.
|
||||
*
|
||||
* Nur mit Recht `admin` und nur aus `FAILED` — was als Schadsoftware oder
|
||||
* unzulässiger Typ erkannt wurde, bleibt blockiert. Die Freigabe wiederholt
|
||||
* die Kette ohne den Virenscanner; die Typprüfung läuft dabei **immer** mit.
|
||||
*/
|
||||
export async function releaseAttachment(
|
||||
p: EffectivePermissions,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
if (!p.isAdmin) throw new AccessError('no-permission')
|
||||
|
||||
const anhang = await db.attachment.findFirst({
|
||||
where: { AND: [visibleAttachmentWhere(p), { id }] },
|
||||
select: { id: true, scanStatus: true, scanMessage: true },
|
||||
})
|
||||
if (!anhang) throw new AccessError('not-found')
|
||||
if (anhang.scanStatus !== 'FAILED') throw new AccessError('release-not-possible')
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.attachment.update({
|
||||
where: { id },
|
||||
data: { releasedById: p.userId, releasedAt: new Date(), scanStatus: 'PENDING' },
|
||||
})
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'attachment.release',
|
||||
targetType: 'Attachment',
|
||||
targetId: id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: { previousMessage: anhang.scanMessage ?? undefined },
|
||||
})
|
||||
})
|
||||
|
||||
// Die Kette noch einmal, ohne den Scanner. Typprüfung und Neukodierung
|
||||
// laufen dabei mit — die Freigabe überspringt nur das, was ausgefallen war.
|
||||
await runCheckChain(id, { skipScan: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst die Lichtbildfelder eines Dokuments zu Adressen auf.
|
||||
*
|
||||
* **Serverseitig**, und nur für Anhänge, die die Prüfkette bestanden haben und
|
||||
* die der Leser sehen darf. Der Browser bekommt eine Adresse oder gar nichts —
|
||||
* nie eine Kennung, zu der er selbst probieren müsste.
|
||||
*/
|
||||
export async function resolvePhotoUrls(
|
||||
p: EffectivePermissions,
|
||||
sections: { fieldKey: string; visible: boolean; value: unknown; type: string }[],
|
||||
): Promise<Record<string, string>> {
|
||||
const gesucht = sections
|
||||
.filter((s) => s.type === 'photo' && s.visible && typeof s.value === 'string' && s.value)
|
||||
.map((s) => ({ fieldKey: s.fieldKey, id: s.value as string }))
|
||||
|
||||
if (gesucht.length === 0) return {}
|
||||
|
||||
const erlaubt = await db.attachment.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
visibleAttachmentWhere(p),
|
||||
{ id: { in: gesucht.map((g) => g.id) } },
|
||||
{ scanStatus: 'CLEAN' },
|
||||
],
|
||||
},
|
||||
select: { id: true, thumbKey: true },
|
||||
})
|
||||
const mitVorschau = new Map(erlaubt.map((a) => [a.id, a.thumbKey !== null]))
|
||||
|
||||
const ergebnis: Record<string, string> = {}
|
||||
for (const { fieldKey, id } of gesucht) {
|
||||
const vorschau = mitVorschau.get(id)
|
||||
if (vorschau === undefined) continue
|
||||
ergebnis[fieldKey] = vorschau ? `/api/files/${id}?variant=thumb` : `/api/files/${id}`
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AttachmentView } from '@/components/files/Attachments'
|
||||
|
||||
/**
|
||||
* Formt die Datenbankzeilen für die Oberfläche um.
|
||||
*
|
||||
* Zwei Dinge passieren hier, beide mit Absicht: `thumbKey` wird zu einem
|
||||
* Wahrheitswert — der Pfad auf der Platte geht den Browser nichts an —, und
|
||||
* `createdAt` wird zu einer Zeichenkette, weil ein `Date` die Grenze zur
|
||||
* Client-Komponente nicht sauber überquert.
|
||||
*/
|
||||
export function toAttachmentViews(
|
||||
rows: {
|
||||
id: string
|
||||
originalName: string
|
||||
mimeType: string
|
||||
sizeBytes: number
|
||||
scanStatus: string
|
||||
scanMessage: string | null
|
||||
thumbKey: string | null
|
||||
blurhash: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
createdAt: Date
|
||||
}[],
|
||||
): AttachmentView[] {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
originalName: row.originalName,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
scanStatus: row.scanStatus,
|
||||
scanMessage: row.scanMessage,
|
||||
blurhash: row.blurhash,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
hasThumb: row.thumbKey !== null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user