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,25 @@
|
||||
import type { Prisma } from '@afis/db'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
import { visibleCaseWhere, visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import { visibleDocumentWhere } from '@/lib/access/personnelFilter'
|
||||
|
||||
/**
|
||||
* Anhänge, die ein Nutzer sehen darf.
|
||||
*
|
||||
* Ein Anhang hat keine eigene Freigabestufe — er erbt die seines Besitzers.
|
||||
* Das ist die richtige Bauart: eine Datei an einem Verlaufseintrag der Stufe 4
|
||||
* ist so geheim wie der Eintrag, und läge die Stufe am Anhang, müsste jemand
|
||||
* beide gleichzeitig richtig setzen.
|
||||
*
|
||||
* Der Filter steckt in der Abfrage, nie dahinter. Auch die Zählung der Anhänge
|
||||
* einer Akte läuft durch ihn.
|
||||
*/
|
||||
export function visibleAttachmentWhere(p: EffectivePermissions): Prisma.AttachmentWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ case: { is: visibleCaseWhere(p) } },
|
||||
{ entry: { is: visibleEntryWhere(p) } },
|
||||
{ document: { is: visibleDocumentWhere(p) } },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ export type AuditAction =
|
||||
| 'document.finalize'
|
||||
| 'document.retract'
|
||||
| 'document.reclassify'
|
||||
| 'attachment.upload'
|
||||
| 'attachment.read'
|
||||
| 'attachment.release'
|
||||
| 'roles.update'
|
||||
| 'export.pdf'
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { pingClamav } from '@/lib/health/clamav'
|
||||
import { pingClamav } from '@/lib/files/clamav'
|
||||
|
||||
export type ComponentState = 'up' | 'degraded' | 'down'
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import net from 'node:net'
|
||||
|
||||
/**
|
||||
* Fragt clamd ueber 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,87 @@
|
||||
/**
|
||||
* Der Adminbereich: Rollen, Vorlagen, Einstellungen.
|
||||
*
|
||||
* Nach Bereichen getrennt, weil eine **Client-Komponente die ganze Datei ins
|
||||
* Bündel zieht**, in der ihr Text steht. Lägen alle Texte in einer, trüge jede
|
||||
* Ansicht die der ganzen Anwendung mit sich herum — das Budget aus
|
||||
* docs/09-performance-und-ux.md wäre nach wenigen Phasen aufgebraucht.
|
||||
*
|
||||
* Serverkomponenten nehmen weiter `de` aus `lib/i18n/de.ts`: was dorthin
|
||||
* gezogen wird, verlässt den Server nicht.
|
||||
*/
|
||||
|
||||
export const roleAdminText = {
|
||||
heading: 'Rollenverwaltung',
|
||||
intro:
|
||||
'Jede Rolle des Discord-Servers steht hier. Zugangsrollen bestimmen die Freigabestufe, Rangrollen die Rechte, und eine Zugehörigkeitsrolle entscheidet, wer sich überhaupt anmelden darf.',
|
||||
empty:
|
||||
'Noch keine Rollen eingelesen. Der Worker spiegelt sie, sobald er am Discord-Gateway hängt.',
|
||||
role: 'Rolle',
|
||||
category: 'Sorte',
|
||||
clearance: 'Freigabestufe',
|
||||
grantsMembership: 'Darf sich anmelden',
|
||||
permissions: 'Rechte',
|
||||
missingFromGuild: 'Auf dem Server nicht mehr vorhanden',
|
||||
save: 'Zuordnung speichern',
|
||||
invalidInput: 'Eingabe nicht gültig',
|
||||
roleGone: 'Rolle nicht gefunden, sie wurde zwischenzeitlich gelöscht',
|
||||
saving: 'Wird gespeichert',
|
||||
saved: 'Zuordnung gespeichert',
|
||||
confirm: 'Rollen-Mapping ändern? Das wirkt sofort für alle angemeldeten Personen.',
|
||||
categories: {
|
||||
MEMBERSHIP: 'Zugehörigkeit',
|
||||
RANK: 'Rang',
|
||||
CLEARANCE: 'Zugang',
|
||||
UNSORTED: 'Nicht eingeordnet',
|
||||
} as Record<string, string>,
|
||||
optIn:
|
||||
'Dieses Recht ist im Auslieferungszustand keiner Rolle zugeordnet. Vergib es bewusst.',
|
||||
preview: {
|
||||
heading: 'Vorschau',
|
||||
intro: 'Was jemand mit dieser Stufe sieht, wenn er keine weiteren Rollen hat.',
|
||||
level: 'Stufe',
|
||||
sees: 'Sieht Akten bis einschliesslich',
|
||||
hidden: 'Höhere Akten sind unsichtbar',
|
||||
lockedOnly: 'Höhere Akten sind sichtbar, aber gesperrt',
|
||||
nobody: 'Keine Rolle führt zu dieser Stufe',
|
||||
viaRoles: 'Erreichbar über',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const templateAdminText = {
|
||||
heading: 'Vorlagen',
|
||||
intro:
|
||||
'Eine Vorlage bestimmt, aus welchen Bausteinen eine Fallart besteht. Speichern legt eine neue Version an; bestehende Akten behalten die Version, mit der sie angelegt wurden.',
|
||||
empty: 'Noch keine Vorlage.',
|
||||
version: (v: number) => `Version ${v}`,
|
||||
fieldsLabel: 'Bausteine als JSON',
|
||||
fieldsHelp:
|
||||
'Liste von Bausteinen. Eingabefelder: shorttext, longtext, select, multiselect, date, person, number, checklist, table. Feste Bausteine: heading, paragraph, signature, pagebreak.',
|
||||
numberPrefix: 'Präfix des Aktenzeichens',
|
||||
numberPadding: 'Stellen der laufenden Nummer',
|
||||
save: 'Als neue Version speichern',
|
||||
saved: (v: number) => `Gespeichert als Version ${v}`,
|
||||
preview: 'Vorschau',
|
||||
previewIntro: 'So sieht eine neue Akte mit dieser Vorlage aus.',
|
||||
invalid: 'Die Bausteine sind kein gültiges JSON.',
|
||||
fieldsInvalid: 'Die Bausteine sind nicht gültig.',
|
||||
inputField: 'Eingabe',
|
||||
staticBlock: 'Fester Baustein',
|
||||
} as const
|
||||
|
||||
export const settingsText = {
|
||||
heading: 'Änderungsrichtlinie',
|
||||
intro:
|
||||
'Regelt, ob und wie lange Verlaufseinträge nachträglich geändert werden dürfen. Gilt für die ganze Abteilung.',
|
||||
policy: 'Richtlinie',
|
||||
graceMinutes: 'Karenzzeit in Minuten',
|
||||
markGrace: 'Auch Änderungen in der Karenzzeit sichtbar markieren',
|
||||
save: 'Richtlinie speichern',
|
||||
saved: 'Richtlinie gespeichert',
|
||||
confirm: 'Änderungsrichtlinie ändern? Das gilt sofort für alle Akten.',
|
||||
policies: {
|
||||
APPEND_ONLY: 'Keine Änderung möglich, nur Korrektureinträge',
|
||||
GRACE_WINDOW: 'Eigene Einträge in der Karenzzeit frei änderbar, danach nur mit Recht',
|
||||
OPEN: 'Jederzeit änderbar, sofern das Recht vorliegt',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Fallakten samt Abschnitten, Verlauf, Beteiligten und Verknüpfungen.
|
||||
*
|
||||
* Nach Bereichen getrennt, weil eine **Client-Komponente die ganze Datei ins
|
||||
* Bündel zieht**, in der ihr Text steht. Lägen alle Texte in einer, trüge jede
|
||||
* Ansicht die der ganzen Anwendung mit sich herum — das Budget aus
|
||||
* docs/09-performance-und-ux.md wäre nach wenigen Phasen aufgebraucht.
|
||||
*
|
||||
* Serverkomponenten nehmen weiter `de` aus `lib/i18n/de.ts`: was dorthin
|
||||
* gezogen wird, verlässt den Server nicht.
|
||||
*/
|
||||
|
||||
export const caseText = {
|
||||
one: 'Fallakte',
|
||||
many: 'Fallakten',
|
||||
number: 'Aktenzeichen',
|
||||
title: 'Titel',
|
||||
type: 'Fallart',
|
||||
status: 'Status',
|
||||
dueAt: 'Wiedervorlage',
|
||||
outcome: 'Ergebnis',
|
||||
createdBy: 'Angelegt von',
|
||||
timeline: 'Verlauf',
|
||||
sections: 'Abschnitte',
|
||||
chat: 'Aktenchat',
|
||||
changes: 'Änderungen',
|
||||
participants: 'Beteiligte',
|
||||
links: 'Verknüpfungen',
|
||||
attachments: 'Anhänge',
|
||||
empty: 'Noch keine Fallakte. Erste Fallakte anlegen.',
|
||||
create: 'Fallakte anlegen',
|
||||
createTitle: 'Neue Fallakte',
|
||||
subject: 'Betroffene Person',
|
||||
subjectNone: 'Keine',
|
||||
open: 'Öffnen',
|
||||
updatedAt: 'Zuletzt geändert',
|
||||
more: 'Weitere laden',
|
||||
listHeading: 'Fallakten',
|
||||
countLabel: (n: number) => (n === 1 ? '1 Akte' : `${n} Akten`),
|
||||
filterStatus: 'Status',
|
||||
filterAll: 'Alle',
|
||||
reclassify: 'Klassifizierung ändern',
|
||||
reclassifyReason: 'Begründung der Herabstufung',
|
||||
statusChange: 'Status ändern',
|
||||
newEntry: 'Neuer Vermerk',
|
||||
entryPlaceholder: 'Was ist passiert?',
|
||||
saving: 'Wird gespeichert',
|
||||
unsaved: 'Nicht gespeichert',
|
||||
statusNames: {
|
||||
DRAFT: 'Entwurf',
|
||||
OPEN: 'Offen',
|
||||
DORMANT: 'Ruhend',
|
||||
REVIEW: 'In Prüfung',
|
||||
CLOSED: 'Abgeschlossen',
|
||||
ARCHIVED: 'Archiviert',
|
||||
SEALED: 'Versiegelt',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
|
||||
export const sectionText = {
|
||||
heading: 'Abschnitte',
|
||||
empty: 'Die Vorlage dieser Fallart hat keine Abschnitte.',
|
||||
save: 'Abschnitt speichern',
|
||||
saving: 'Wird gespeichert',
|
||||
saved: 'Gespeichert',
|
||||
draftKept: 'Entwurf lokal gesichert',
|
||||
version: (v: number) => `v${v}`,
|
||||
history: 'Fassungen',
|
||||
conflict:
|
||||
'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
||||
mine: 'Meine Fassung',
|
||||
theirs: 'Fremde Fassung',
|
||||
takeMine: 'Meine übernehmen',
|
||||
takeTheirs: 'Fremde übernehmen',
|
||||
redacted: 'Geschwärzt',
|
||||
redactedFrom: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`,
|
||||
lockedField: (n: number) => `Feld gesperrt, sichtbar ab Stufe ${n}`,
|
||||
requiredToClose: 'Für den Abschluss nötig',
|
||||
signatureLine: 'Unterschrift',
|
||||
} as const
|
||||
|
||||
export const entryText = {
|
||||
one: 'Vermerk',
|
||||
many: 'Vermerke',
|
||||
create: 'Vermerk speichern',
|
||||
empty: 'Noch kein Vermerk. Ersten Vermerk anlegen.',
|
||||
edited: (v: number) => `bearbeitet · v${v}`,
|
||||
history: 'Änderungsverlauf',
|
||||
edit: 'Bearbeiten',
|
||||
editSave: 'Änderung speichern',
|
||||
retract: 'Zurückziehen',
|
||||
restore: 'Wiederherstellen',
|
||||
retractReason: 'Begründung der Zurücknahme',
|
||||
reasonTooShort: 'Begründung nötig, mindestens 10 Zeichen.',
|
||||
cancel: 'Abbrechen',
|
||||
changesHeading: 'Änderungen an dieser Akte',
|
||||
changesIntro:
|
||||
'Jede nachträgliche Änderung am Verlauf, chronologisch. Sichtbar für alle, die die Akte lesen dürfen.',
|
||||
changesEmpty: 'Bisher wurde nichts nachträglich geändert.',
|
||||
versionLabel: (v: number) => `Fassung ${v}`,
|
||||
before: 'Vorher',
|
||||
after: 'Jetzt',
|
||||
changeReason: 'Begründung',
|
||||
retracted: (date: string, person: string, reason: string) =>
|
||||
`Eintrag zurückgezogen am ${date} durch ${person} · Begründung: ${reason}`,
|
||||
types: {
|
||||
NOTE: 'Vermerk',
|
||||
INTERROGATION: 'Befragung',
|
||||
EVIDENCE: 'Beweismittel',
|
||||
STATUS_CHANGE: 'Statuswechsel',
|
||||
CORRECTION: 'Korrektur',
|
||||
SYSTEM: 'System',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
|
||||
export const participantText = {
|
||||
heading: 'Beteiligte',
|
||||
empty: 'Noch niemand eingetragen.',
|
||||
add: 'Beteiligte eintragen',
|
||||
remove: 'Entfernen',
|
||||
role: 'Rolle',
|
||||
person: 'Person',
|
||||
pickUser: 'Mitglied',
|
||||
pickPersonnel: 'Personalakte',
|
||||
hint:
|
||||
'Wer hier steht, liest die Akte unabhängig von seiner Freigabestufe. Das ist der Weg, jemanden ausnahmsweise hinzuzuziehen.',
|
||||
roles: {
|
||||
LEAD: 'Ermittlungsführung',
|
||||
INVESTIGATOR: 'Ermittler',
|
||||
WITNESS: 'Zeuge',
|
||||
SUBJECT: 'Betroffener',
|
||||
OBSERVER: 'Beobachter',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
|
||||
export const linkText = {
|
||||
heading: 'Verknüpfungen',
|
||||
empty: 'Keine Verknüpfung.',
|
||||
add: 'Akte verknüpfen',
|
||||
remove: 'Verknüpfung lösen',
|
||||
target: 'Aktenzeichen',
|
||||
kind: 'Art',
|
||||
kinds: {
|
||||
RELATED: 'Verwandt',
|
||||
SUCCESSOR: 'Nachfolger',
|
||||
MERGED_INTO: 'Zusammengeführt in',
|
||||
} as Record<string, string>,
|
||||
incoming: {
|
||||
RELATED: 'Verwandt',
|
||||
SUCCESSOR: 'Vorgänger',
|
||||
MERGED_INTO: 'Zusammengeführt aus',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Was fast jede Ansicht braucht: Knöpfe, Fehlermeldungen, Freigabestufen.
|
||||
*
|
||||
* Nach Bereichen getrennt, weil eine **Client-Komponente die ganze Datei ins
|
||||
* Bündel zieht**, in der ihr Text steht. Lägen alle Texte in einer, trüge jede
|
||||
* Ansicht die der ganzen Anwendung mit sich herum — das Budget aus
|
||||
* docs/09-performance-und-ux.md wäre nach wenigen Phasen aufgebraucht.
|
||||
*
|
||||
* Serverkomponenten nehmen weiter `de` aus `lib/i18n/de.ts`: was dorthin
|
||||
* gezogen wird, verlässt den Server nicht.
|
||||
*/
|
||||
|
||||
export const commonText = {
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
undo: 'Rückgängig',
|
||||
retry: 'Erneut versuchen',
|
||||
loading: 'Wird geladen',
|
||||
offline: 'Keine Verbindung, wird nachgesendet',
|
||||
unknown: 'Unbekannt',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Meldungen zu abgelehnten Aktionen. Der Schlüssel ist der `code` eines
|
||||
* `AccessError`, siehe lib/errors.ts. Gilt für Fallakten, Personalakten und
|
||||
* Dokumente gleichermaßen — die Regeln sind dieselben.
|
||||
*/
|
||||
export const actionErrorsText = {
|
||||
'not-found': 'Die Akte gibt es nicht, oder du darfst sie nicht sehen.',
|
||||
'clearance-too-low': 'Deine Freigabestufe reicht dafür nicht.',
|
||||
'no-permission': 'Dir fehlt das nötige Recht.',
|
||||
'read-only': 'Der Datensatz ist schreibgeschützt.',
|
||||
closed: 'Der Vorgang ist abgeschlossen. Änderungen brauchen ein eigenes Recht.',
|
||||
'reason-required': 'Begründung nötig, mindestens 10 Zeichen.',
|
||||
'transition-invalid': 'Dieser Statuswechsel ist nicht vorgesehen.',
|
||||
'sections-incomplete': 'Es fehlen Pflichtangaben für den Abschluss.',
|
||||
'template-missing': 'Dafür gibt es keine Vorlage.',
|
||||
'field-unknown': 'Dieses Feld gehört nicht zur Vorlage.',
|
||||
'name-required': 'Ein Name mit mindestens zwei Zeichen ist nötig.',
|
||||
'participant-target': 'Entweder ein Discord-Konto oder eine Personalakte, nicht beides.',
|
||||
'link-self': 'Eine Akte lässt sich nicht mit sich selbst verknüpfen.',
|
||||
default: 'Nicht gespeichert. Erneut versuchen.',
|
||||
} as Record<string, string>
|
||||
|
||||
export const errorsText = {
|
||||
notFound: {
|
||||
title: 'Nicht gefunden',
|
||||
body: 'Die Seite gibt es nicht, oder du darfst sie nicht sehen.',
|
||||
},
|
||||
unexpected: {
|
||||
title: 'Nicht geladen',
|
||||
body: 'Der Abruf ist fehlgeschlagen. Erneut versuchen.',
|
||||
},
|
||||
conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
||||
} as const
|
||||
|
||||
export const clearanceText = {
|
||||
label: 'Freigabestufe',
|
||||
classification: 'Klassifizierung',
|
||||
level: (n: number) => `Stufe ${n}`,
|
||||
// Die Bezeichnungen der Stufen stehen nicht hier, sondern in
|
||||
// ClearanceLevelConfig: sie sind im Adminbereich änderbar und folgen den
|
||||
// Zugangsrollen des Discord-Servers. Siehe docs/13-entscheidungen.md.
|
||||
locked: 'Inhalt gesperrt',
|
||||
lockedHint: (n: number) => `Sichtbar ab Stufe ${n}`,
|
||||
redacted: 'Geschwärzt',
|
||||
redactedHint: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`,
|
||||
} as const
|
||||
+33
-444
@@ -8,450 +8,39 @@
|
||||
* - Fehler nennen Ursache und naechsten Schritt, ohne Entschuldigung
|
||||
* - Leere Zustaende sind eine Aufforderung
|
||||
* - Keine Ausrufezeichen, kein "bitte", kein "erfolgreich"
|
||||
*
|
||||
* Die Texte stehen nach Bereichen getrennt daneben; diese Datei setzt sie nur
|
||||
* wieder zusammen. Serverkomponenten duerfen `de` nehmen. Eine
|
||||
* Client-Komponente importiert **den Bereich, den sie braucht** — sonst traegt
|
||||
* sie die Texte der ganzen Anwendung ins Buendel.
|
||||
*/
|
||||
import { appText, navText, authText, overviewText, healthText } from '@/lib/i18n/shell'
|
||||
import { commonText, actionErrorsText, errorsText, clearanceText } from '@/lib/i18n/common'
|
||||
import { caseText, sectionText, entryText, participantText, linkText } from '@/lib/i18n/cases'
|
||||
import { personnelText, assignmentText } from '@/lib/i18n/people'
|
||||
import { documentText, attachmentText } from '@/lib/i18n/documents'
|
||||
import { roleAdminText, templateAdminText, settingsText } from '@/lib/i18n/admin'
|
||||
|
||||
export const de = {
|
||||
app: {
|
||||
name: 'AfiS-Aktensystem',
|
||||
shortName: 'AfiS',
|
||||
description: 'Fallakten, Personalakten und Ermittlungen der Abteilung für innere Sicherheit',
|
||||
},
|
||||
|
||||
nav: {
|
||||
dashboard: 'Übersicht',
|
||||
cases: 'Fallakten',
|
||||
personnel: 'Personalakten',
|
||||
search: 'Suche',
|
||||
admin: 'Verwaltung',
|
||||
skipToContent: 'Zum Inhalt springen',
|
||||
},
|
||||
|
||||
auth: {
|
||||
signIn: 'Mit Discord anmelden',
|
||||
signOut: 'Abmelden',
|
||||
signedInAs: 'Angemeldet als',
|
||||
intro:
|
||||
'Die Anmeldung läuft über Discord. Zugriff haben Mitglieder der Abteilung für innere Sicherheit.',
|
||||
reasons: {
|
||||
'keine-mitgliedschaft': {
|
||||
title: 'Du bist nicht auf dem AfiS-Discord',
|
||||
body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.',
|
||||
},
|
||||
'keine-afis-rolle': {
|
||||
title: 'Keine Berechtigung für das Aktensystem',
|
||||
body: 'Du bist auf dem Server, trägst aber keine Rolle der Abteilung. Wende dich an die Abteilungsleitung.',
|
||||
},
|
||||
'discord-nicht-erreichbar': {
|
||||
title: 'Discord antwortet nicht',
|
||||
body: 'Die Mitgliedschaft war nicht prüfbar. In einigen Minuten erneut versuchen.',
|
||||
},
|
||||
} as Record<string, { title: string; body: string }>,
|
||||
sessionEnded: {
|
||||
title: 'Sitzung beendet',
|
||||
body: 'Deine Rollen haben sich geändert. Melde dich neu an.',
|
||||
},
|
||||
error: {
|
||||
title: 'Anmeldung nicht möglich',
|
||||
body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.',
|
||||
},
|
||||
backToSignIn: 'Zurück zur Anmeldung',
|
||||
},
|
||||
|
||||
overview: {
|
||||
heading: 'Dein Zugang',
|
||||
intro: 'Was das System über dich weiss und was daraus folgt.',
|
||||
person: 'Person',
|
||||
discordId: 'Discord-Kennung',
|
||||
roles: 'Rollen auf dem Discord',
|
||||
clearance: 'Errechnete Freigabestufe',
|
||||
permissions: 'Rechte',
|
||||
noPermissions: 'Keine Rechte. Die Abteilungsleitung vergibt sie in der Rollenverwaltung.',
|
||||
noRoles: 'Keine Rollen.',
|
||||
bootstrapAdmin: 'Notfallzugang aus der Konfiguration, unabhängig vom Rollen-Mapping',
|
||||
unmapped: 'nicht zugeordnet',
|
||||
},
|
||||
|
||||
roleAdmin: {
|
||||
heading: 'Rollenverwaltung',
|
||||
intro:
|
||||
'Jede Rolle des Discord-Servers steht hier. Zugangsrollen bestimmen die Freigabestufe, Rangrollen die Rechte, und eine Zugehörigkeitsrolle entscheidet, wer sich überhaupt anmelden darf.',
|
||||
empty:
|
||||
'Noch keine Rollen eingelesen. Der Worker spiegelt sie, sobald er am Discord-Gateway hängt.',
|
||||
role: 'Rolle',
|
||||
category: 'Sorte',
|
||||
clearance: 'Freigabestufe',
|
||||
grantsMembership: 'Darf sich anmelden',
|
||||
permissions: 'Rechte',
|
||||
missingFromGuild: 'Auf dem Server nicht mehr vorhanden',
|
||||
save: 'Zuordnung speichern',
|
||||
invalidInput: 'Eingabe nicht gültig',
|
||||
roleGone: 'Rolle nicht gefunden, sie wurde zwischenzeitlich gelöscht',
|
||||
saving: 'Wird gespeichert',
|
||||
saved: 'Zuordnung gespeichert',
|
||||
confirm: 'Rollen-Mapping ändern? Das wirkt sofort für alle angemeldeten Personen.',
|
||||
categories: {
|
||||
MEMBERSHIP: 'Zugehörigkeit',
|
||||
RANK: 'Rang',
|
||||
CLEARANCE: 'Zugang',
|
||||
UNSORTED: 'Nicht eingeordnet',
|
||||
} as Record<string, string>,
|
||||
optIn:
|
||||
'Dieses Recht ist im Auslieferungszustand keiner Rolle zugeordnet. Vergib es bewusst.',
|
||||
preview: {
|
||||
heading: 'Vorschau',
|
||||
intro: 'Was jemand mit dieser Stufe sieht, wenn er keine weiteren Rollen hat.',
|
||||
level: 'Stufe',
|
||||
sees: 'Sieht Akten bis einschliesslich',
|
||||
hidden: 'Höhere Akten sind unsichtbar',
|
||||
lockedOnly: 'Höhere Akten sind sichtbar, aber gesperrt',
|
||||
nobody: 'Keine Rolle führt zu dieser Stufe',
|
||||
viaRoles: 'Erreichbar über',
|
||||
},
|
||||
},
|
||||
|
||||
clearance: {
|
||||
label: 'Freigabestufe',
|
||||
classification: 'Klassifizierung',
|
||||
level: (n: number) => `Stufe ${n}`,
|
||||
// Die Bezeichnungen der Stufen stehen nicht hier, sondern in
|
||||
// ClearanceLevelConfig: sie sind im Adminbereich änderbar und folgen den
|
||||
// Zugangsrollen des Discord-Servers. Siehe docs/13-entscheidungen.md.
|
||||
locked: 'Inhalt gesperrt',
|
||||
lockedHint: (n: number) => `Sichtbar ab Stufe ${n}`,
|
||||
redacted: 'Geschwärzt',
|
||||
redactedHint: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`,
|
||||
},
|
||||
|
||||
case: {
|
||||
one: 'Fallakte',
|
||||
many: 'Fallakten',
|
||||
number: 'Aktenzeichen',
|
||||
title: 'Titel',
|
||||
type: 'Fallart',
|
||||
status: 'Status',
|
||||
dueAt: 'Wiedervorlage',
|
||||
outcome: 'Ergebnis',
|
||||
createdBy: 'Angelegt von',
|
||||
timeline: 'Verlauf',
|
||||
sections: 'Abschnitte',
|
||||
chat: 'Aktenchat',
|
||||
changes: 'Änderungen',
|
||||
participants: 'Beteiligte',
|
||||
links: 'Verknüpfungen',
|
||||
attachments: 'Anhänge',
|
||||
empty: 'Noch keine Fallakte. Erste Fallakte anlegen.',
|
||||
create: 'Fallakte anlegen',
|
||||
createTitle: 'Neue Fallakte',
|
||||
subject: 'Betroffene Person',
|
||||
subjectNone: 'Keine',
|
||||
open: 'Öffnen',
|
||||
updatedAt: 'Zuletzt geändert',
|
||||
more: 'Weitere laden',
|
||||
listHeading: 'Fallakten',
|
||||
countLabel: (n: number) => (n === 1 ? '1 Akte' : `${n} Akten`),
|
||||
filterStatus: 'Status',
|
||||
filterAll: 'Alle',
|
||||
reclassify: 'Klassifizierung ändern',
|
||||
reclassifyReason: 'Begründung der Herabstufung',
|
||||
statusChange: 'Status ändern',
|
||||
newEntry: 'Neuer Vermerk',
|
||||
entryPlaceholder: 'Was ist passiert?',
|
||||
saving: 'Wird gespeichert',
|
||||
unsaved: 'Nicht gespeichert',
|
||||
statusNames: {
|
||||
DRAFT: 'Entwurf',
|
||||
OPEN: 'Offen',
|
||||
DORMANT: 'Ruhend',
|
||||
REVIEW: 'In Prüfung',
|
||||
CLOSED: 'Abgeschlossen',
|
||||
ARCHIVED: 'Archiviert',
|
||||
SEALED: 'Versiegelt',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
section: {
|
||||
heading: 'Abschnitte',
|
||||
empty: 'Die Vorlage dieser Fallart hat keine Abschnitte.',
|
||||
save: 'Abschnitt speichern',
|
||||
saving: 'Wird gespeichert',
|
||||
saved: 'Gespeichert',
|
||||
draftKept: 'Entwurf lokal gesichert',
|
||||
version: (v: number) => `v${v}`,
|
||||
history: 'Fassungen',
|
||||
conflict:
|
||||
'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
||||
mine: 'Meine Fassung',
|
||||
theirs: 'Fremde Fassung',
|
||||
takeMine: 'Meine übernehmen',
|
||||
takeTheirs: 'Fremde übernehmen',
|
||||
redacted: 'Geschwärzt',
|
||||
redactedFrom: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`,
|
||||
lockedField: (n: number) => `Feld gesperrt, sichtbar ab Stufe ${n}`,
|
||||
requiredToClose: 'Für den Abschluss nötig',
|
||||
signatureLine: 'Unterschrift',
|
||||
},
|
||||
|
||||
entry: {
|
||||
one: 'Vermerk',
|
||||
many: 'Vermerke',
|
||||
create: 'Vermerk speichern',
|
||||
empty: 'Noch kein Vermerk. Ersten Vermerk anlegen.',
|
||||
edited: (v: number) => `bearbeitet · v${v}`,
|
||||
history: 'Änderungsverlauf',
|
||||
edit: 'Bearbeiten',
|
||||
editSave: 'Änderung speichern',
|
||||
retract: 'Zurückziehen',
|
||||
restore: 'Wiederherstellen',
|
||||
retractReason: 'Begründung der Zurücknahme',
|
||||
reasonTooShort: 'Begründung nötig, mindestens 10 Zeichen.',
|
||||
cancel: 'Abbrechen',
|
||||
changesHeading: 'Änderungen an dieser Akte',
|
||||
changesIntro:
|
||||
'Jede nachträgliche Änderung am Verlauf, chronologisch. Sichtbar für alle, die die Akte lesen dürfen.',
|
||||
changesEmpty: 'Bisher wurde nichts nachträglich geändert.',
|
||||
versionLabel: (v: number) => `Fassung ${v}`,
|
||||
before: 'Vorher',
|
||||
after: 'Jetzt',
|
||||
changeReason: 'Begründung',
|
||||
retracted: (date: string, person: string, reason: string) =>
|
||||
`Eintrag zurückgezogen am ${date} durch ${person} · Begründung: ${reason}`,
|
||||
types: {
|
||||
NOTE: 'Vermerk',
|
||||
INTERROGATION: 'Befragung',
|
||||
EVIDENCE: 'Beweismittel',
|
||||
STATUS_CHANGE: 'Statuswechsel',
|
||||
CORRECTION: 'Korrektur',
|
||||
SYSTEM: 'System',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
personnel: {
|
||||
one: 'Personalakte',
|
||||
many: 'Personalakten',
|
||||
listHeading: 'Personalakten',
|
||||
name: 'Name',
|
||||
rank: 'Dienstgrad',
|
||||
department: 'Abteilung',
|
||||
status: 'Status',
|
||||
statusHint: 'Mehrere möglich. „Gekündigt" und „Verhaftet" schließen einander nicht aus.',
|
||||
statusNone: 'Kein Status gesetzt',
|
||||
filterStatus: 'Status',
|
||||
filterAll: 'Alle',
|
||||
note: 'Anmerkung',
|
||||
noteHelp: 'Steht im Kopf der Akte, etwa "kein Discord-Konto, Aktenlage aus 2025".',
|
||||
linkedAccount: 'Discord-Konto',
|
||||
noAccount: 'Ohne Discord-Konto',
|
||||
empty: 'Noch keine Personalakte. Erste Personalakte anlegen.',
|
||||
emptySearch: 'Kein Treffer. Anderen Begriff versuchen.',
|
||||
create: 'Personalakte anlegen',
|
||||
createTitle: 'Neue Personalakte',
|
||||
createHint:
|
||||
'Meist für Externe ohne Discord-Konto: Sicherheitsdienst, Zeugen, Vertragspartner. Zusammen mit der Akte entsteht das Aktenblatt als erster Eintrag.',
|
||||
search: 'Name, Dienstgrad oder Abteilung',
|
||||
countLabel: (n: number) => (n === 1 ? '1 Personalakte' : `${n} Personalakten`),
|
||||
edit: 'Kopfdaten ändern',
|
||||
reclassify: 'Klassifizierung ändern',
|
||||
headData: 'Kopfdaten',
|
||||
backlinks: 'Fälle mit dieser Person',
|
||||
backlinksEmpty: 'Diese Person kommt in keinem Fall vor, den du sehen darfst.',
|
||||
backlinksCount: (n: number) => (n === 1 ? '1 Fall' : `${n} Fälle`),
|
||||
linkCase: 'Fall verknüpfen',
|
||||
linkCaseHint: 'Aktenzeichen eintragen und die Rolle wählen, in der die Person darin vorkommt.',
|
||||
},
|
||||
|
||||
assignment: {
|
||||
heading: 'Zuständige der Abteilung',
|
||||
empty: 'Niemand zugeordnet.',
|
||||
add: 'Mitglied zuordnen',
|
||||
remove: 'Entfernen',
|
||||
hint:
|
||||
'Wer hier steht, liest die Personalakte unabhängig von seiner Freigabestufe.',
|
||||
roles: {
|
||||
HANDLER: 'Aktenführung',
|
||||
INVESTIGATOR: 'Ermittler',
|
||||
CONTACT: 'Ansprechpartner',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
participant: {
|
||||
heading: 'Beteiligte',
|
||||
empty: 'Noch niemand eingetragen.',
|
||||
add: 'Beteiligte eintragen',
|
||||
remove: 'Entfernen',
|
||||
role: 'Rolle',
|
||||
person: 'Person',
|
||||
pickUser: 'Mitglied',
|
||||
pickPersonnel: 'Personalakte',
|
||||
hint:
|
||||
'Wer hier steht, liest die Akte unabhängig von seiner Freigabestufe. Das ist der Weg, jemanden ausnahmsweise hinzuzuziehen.',
|
||||
roles: {
|
||||
LEAD: 'Ermittlungsführung',
|
||||
INVESTIGATOR: 'Ermittler',
|
||||
WITNESS: 'Zeuge',
|
||||
SUBJECT: 'Betroffener',
|
||||
OBSERVER: 'Beobachter',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
link: {
|
||||
heading: 'Verknüpfungen',
|
||||
empty: 'Keine Verknüpfung.',
|
||||
add: 'Akte verknüpfen',
|
||||
remove: 'Verknüpfung lösen',
|
||||
target: 'Aktenzeichen',
|
||||
kind: 'Art',
|
||||
kinds: {
|
||||
RELATED: 'Verwandt',
|
||||
SUCCESSOR: 'Nachfolger',
|
||||
MERGED_INTO: 'Zusammengeführt in',
|
||||
} as Record<string, string>,
|
||||
incoming: {
|
||||
RELATED: 'Verwandt',
|
||||
SUCCESSOR: 'Vorgänger',
|
||||
MERGED_INTO: 'Zusammengeführt aus',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
document: {
|
||||
one: 'Dokument',
|
||||
many: 'Dokumente',
|
||||
number: 'Dokumentnummer',
|
||||
title: 'Titel',
|
||||
kind: 'Dokumentart',
|
||||
heading: 'Dokumente',
|
||||
empty: 'Noch kein Dokument.',
|
||||
create: 'Dokument anlegen',
|
||||
createTitle: 'Neues Dokument',
|
||||
createHint: 'Die Vorlage bestimmt Aufbau und Aussehen. Der Titel lässt sich später ändern.',
|
||||
open: 'Öffnen',
|
||||
finalize: 'Ausfertigen',
|
||||
finalizeHint: 'Danach sind Änderungen nur noch mit eigenem Recht möglich.',
|
||||
retract: 'Zurückziehen',
|
||||
retractReason: 'Begründung der Zurücknahme',
|
||||
reclassify: 'Klassifizierung ändern',
|
||||
retracted: (date: string, person: string, reason: string) =>
|
||||
`Dokument zurückgezogen am ${date} durch ${person} · Begründung: ${reason}`,
|
||||
finalized: (date: string, person: string) => `Ausgefertigt am ${date} durch ${person}`,
|
||||
sheet: 'Aktenblatt',
|
||||
edit: 'Bearbeiten',
|
||||
print: 'Drucken',
|
||||
issuedAt: 'Ausgestellt',
|
||||
signedOn: 'Datum',
|
||||
unclassified: 'Ohne Klassifikation',
|
||||
photoEmpty: 'Lichtbild 3×4 cm',
|
||||
photoLater: 'Lichtbilder kommen mit der Dateiprüfung.',
|
||||
footerNote: 'Dieses Dokument ist klassifiziert. Nur für autorisiertes Personal.',
|
||||
ownerCase: 'Zur Fallakte',
|
||||
ownerPersonnel: 'Zur Personalakte',
|
||||
ownerNone: 'Ohne Besitzer',
|
||||
statusNames: {
|
||||
DRAFT: 'Entwurf',
|
||||
FINAL: 'Ausgefertigt',
|
||||
RETRACTED: 'Zurückgezogen',
|
||||
} as Record<string, string>,
|
||||
layoutNames: {
|
||||
FORM: 'Formular',
|
||||
PROSE: 'Vertragstext',
|
||||
NOTE: 'Vermerk',
|
||||
DOSSIER: 'Aktenblatt',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
common: {
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
undo: 'Rückgängig',
|
||||
retry: 'Erneut versuchen',
|
||||
loading: 'Wird geladen',
|
||||
offline: 'Keine Verbindung, wird nachgesendet',
|
||||
unknown: 'Unbekannt',
|
||||
},
|
||||
|
||||
/**
|
||||
* Meldungen zu abgelehnten Aktionen. Der Schlüssel ist der `code` eines
|
||||
* `AccessError`, siehe lib/errors.ts. Gilt für Fallakten, Personalakten und
|
||||
* Dokumente gleichermaßen — die Regeln sind dieselben.
|
||||
*/
|
||||
actionErrors: {
|
||||
'not-found': 'Die Akte gibt es nicht, oder du darfst sie nicht sehen.',
|
||||
'clearance-too-low': 'Deine Freigabestufe reicht dafür nicht.',
|
||||
'no-permission': 'Dir fehlt das nötige Recht.',
|
||||
'read-only': 'Der Datensatz ist schreibgeschützt.',
|
||||
closed: 'Der Vorgang ist abgeschlossen. Änderungen brauchen ein eigenes Recht.',
|
||||
'reason-required': 'Begründung nötig, mindestens 10 Zeichen.',
|
||||
'transition-invalid': 'Dieser Statuswechsel ist nicht vorgesehen.',
|
||||
'sections-incomplete': 'Es fehlen Pflichtangaben für den Abschluss.',
|
||||
'template-missing': 'Dafür gibt es keine Vorlage.',
|
||||
'field-unknown': 'Dieses Feld gehört nicht zur Vorlage.',
|
||||
'name-required': 'Ein Name mit mindestens zwei Zeichen ist nötig.',
|
||||
'participant-target': 'Entweder ein Discord-Konto oder eine Personalakte, nicht beides.',
|
||||
'link-self': 'Eine Akte lässt sich nicht mit sich selbst verknüpfen.',
|
||||
default: 'Nicht gespeichert. Erneut versuchen.',
|
||||
} as Record<string, string>,
|
||||
|
||||
errors: {
|
||||
notFound: {
|
||||
title: 'Nicht gefunden',
|
||||
body: 'Die Seite gibt es nicht, oder du darfst sie nicht sehen.',
|
||||
},
|
||||
unexpected: {
|
||||
title: 'Nicht geladen',
|
||||
body: 'Der Abruf ist fehlgeschlagen. Erneut versuchen.',
|
||||
},
|
||||
conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
||||
},
|
||||
|
||||
templateAdmin: {
|
||||
heading: 'Vorlagen',
|
||||
intro:
|
||||
'Eine Vorlage bestimmt, aus welchen Bausteinen eine Fallart besteht. Speichern legt eine neue Version an; bestehende Akten behalten die Version, mit der sie angelegt wurden.',
|
||||
empty: 'Noch keine Vorlage.',
|
||||
version: (v: number) => `Version ${v}`,
|
||||
fieldsLabel: 'Bausteine als JSON',
|
||||
fieldsHelp:
|
||||
'Liste von Bausteinen. Eingabefelder: shorttext, longtext, select, multiselect, date, person, number, checklist, table. Feste Bausteine: heading, paragraph, signature, pagebreak.',
|
||||
numberPrefix: 'Präfix des Aktenzeichens',
|
||||
numberPadding: 'Stellen der laufenden Nummer',
|
||||
save: 'Als neue Version speichern',
|
||||
saved: (v: number) => `Gespeichert als Version ${v}`,
|
||||
preview: 'Vorschau',
|
||||
previewIntro: 'So sieht eine neue Akte mit dieser Vorlage aus.',
|
||||
invalid: 'Die Bausteine sind kein gültiges JSON.',
|
||||
fieldsInvalid: 'Die Bausteine sind nicht gültig.',
|
||||
inputField: 'Eingabe',
|
||||
staticBlock: 'Fester Baustein',
|
||||
},
|
||||
|
||||
settings: {
|
||||
heading: 'Änderungsrichtlinie',
|
||||
intro:
|
||||
'Regelt, ob und wie lange Verlaufseinträge nachträglich geändert werden dürfen. Gilt für die ganze Abteilung.',
|
||||
policy: 'Richtlinie',
|
||||
graceMinutes: 'Karenzzeit in Minuten',
|
||||
markGrace: 'Auch Änderungen in der Karenzzeit sichtbar markieren',
|
||||
save: 'Richtlinie speichern',
|
||||
saved: 'Richtlinie gespeichert',
|
||||
confirm: 'Änderungsrichtlinie ändern? Das gilt sofort für alle Akten.',
|
||||
policies: {
|
||||
APPEND_ONLY: 'Keine Änderung möglich, nur Korrektureinträge',
|
||||
GRACE_WINDOW: 'Eigene Einträge in der Karenzzeit frei änderbar, danach nur mit Recht',
|
||||
OPEN: 'Jederzeit änderbar, sofern das Recht vorliegt',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
health: {
|
||||
heading: 'Systemzustand',
|
||||
service: 'Dienst',
|
||||
state: 'Zustand',
|
||||
checkedAt: (when: string) => `Zustand der Dienste, abgefragt ${when}`,
|
||||
ok: 'Betriebsbereit',
|
||||
degraded: 'Eingeschränkt',
|
||||
down: 'Gestört',
|
||||
components: {
|
||||
database: 'Datenbank',
|
||||
discordGateway: 'Discord-Gateway',
|
||||
clamav: 'Virenprüfung',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
app: appText,
|
||||
nav: navText,
|
||||
auth: authText,
|
||||
overview: overviewText,
|
||||
roleAdmin: roleAdminText,
|
||||
clearance: clearanceText,
|
||||
case: caseText,
|
||||
section: sectionText,
|
||||
entry: entryText,
|
||||
personnel: personnelText,
|
||||
assignment: assignmentText,
|
||||
participant: participantText,
|
||||
link: linkText,
|
||||
document: documentText,
|
||||
attachment: attachmentText,
|
||||
common: commonText,
|
||||
actionErrors: actionErrorsText,
|
||||
errors: errorsText,
|
||||
templateAdmin: templateAdminText,
|
||||
settings: settingsText,
|
||||
health: healthText,
|
||||
} as const
|
||||
|
||||
export type Messages = typeof de
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Dokumente und Anhänge.
|
||||
*
|
||||
* Nach Bereichen getrennt, weil eine **Client-Komponente die ganze Datei ins
|
||||
* Bündel zieht**, in der ihr Text steht. Lägen alle Texte in einer, trüge jede
|
||||
* Ansicht die der ganzen Anwendung mit sich herum — das Budget aus
|
||||
* docs/09-performance-und-ux.md wäre nach wenigen Phasen aufgebraucht.
|
||||
*
|
||||
* Serverkomponenten nehmen weiter `de` aus `lib/i18n/de.ts`: was dorthin
|
||||
* gezogen wird, verlässt den Server nicht.
|
||||
*/
|
||||
|
||||
export const documentText = {
|
||||
one: 'Dokument',
|
||||
many: 'Dokumente',
|
||||
number: 'Dokumentnummer',
|
||||
title: 'Titel',
|
||||
kind: 'Dokumentart',
|
||||
heading: 'Dokumente',
|
||||
empty: 'Noch kein Dokument.',
|
||||
create: 'Dokument anlegen',
|
||||
createTitle: 'Neues Dokument',
|
||||
createHint: 'Die Vorlage bestimmt Aufbau und Aussehen. Der Titel lässt sich später ändern.',
|
||||
open: 'Öffnen',
|
||||
finalize: 'Ausfertigen',
|
||||
finalizeHint: 'Danach sind Änderungen nur noch mit eigenem Recht möglich.',
|
||||
retract: 'Zurückziehen',
|
||||
retractReason: 'Begründung der Zurücknahme',
|
||||
reclassify: 'Klassifizierung ändern',
|
||||
retracted: (date: string, person: string, reason: string) =>
|
||||
`Dokument zurückgezogen am ${date} durch ${person} · Begründung: ${reason}`,
|
||||
finalized: (date: string, person: string) => `Ausgefertigt am ${date} durch ${person}`,
|
||||
sheet: 'Aktenblatt',
|
||||
edit: 'Bearbeiten',
|
||||
print: 'Drucken',
|
||||
issuedAt: 'Ausgestellt',
|
||||
signedOn: 'Datum',
|
||||
unclassified: 'Ohne Klassifikation',
|
||||
photoEmpty: 'Lichtbild 3×4 cm',
|
||||
photoLater: 'Lichtbilder kommen mit der Dateiprüfung.',
|
||||
footerNote: 'Dieses Dokument ist klassifiziert. Nur für autorisiertes Personal.',
|
||||
ownerCase: 'Zur Fallakte',
|
||||
ownerPersonnel: 'Zur Personalakte',
|
||||
ownerNone: 'Ohne Besitzer',
|
||||
statusNames: {
|
||||
DRAFT: 'Entwurf',
|
||||
FINAL: 'Ausgefertigt',
|
||||
RETRACTED: 'Zurückgezogen',
|
||||
} as Record<string, string>,
|
||||
layoutNames: {
|
||||
FORM: 'Formular',
|
||||
PROSE: 'Vertragstext',
|
||||
NOTE: 'Vermerk',
|
||||
DOSSIER: 'Aktenblatt',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
|
||||
export const attachmentText = {
|
||||
one: 'Anhang',
|
||||
many: 'Anhänge',
|
||||
heading: 'Anhänge',
|
||||
empty: 'Keine Anhänge.',
|
||||
add: 'Datei anhängen',
|
||||
choose: 'Datei wählen',
|
||||
dropHint: 'Datei hierher ziehen oder auswählen',
|
||||
allowed: 'PNG, JPEG, WebP, PDF oder Text, bis 25 MB',
|
||||
uploading: 'Wird übertragen',
|
||||
checking: 'Wird geprüft',
|
||||
download: 'Herunterladen',
|
||||
release: 'Trotzdem freigeben',
|
||||
releaseHint:
|
||||
'Die Datei wurde nicht auf Schadsoftware geprüft. Sie wird beim Freigeben neu kodiert, soweit das geht.',
|
||||
quotaUsed: (used: string, total: string) => `${used} von ${total} belegt`,
|
||||
statusNames: {
|
||||
PENDING: 'Wird geprüft',
|
||||
CLEAN: 'Geprüft',
|
||||
BLOCKED: 'Abgelehnt',
|
||||
FAILED: 'Prüfung unvollständig',
|
||||
} as Record<string, string>,
|
||||
errors: {
|
||||
'file-empty': 'Die Datei ist leer.',
|
||||
'file-too-large': 'Die Datei ist größer als 25 MB.',
|
||||
'quota-exceeded': 'Dein Kontingent von 500 MB ist ausgeschöpft.',
|
||||
'rate-limited': 'Zu viele Uploads. In einer Minute erneut versuchen.',
|
||||
'release-not-possible': 'Nur eine unvollständige Prüfung lässt sich freigeben.',
|
||||
transfer: 'Übertragung fehlgeschlagen. Erneut versuchen.',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Personalakten und die Zuständigen der Abteilung.
|
||||
*
|
||||
* Nach Bereichen getrennt, weil eine **Client-Komponente die ganze Datei ins
|
||||
* Bündel zieht**, in der ihr Text steht. Lägen alle Texte in einer, trüge jede
|
||||
* Ansicht die der ganzen Anwendung mit sich herum — das Budget aus
|
||||
* docs/09-performance-und-ux.md wäre nach wenigen Phasen aufgebraucht.
|
||||
*
|
||||
* Serverkomponenten nehmen weiter `de` aus `lib/i18n/de.ts`: was dorthin
|
||||
* gezogen wird, verlässt den Server nicht.
|
||||
*/
|
||||
|
||||
export const personnelText = {
|
||||
one: 'Personalakte',
|
||||
many: 'Personalakten',
|
||||
listHeading: 'Personalakten',
|
||||
name: 'Name',
|
||||
rank: 'Dienstgrad',
|
||||
department: 'Abteilung',
|
||||
status: 'Status',
|
||||
statusHint: 'Mehrere möglich. „Gekündigt" und „Verhaftet" schließen einander nicht aus.',
|
||||
statusNone: 'Kein Status gesetzt',
|
||||
filterStatus: 'Status',
|
||||
filterAll: 'Alle',
|
||||
note: 'Anmerkung',
|
||||
noteHelp: 'Steht im Kopf der Akte, etwa "kein Discord-Konto, Aktenlage aus 2025".',
|
||||
linkedAccount: 'Discord-Konto',
|
||||
noAccount: 'Ohne Discord-Konto',
|
||||
empty: 'Noch keine Personalakte. Erste Personalakte anlegen.',
|
||||
emptySearch: 'Kein Treffer. Anderen Begriff versuchen.',
|
||||
create: 'Personalakte anlegen',
|
||||
createTitle: 'Neue Personalakte',
|
||||
createHint:
|
||||
'Meist für Externe ohne Discord-Konto: Sicherheitsdienst, Zeugen, Vertragspartner. Zusammen mit der Akte entsteht das Aktenblatt als erster Eintrag.',
|
||||
search: 'Name, Dienstgrad oder Abteilung',
|
||||
countLabel: (n: number) => (n === 1 ? '1 Personalakte' : `${n} Personalakten`),
|
||||
edit: 'Kopfdaten ändern',
|
||||
reclassify: 'Klassifizierung ändern',
|
||||
headData: 'Kopfdaten',
|
||||
backlinks: 'Fälle mit dieser Person',
|
||||
backlinksEmpty: 'Diese Person kommt in keinem Fall vor, den du sehen darfst.',
|
||||
backlinksCount: (n: number) => (n === 1 ? '1 Fall' : `${n} Fälle`),
|
||||
linkCase: 'Fall verknüpfen',
|
||||
linkCaseHint: 'Aktenzeichen eintragen und die Rolle wählen, in der die Person darin vorkommt.',
|
||||
} as const
|
||||
|
||||
export const assignmentText = {
|
||||
heading: 'Zuständige der Abteilung',
|
||||
empty: 'Niemand zugeordnet.',
|
||||
add: 'Mitglied zuordnen',
|
||||
remove: 'Entfernen',
|
||||
hint:
|
||||
'Wer hier steht, liest die Personalakte unabhängig von seiner Freigabestufe.',
|
||||
roles: {
|
||||
HANDLER: 'Aktenführung',
|
||||
INVESTIGATOR: 'Ermittler',
|
||||
CONTACT: 'Ansprechpartner',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Rahmen der Anwendung: Name, Navigation, Anmeldung, Übersicht, Zustand.
|
||||
*
|
||||
* Nach Bereichen getrennt, weil eine **Client-Komponente die ganze Datei ins
|
||||
* Bündel zieht**, in der ihr Text steht. Lägen alle Texte in einer, trüge jede
|
||||
* Ansicht die der ganzen Anwendung mit sich herum — das Budget aus
|
||||
* docs/09-performance-und-ux.md wäre nach wenigen Phasen aufgebraucht.
|
||||
*
|
||||
* Serverkomponenten nehmen weiter `de` aus `lib/i18n/de.ts`: was dorthin
|
||||
* gezogen wird, verlässt den Server nicht.
|
||||
*/
|
||||
|
||||
export const appText = {
|
||||
name: 'AfiS-Aktensystem',
|
||||
shortName: 'AfiS',
|
||||
description: 'Fallakten, Personalakten und Ermittlungen der Abteilung für innere Sicherheit',
|
||||
} as const
|
||||
|
||||
export const navText = {
|
||||
dashboard: 'Übersicht',
|
||||
cases: 'Fallakten',
|
||||
personnel: 'Personalakten',
|
||||
search: 'Suche',
|
||||
admin: 'Verwaltung',
|
||||
skipToContent: 'Zum Inhalt springen',
|
||||
} as const
|
||||
|
||||
export const authText = {
|
||||
signIn: 'Mit Discord anmelden',
|
||||
signOut: 'Abmelden',
|
||||
signedInAs: 'Angemeldet als',
|
||||
intro:
|
||||
'Die Anmeldung läuft über Discord. Zugriff haben Mitglieder der Abteilung für innere Sicherheit.',
|
||||
reasons: {
|
||||
'keine-mitgliedschaft': {
|
||||
title: 'Du bist nicht auf dem AfiS-Discord',
|
||||
body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.',
|
||||
},
|
||||
'keine-afis-rolle': {
|
||||
title: 'Keine Berechtigung für das Aktensystem',
|
||||
body: 'Du bist auf dem Server, trägst aber keine Rolle der Abteilung. Wende dich an die Abteilungsleitung.',
|
||||
},
|
||||
'discord-nicht-erreichbar': {
|
||||
title: 'Discord antwortet nicht',
|
||||
body: 'Die Mitgliedschaft war nicht prüfbar. In einigen Minuten erneut versuchen.',
|
||||
},
|
||||
} as Record<string, { title: string; body: string }>,
|
||||
sessionEnded: {
|
||||
title: 'Sitzung beendet',
|
||||
body: 'Deine Rollen haben sich geändert. Melde dich neu an.',
|
||||
},
|
||||
error: {
|
||||
title: 'Anmeldung nicht möglich',
|
||||
body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.',
|
||||
},
|
||||
backToSignIn: 'Zurück zur Anmeldung',
|
||||
} as const
|
||||
|
||||
export const overviewText = {
|
||||
heading: 'Dein Zugang',
|
||||
intro: 'Was das System über dich weiss und was daraus folgt.',
|
||||
person: 'Person',
|
||||
discordId: 'Discord-Kennung',
|
||||
roles: 'Rollen auf dem Discord',
|
||||
clearance: 'Errechnete Freigabestufe',
|
||||
permissions: 'Rechte',
|
||||
noPermissions: 'Keine Rechte. Die Abteilungsleitung vergibt sie in der Rollenverwaltung.',
|
||||
noRoles: 'Keine Rollen.',
|
||||
bootstrapAdmin: 'Notfallzugang aus der Konfiguration, unabhängig vom Rollen-Mapping',
|
||||
unmapped: 'nicht zugeordnet',
|
||||
} as const
|
||||
|
||||
export const healthText = {
|
||||
heading: 'Systemzustand',
|
||||
service: 'Dienst',
|
||||
state: 'Zustand',
|
||||
checkedAt: (when: string) => `Zustand der Dienste, abgefragt ${when}`,
|
||||
ok: 'Betriebsbereit',
|
||||
degraded: 'Eingeschränkt',
|
||||
down: 'Gestört',
|
||||
components: {
|
||||
database: 'Datenbank',
|
||||
discordGateway: 'Discord-Gateway',
|
||||
clamav: 'Virenprüfung',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
@@ -1,20 +1,38 @@
|
||||
import { Client } from 'pg'
|
||||
import { invalidateAll, invalidateUser } from '@/lib/auth/effective'
|
||||
import { ATTACHMENT_CHANNEL } from '@/lib/realtime/publish'
|
||||
|
||||
/**
|
||||
* Ein einziger `pg`-Client hält `LISTEN` offen und verteilt im Prozess weiter.
|
||||
* Kein WebSocket, kein Redis: Postgres `LISTEN/NOTIFY` reicht bei dieser
|
||||
* Nutzerzahl vollständig. Siehe docs/06-chat-und-realtime.md.
|
||||
*
|
||||
* Phase 1 nutzt zwei Kanäle: `member_changed` verwirft die Rechte eines
|
||||
* Nutzers, `role_mapping_changed` verwirft alles. Dadurch ist eine
|
||||
* Rechteänderung in Discord in unter einer Sekunde wirksam, ohne dass ein
|
||||
* einziger Request auf die Discord-API wartet.
|
||||
* Drei Kanäle: `member_changed` verwirft die Rechte eines Nutzers,
|
||||
* `role_mapping_changed` verwirft alles. Dadurch ist eine Rechteänderung in
|
||||
* Discord in unter einer Sekunde wirksam, ohne dass ein einziger Request auf
|
||||
* die Discord-API wartet. `attachment_scanned` meldet das Ende einer
|
||||
* Dateiprüfung an die offenen Ansichten.
|
||||
*
|
||||
* Die Verbindung läuft an pgbouncer vorbei: im Transaktionsmodus überlebt ein
|
||||
* `LISTEN` den Pool nicht.
|
||||
*/
|
||||
const CHANNELS = ['member_changed', 'role_mapping_changed'] as const
|
||||
const CHANNELS = ['member_changed', 'role_mapping_changed', ATTACHMENT_CHANNEL] as const
|
||||
|
||||
/**
|
||||
* Wer im Prozess auf Ereignisse wartet. Ein `Set` von Rückrufen statt eines
|
||||
* EventEmitters, damit klar bleibt, dass hier nichts gepuffert wird: wer nicht
|
||||
* verbunden ist, verpasst das Ereignis und holt sich den Stand beim nächsten
|
||||
* Laden.
|
||||
*/
|
||||
type Subscriber = (event: { channel: string; payload: string }) => void
|
||||
|
||||
const globalForSubscribers = globalThis as unknown as { afisSubscribers?: Set<Subscriber> }
|
||||
const subscribers = (globalForSubscribers.afisSubscribers ??= new Set<Subscriber>())
|
||||
|
||||
export function subscribe(listener: Subscriber): () => void {
|
||||
subscribers.add(listener)
|
||||
return () => subscribers.delete(listener)
|
||||
}
|
||||
|
||||
type Listener = { client: Client; ready: Promise<void> }
|
||||
|
||||
@@ -33,6 +51,16 @@ function connect(): Listener {
|
||||
}
|
||||
if (message.channel === 'role_mapping_changed') {
|
||||
invalidateAll()
|
||||
return
|
||||
}
|
||||
if (message.payload) {
|
||||
for (const listener of subscribers) {
|
||||
try {
|
||||
listener({ channel: message.channel, payload: message.payload })
|
||||
} catch (error) {
|
||||
console.error('[realtime] Empfänger hat geworfen', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
/**
|
||||
* Ereignisse an alle offenen Ansichten schicken.
|
||||
*
|
||||
* Über `pg_notify`, nicht über einen prozessinternen Verteiler: dann stimmt es
|
||||
* auch, wenn später ein zweiter Web-Prozess dazukommt, und es braucht keinen
|
||||
* zusätzlichen Dienst. Siehe docs/06-chat-und-realtime.md.
|
||||
*/
|
||||
|
||||
export const ATTACHMENT_CHANNEL = 'attachment_scanned'
|
||||
|
||||
export async function notifyAttachmentScanned(attachmentId: string): Promise<void> {
|
||||
try {
|
||||
// Der Kanalname ist eine Konstante, nur die Nutzlast kommt von aussen —
|
||||
// und die ist eine Kennung aus der Datenbank.
|
||||
await db.$executeRaw`SELECT pg_notify(${ATTACHMENT_CHANNEL}, ${attachmentId})`
|
||||
} catch (error) {
|
||||
// Ein verlorenes Ereignis kostet eine Aktualisierung von Hand, mehr nicht.
|
||||
console.error('[realtime] Ereignis nicht zugestellt', error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user