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:
Harlekin
2026-09-02 20:27:22 +02:00
parent a278c44e78
commit 10eeea4897
62 changed files with 3564 additions and 857 deletions
@@ -1,7 +1,8 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { settingsText } from '@/lib/i18n/admin'
import { caseText } from '@/lib/i18n/cases'
import { savePolicy } from './actions'
const POLICIES = ['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN'] as const
@@ -24,7 +25,7 @@ export function PolicyForm({
const [pending, startTransition] = useTransition()
function submit() {
if (!confirm(de.settings.confirm)) return
if (!confirm(settingsText.confirm)) return
setError(null)
startTransition(async () => {
const result = await savePolicy({
@@ -40,7 +41,7 @@ export function PolicyForm({
return (
<div className="mt-6 space-y-6">
<fieldset>
<legend className="font-condensed text-xs text-text-dim">{de.settings.policy}</legend>
<legend className="font-condensed text-xs text-text-dim">{settingsText.policy}</legend>
<div className="mt-2 space-y-2">
{POLICIES.map((value) => (
<label key={value} className="flex items-start gap-2 text-sm">
@@ -55,14 +56,14 @@ export function PolicyForm({
}}
className="mt-1"
/>
<span>{de.settings.policies[value]}</span>
<span>{settingsText.policies[value]}</span>
</label>
))}
</div>
</fieldset>
<label className="block">
<span className="font-condensed text-xs text-text-dim">{de.settings.graceMinutes}</span>
<span className="font-condensed text-xs text-text-dim">{settingsText.graceMinutes}</span>
<input
type="number"
min={0}
@@ -86,7 +87,7 @@ export function PolicyForm({
setSaved(false)
}}
/>
<span>{de.settings.markGrace}</span>
<span>{settingsText.markGrace}</span>
</label>
<div className="flex items-center gap-3">
@@ -96,10 +97,10 @@ export function PolicyForm({
disabled={pending}
className="border border-line bg-raised px-4 py-2 font-condensed text-xs text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.settings.save}
{pending ? caseText.saving : settingsText.save}
</button>
{saved ? (
<span className="font-condensed text-2xs text-text-dim">{de.settings.saved}</span>
<span className="font-condensed text-2xs text-text-dim">{settingsText.saved}</span>
) : null}
{error ? <span className="font-condensed text-2xs text-stamp-4">{error}</span> : null}
</div>
+11 -11
View File
@@ -1,7 +1,7 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { roleAdminText } from '@/lib/i18n/admin'
import { OPT_IN_PERMISSIONS, PERMISSIONS, type Permission } from '@/lib/auth/permissions'
import { saveRoleMapping } from './actions'
@@ -52,7 +52,7 @@ export function RoleRow({
}
function submit() {
if (!confirm(de.roleAdmin.confirm)) return
if (!confirm(roleAdminText.confirm)) return
setError(null)
startTransition(async () => {
const result = await saveRoleMapping({
@@ -77,14 +77,14 @@ export function RoleRow({
<span className="font-mono text-2xs text-text-dim">{role.discordRoleId}</span>
{!role.isPresent ? (
<span className="font-condensed text-2xs text-stamp-4">
{de.roleAdmin.missingFromGuild}
{roleAdminText.missingFromGuild}
</span>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-x-8 gap-y-3 text-xs">
<label className="flex items-center gap-2">
<span className="font-condensed text-text-dim">{de.roleAdmin.category}</span>
<span className="font-condensed text-text-dim">{roleAdminText.category}</span>
<select
value={category}
onChange={(event) => {
@@ -95,14 +95,14 @@ export function RoleRow({
>
{CATEGORIES.map((value) => (
<option key={value} value={value}>
{de.roleAdmin.categories[value]}
{roleAdminText.categories[value]}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="font-condensed text-text-dim">{de.roleAdmin.clearance}</span>
<span className="font-condensed text-text-dim">{roleAdminText.clearance}</span>
<select
value={clearanceLevel}
onChange={(event) => {
@@ -128,12 +128,12 @@ export function RoleRow({
setSaved(false)
}}
/>
<span className="font-condensed text-text-dim">{de.roleAdmin.grantsMembership}</span>
<span className="font-condensed text-text-dim">{roleAdminText.grantsMembership}</span>
</label>
</div>
<fieldset className="mt-4">
<legend className="text-xs text-text-dim">{de.roleAdmin.permissions}</legend>
<legend className="text-xs text-text-dim">{roleAdminText.permissions}</legend>
<div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-x-4 gap-y-1.5">
{PERMISSIONS.map((permission) => {
const optIn = OPT_IN_PERMISSIONS.includes(permission)
@@ -141,7 +141,7 @@ export function RoleRow({
<label
key={permission}
className="flex items-center gap-2 font-mono text-2xs"
title={optIn ? de.roleAdmin.optIn : undefined}
title={optIn ? roleAdminText.optIn : undefined}
>
<input
type="checkbox"
@@ -162,10 +162,10 @@ export function RoleRow({
disabled={pending || !dirty}
className="border border-line bg-raised px-3 py-1.5 font-condensed text-xs text-accent disabled:text-text-dim"
>
{pending ? de.roleAdmin.saving : de.roleAdmin.save}
{pending ? roleAdminText.saving : roleAdminText.save}
</button>
{saved && !dirty ? (
<span className="font-condensed text-2xs text-text-dim">{de.roleAdmin.saved}</span>
<span className="font-condensed text-2xs text-text-dim">{roleAdminText.saved}</span>
) : null}
{error ? <span className="font-condensed text-2xs text-stamp-4">{error}</span> : null}
</div>
+18 -17
View File
@@ -1,7 +1,8 @@
'use client'
import { useMemo, useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { templateAdminText } from '@/lib/i18n/admin'
import { caseText, sectionText } from '@/lib/i18n/cases'
import { isInputField, looksLikeFields, type TemplateField } from '@/lib/templates/fields'
import { saveTemplate } from './actions'
@@ -41,11 +42,11 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
// Nur eine grobe Formprüfung für die Vorschau. Ob die Vorlage
// gespeichert werden darf, entscheidet allein der Server.
if (!looksLikeFields(roh)) {
return { ok: false as const, fehler: de.templateAdmin.fieldsInvalid }
return { ok: false as const, fehler: templateAdminText.fieldsInvalid }
}
return { ok: true as const, fields: roh }
} catch {
return { ok: false as const, fehler: de.templateAdmin.invalid }
return { ok: false as const, fehler: templateAdminText.invalid }
}
}, [json])
@@ -62,7 +63,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
numberPadding,
fields: geparst.fields,
})
if (antwort.ok) setResult(de.templateAdmin.saved(antwort.version))
if (antwort.ok) setResult(templateAdminText.saved(antwort.version))
else setError(antwort.error)
})
}
@@ -73,14 +74,14 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
<h2 className="font-condensed">{vorlage.name}</h2>
<span className="font-mono text-2xs text-text-dim">{vorlage.key}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.templateAdmin.version(vorlage.version)}
{templateAdminText.version(vorlage.version)}
</span>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="space-y-3 text-xs">
<label className="block">
<span className="font-condensed text-text-dim">{de.case.title}</span>
<span className="font-condensed text-text-dim">{caseText.title}</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
@@ -89,7 +90,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
</label>
<label className="block">
<span className="font-condensed text-text-dim">{de.case.type}</span>
<span className="font-condensed text-text-dim">{caseText.type}</span>
<input
value={description}
onChange={(event) => setDescription(event.target.value)}
@@ -99,7 +100,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
<div className="flex gap-3">
<label className="block">
<span className="font-condensed text-text-dim">{de.templateAdmin.numberPrefix}</span>
<span className="font-condensed text-text-dim">{templateAdminText.numberPrefix}</span>
<input
value={numberPrefix}
onChange={(event) => setNumberPrefix(event.target.value)}
@@ -107,7 +108,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
/>
</label>
<label className="block">
<span className="font-condensed text-text-dim">{de.templateAdmin.numberPadding}</span>
<span className="font-condensed text-text-dim">{templateAdminText.numberPadding}</span>
<input
type="number"
min={1}
@@ -120,7 +121,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
</div>
<label className="block">
<span className="font-condensed text-text-dim">{de.templateAdmin.fieldsLabel}</span>
<span className="font-condensed text-text-dim">{templateAdminText.fieldsLabel}</span>
<textarea
value={json}
onChange={(event) => setJson(event.target.value)}
@@ -128,7 +129,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
spellCheck={false}
className="mt-1 block w-full border border-line bg-raised px-2 py-1 font-mono text-2xs"
/>
<span className="mt-1 block text-2xs text-text-dim">{de.templateAdmin.fieldsHelp}</span>
<span className="mt-1 block text-2xs text-text-dim">{templateAdminText.fieldsHelp}</span>
</label>
<div className="flex items-center gap-3">
@@ -138,7 +139,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
disabled={pending || !geparst.ok}
className="border border-line bg-raised px-3 py-1.5 font-condensed text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.templateAdmin.save}
{pending ? caseText.saving : templateAdminText.save}
</button>
{result ? <span className="font-condensed text-2xs text-text-dim">{result}</span> : null}
{!geparst.ok ? (
@@ -149,8 +150,8 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
</div>
<div className="border border-line p-4">
<p className="font-condensed text-xs text-text-dim">{de.templateAdmin.preview}</p>
<p className="mt-1 text-2xs text-text-dim">{de.templateAdmin.previewIntro}</p>
<p className="font-condensed text-xs text-text-dim">{templateAdminText.preview}</p>
<p className="mt-1 text-2xs text-text-dim">{templateAdminText.previewIntro}</p>
<div className="mt-4">
{geparst.ok ? <Vorschau fields={geparst.fields} /> : null}
</div>
@@ -162,7 +163,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
/** „So sieht eine neue Akte damit aus", ohne etwas zu speichern. */
function Vorschau({ fields }: { fields: TemplateField[] }) {
if (fields.length === 0) return <p className="text-2xs text-text-dim">{de.section.empty}</p>
if (fields.length === 0) return <p className="text-2xs text-text-dim">{sectionText.empty}</p>
return (
<dl className="space-y-4">
@@ -176,14 +177,14 @@ function Vorschau({ fields }: { fields: TemplateField[] }) {
) : null}
{isInputField(field) && field.requiredToClose ? (
<span className="font-condensed text-2xs text-stamp-3">
{de.section.requiredToClose}
{sectionText.requiredToClose}
</span>
) : null}
</dt>
<dd className="mt-1">
{!isInputField(field) ? (
<p className="text-2xs text-text-dim italic">
{field.text ? field.text.slice(0, 120) : de.templateAdmin.staticBlock}
{field.text ? field.text.slice(0, 120) : templateAdminText.staticBlock}
</p>
) : field.type === 'longtext' ? (
<div className="h-12 border border-line bg-raised" />
+38
View File
@@ -0,0 +1,38 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/errors'
import { releaseAttachment } from '@/lib/files/service'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
const schema = z.object({ id: z.string().min(1) })
/**
* Freigabe einer Datei, deren Prüfung nicht abgeschlossen werden konnte.
* Nur mit Recht `admin`, geprüft im Dienst — nicht hier.
*/
export async function releaseAttachmentAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = schema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await releaseAttachment(user.permissions, parsed.data.id)
revalidatePath('/faelle')
return { ok: true }
} catch (error) {
if (error instanceof AccessError) {
return {
ok: false,
error:
de.attachment.errors[error.code] ?? de.actionErrors[error.code] ?? de.actionErrors.default!,
}
}
console.error('[dateien] Freigabe fehlgeschlagen', error)
return { ok: false, error: de.actionErrors.default! }
}
}
@@ -1,7 +1,9 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { caseText } from '@/lib/i18n/cases'
import { actionErrorsText, clearanceText, commonText } from '@/lib/i18n/common'
import { documentText } from '@/lib/i18n/documents'
import {
finalizeDocumentAction,
reclassifyDocumentAction,
@@ -45,7 +47,7 @@ export function DocumentActions({
setError(null)
startTransition(async () => {
const result = await action()
if (!result.ok) setError(result.error ?? de.actionErrors.default!)
if (!result.ok) setError(result.error ?? actionErrorsText.default!)
else setRetracting(false)
})
}
@@ -58,24 +60,24 @@ export function DocumentActions({
type="button"
disabled={pending}
onClick={() => {
if (!confirm(de.document.finalizeHint)) return
if (!confirm(documentText.finalizeHint)) return
run(() => finalizeDocumentAction({ documentNumber }))
}}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
>
{de.document.finalize}
{documentText.finalize}
</button>
</div>
) : null}
{canReclassify ? (
<div>
<p className="font-condensed text-text-dim">{de.document.reclassify}</p>
<p className="font-condensed text-text-dim">{documentText.reclassify}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<select
value={nextLevel}
onChange={(event) => setNextLevel(Number(event.target.value))}
aria-label={de.clearance.classification}
aria-label={clearanceText.classification}
className="border border-line bg-raised px-2 py-1"
>
{levels.map((level) => (
@@ -98,7 +100,7 @@ export function DocumentActions({
}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
>
{de.common.save}
{commonText.save}
</button>
</div>
{lowering ? (
@@ -106,8 +108,8 @@ export function DocumentActions({
value={levelReason}
onChange={(event) => setLevelReason(event.target.value)}
rows={2}
placeholder={de.case.reclassifyReason}
aria-label={de.case.reclassifyReason}
placeholder={caseText.reclassifyReason}
aria-label={caseText.reclassifyReason}
className="mt-2 block w-full max-w-sm border border-line bg-raised px-3 py-2 text-2xs"
/>
) : null}
@@ -122,8 +124,8 @@ export function DocumentActions({
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={2}
placeholder={de.document.retractReason}
aria-label={de.document.retractReason}
placeholder={documentText.retractReason}
aria-label={documentText.retractReason}
className="block w-full max-w-sm border border-line bg-raised px-3 py-2 text-2xs"
/>
<div className="mt-2 flex gap-3">
@@ -133,14 +135,14 @@ export function DocumentActions({
onClick={() => run(() => retractDocumentAction({ documentNumber, reason }))}
className="border border-line bg-raised px-3 py-1 font-condensed text-stamp-4 disabled:text-text-dim"
>
{de.document.retract}
{documentText.retract}
</button>
<button
type="button"
onClick={() => setRetracting(false)}
className="font-condensed text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
</div>
@@ -150,7 +152,7 @@ export function DocumentActions({
onClick={() => setRetracting(true)}
className="font-condensed text-text-dim"
>
{de.document.retract}
{documentText.retract}
</button>
)}
</div>
@@ -19,6 +19,9 @@ import { Sheet } from '@/components/documents/Sheet'
import { SheetSwitch } from '@/components/documents/SheetSwitch'
import { saveDocumentSectionAction } from '../actions'
import { DocumentActions } from './DocumentActions'
import { Attachments } from '@/components/files/Attachments'
import { listAttachments, resolvePhotoUrls } from '@/lib/files/service'
import { toAttachmentViews } from '@/lib/files/view'
import type { EffectivePermissions } from '@/lib/auth/permissions'
export const dynamic = 'force-dynamic'
@@ -49,12 +52,24 @@ async function Body({
canEdit: boolean
}) {
const sections = await loadDocumentSections(permissions, document)
const photoUrls = await resolvePhotoUrls(
permissions,
sections.map((section) => ({
fieldKey: section.fieldKey,
visible: section.visible,
value: section.visible ? section.value : null,
type: section.field.type,
})),
)
const editor = (
<Sections
scope={`dokument:${document.documentNumber}`}
save={saveDocumentSectionAction.bind(null, document.documentNumber)}
sections={sections}
canEdit={canEdit}
documentNumber={document.documentNumber}
photoUrls={photoUrls}
/>
)
@@ -83,12 +98,35 @@ async function Body({
issuedAt={document.createdAt}
foundationLevels={foundationLevels}
footerNote={de.document.footerNote}
photoUrls={photoUrls}
/>
}
/>
)
}
async function AttachmentBlock({
documentId,
documentNumber,
permissions,
canUpload,
}: {
documentId: string
documentNumber: string
permissions: EffectivePermissions
canUpload: boolean
}) {
const anhaenge = await listAttachments(permissions, { documentId })
return (
<Attachments
target={{ documentNumber }}
initial={toAttachmentViews(anhaenge)}
canUpload={canUpload}
canRelease={permissions.isAdmin}
/>
)
}
/**
* Ein Dokument.
*
@@ -228,6 +266,20 @@ export default async function DocumentPage({
<Body document={doc} permissions={user.permissions} canEdit={canEdit} />
</Suspense>
</section>
<section className="no-print mt-10">
<h2 className="font-condensed">{de.attachment.heading}</h2>
<div className="mt-4">
<Suspense fallback={<SkeletonLine width="50%" />}>
<AttachmentBlock
documentId={doc.id}
documentNumber={doc.documentNumber}
permissions={user.permissions}
canUpload={canEdit}
/>
</Suspense>
</div>
</section>
</main>
)
}
@@ -1,7 +1,8 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { caseText, entryText } from '@/lib/i18n/cases'
import { actionErrorsText, commonText } from '@/lib/i18n/common'
import { changeStatusAction, reclassifyAction } from '../actions'
/**
@@ -38,7 +39,7 @@ export function CaseActions({
setError(null)
startTransition(async () => {
const result = await action()
if (!result.ok) setError(result.error ?? de.actionErrors.default!)
if (!result.ok) setError(result.error ?? actionErrorsText.default!)
})
}
@@ -46,7 +47,7 @@ export function CaseActions({
<div className="mt-6 flex flex-wrap gap-x-10 gap-y-5 border-t border-line pt-5 text-xs">
{statusTargets.length > 0 ? (
<div>
<p className="font-condensed text-text-dim">{de.case.statusChange}</p>
<p className="font-condensed text-text-dim">{caseText.statusChange}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<select
value={nextStatus}
@@ -55,14 +56,14 @@ export function CaseActions({
>
{statusTargets.map((target) => (
<option key={target} value={target}>
{de.case.statusNames[target] ?? target}
{caseText.statusNames[target] ?? target}
</option>
))}
</select>
<input
value={statusReason}
onChange={(event) => setStatusReason(event.target.value)}
placeholder={de.entry.changeReason}
placeholder={entryText.changeReason}
className="w-56 border border-line bg-raised px-2 py-1"
/>
<button
@@ -76,19 +77,19 @@ export function CaseActions({
to: nextStatus,
reason: statusReason || undefined,
}),
`${de.case.statusChange}: ${de.case.statusNames[nextStatus] ?? nextStatus}?`,
`${caseText.statusChange}: ${caseText.statusNames[nextStatus] ?? nextStatus}?`,
)
}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.common.save}
{pending ? caseText.saving : commonText.save}
</button>
</div>
</div>
) : null}
<div>
<p className="font-condensed text-text-dim">{de.case.reclassify}</p>
<p className="font-condensed text-text-dim">{caseText.reclassify}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<select
value={nextLevel}
@@ -109,7 +110,7 @@ export function CaseActions({
<input
value={levelReason}
onChange={(event) => setLevelReason(event.target.value)}
placeholder={de.case.reclassifyReason}
placeholder={caseText.reclassifyReason}
className="w-64 border border-line bg-raised px-2 py-1"
/>
) : null}
@@ -124,12 +125,12 @@ export function CaseActions({
to: nextLevel,
reason: levelReason || undefined,
}),
`${de.case.reclassify}: ${nextLevel}?`,
`${caseText.reclassify}: ${nextLevel}?`,
)
}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.common.save}
{pending ? caseText.saving : commonText.save}
</button>
</div>
</div>
+12 -11
View File
@@ -2,7 +2,8 @@
import { useState, useTransition } from 'react'
import Link from 'next/link'
import { de } from '@/lib/i18n/de'
import { linkText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common'
import { LINK_KINDS } from '@/lib/cases/terms'
import { linkCasesAction, unlinkCasesAction } from '../actions'
import { classificationEdgeStyle } from '@/components/StampMark'
@@ -37,7 +38,7 @@ export function CaseLinks({
return (
<div>
{links.length === 0 ? (
<p className="text-text-dim">{de.link.empty}</p>
<p className="text-text-dim">{linkText.empty}</p>
) : (
<ul className="border-t border-line">
{links.map((link) => (
@@ -47,7 +48,7 @@ export function CaseLinks({
style={classificationEdgeStyle(link.other.classification)}
>
<span className="font-condensed text-2xs text-text-dim">
{(link.direction === 'in' ? de.link.incoming : de.link.kinds)[link.kind] ??
{(link.direction === 'in' ? linkText.incoming : linkText.kinds)[link.kind] ??
link.kind}
</span>
<Link
@@ -74,7 +75,7 @@ export function CaseLinks({
}
className="font-condensed text-2xs text-text-dim"
>
{de.link.remove}
{linkText.remove}
</button>
) : null}
</li>
@@ -90,20 +91,20 @@ export function CaseLinks({
<select
value={kind}
onChange={(event) => setKind(event.target.value)}
aria-label={de.link.kind}
aria-label={linkText.kind}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{LINK_KINDS.map((value) => (
<option key={value} value={value}>
{de.link.kinds[value] ?? value}
{linkText.kinds[value] ?? value}
</option>
))}
</select>
<input
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={de.link.target}
aria-label={de.link.target}
placeholder={linkText.target}
aria-label={linkText.target}
className="border border-line bg-raised px-2 py-1 font-mono text-2xs"
/>
<button
@@ -127,14 +128,14 @@ export function CaseLinks({
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{de.link.add}
{linkText.add}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
) : (
@@ -143,7 +144,7 @@ export function CaseLinks({
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.link.add}
{linkText.add}
</button>
)
) : null}
@@ -1,7 +1,8 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { caseText, entryText } from '@/lib/i18n/cases'
import { actionErrorsText } from '@/lib/i18n/common'
import { editEntryAction, restoreEntryAction, retractEntryAction } from '../actions'
/**
@@ -39,7 +40,7 @@ export function EntryActions({
setMode('idle')
setReason('')
} else {
setError(result.error ?? de.actionErrors.default!)
setError(result.error ?? actionErrorsText.default!)
}
})
}
@@ -51,7 +52,7 @@ export function EntryActions({
<input
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={de.entry.retractReason}
placeholder={entryText.retractReason}
className="w-64 border border-line bg-raised px-2 py-1"
/>
<button
@@ -60,7 +61,7 @@ export function EntryActions({
onClick={() => run(() => restoreEntryAction({ entryId, caseNumber, reason }))}
className="font-condensed text-accent disabled:text-text-dim"
>
{de.entry.restore}
{entryText.restore}
</button>
{error ? <span className="text-stamp-4">{error}</span> : null}
</div>
@@ -73,7 +74,7 @@ export function EntryActions({
<div className="mt-1.5 flex gap-3 text-2xs">
{canEdit ? (
<button type="button" onClick={() => setMode('edit')} className="font-condensed text-accent">
{de.entry.edit}
{entryText.edit}
</button>
) : null}
{canRetract ? (
@@ -82,7 +83,7 @@ export function EntryActions({
onClick={() => setMode('retract')}
className="font-condensed text-accent"
>
{de.entry.retract}
{entryText.retract}
</button>
) : null}
</div>
@@ -103,7 +104,7 @@ export function EntryActions({
<input
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={mode === 'edit' ? de.entry.changeReason : de.entry.retractReason}
placeholder={mode === 'edit' ? entryText.changeReason : entryText.retractReason}
className="mt-2 block w-full border border-line bg-raised px-2 py-1"
/>
@@ -120,7 +121,7 @@ export function EntryActions({
}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : mode === 'edit' ? de.entry.editSave : de.entry.retract}
{pending ? caseText.saving : mode === 'edit' ? entryText.editSave : entryText.retract}
</button>
<button
type="button"
@@ -132,7 +133,7 @@ export function EntryActions({
}}
className="font-condensed text-text-dim"
>
{de.entry.cancel}
{entryText.cancel}
</button>
{error ? <span className="text-stamp-4">{error}</span> : null}
</div>
@@ -2,7 +2,7 @@
import { useOptimistic, useRef, useState, useTransition } from 'react'
import { uuidv7 } from 'uuidv7'
import { de } from '@/lib/i18n/de'
import { caseText, entryText } from '@/lib/i18n/cases'
import { addEntryAction } from '../actions'
type Pending = { id: string; body: string; failed: boolean }
@@ -52,7 +52,7 @@ export function EntryComposer({ caseNumber }: { caseNumber: string }) {
className="max-w-(--container-measure)"
>
<label htmlFor="vermerk" className="font-condensed text-xs text-text-dim">
{de.case.newEntry}
{caseText.newEntry}
</label>
<textarea
id="vermerk"
@@ -66,7 +66,7 @@ export function EntryComposer({ caseNumber }: { caseNumber: string }) {
}
}}
rows={3}
placeholder={de.case.entryPlaceholder}
placeholder={caseText.entryPlaceholder}
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
<button
@@ -74,7 +74,7 @@ export function EntryComposer({ caseNumber }: { caseNumber: string }) {
disabled={pending || draft.trim().length === 0}
className="mt-2 border border-line bg-raised px-3 py-1.5 font-condensed text-xs text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.entry.create}
{pending ? caseText.saving : entryText.create}
</button>
</form>
@@ -92,7 +92,7 @@ export function EntryComposer({ caseNumber }: { caseNumber: string }) {
{entry.body}
</p>
{entry.failed ? (
<p className="mt-1 font-condensed text-2xs text-stamp-4">{de.case.unsaved}</p>
<p className="mt-1 font-condensed text-2xs text-stamp-4">{caseText.unsaved}</p>
) : null}
</li>
))}
@@ -2,7 +2,8 @@
import { useState, useTransition } from 'react'
import Link from 'next/link'
import { de } from '@/lib/i18n/de'
import { participantText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { addParticipantAction, removeParticipantAction } from '../actions'
@@ -62,7 +63,7 @@ export function Participants({
return (
<div>
{participants.length === 0 ? (
<p className="text-text-dim">{de.participant.empty}</p>
<p className="text-text-dim">{participantText.empty}</p>
) : (
<ul className="border-t border-line">
{participants.map((person) => (
@@ -71,15 +72,15 @@ export function Participants({
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line py-2"
>
<span className="font-condensed text-2xs text-text-dim">
{de.participant.roles[person.role] ?? person.role}
{participantText.roles[person.role] ?? person.role}
</span>
<span className="grow text-sm">
{person.personnelFileId ? (
<Link href={`/personalakten/${person.personnelFileId}`}>
{person.displayName ?? de.common.unknown}
{person.displayName ?? commonText.unknown}
</Link>
) : (
(person.displayName ?? de.common.unknown)
(person.displayName ?? commonText.unknown)
)}
</span>
{canEdit ? (
@@ -97,7 +98,7 @@ export function Participants({
}
className="font-condensed text-2xs text-text-dim"
>
{de.participant.remove}
{participantText.remove}
</button>
) : null}
</li>
@@ -111,36 +112,36 @@ export function Participants({
open ? (
<div className="mt-4 border border-line p-3">
<p className="max-w-(--container-measure) text-2xs text-text-dim">
{de.participant.hint}
{participantText.hint}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(event) => setRole(event.target.value)}
aria-label={de.participant.role}
aria-label={participantText.role}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{PARTICIPANT_ROLES.map((value) => (
<option key={value} value={value}>
{de.participant.roles[value] ?? value}
{participantText.roles[value] ?? value}
</option>
))}
</select>
<select
value={target}
onChange={(event) => setTarget(event.target.value)}
aria-label={de.participant.person}
aria-label={participantText.person}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
<option value="">—</option>
<optgroup label={de.participant.pickUser}>
<optgroup label={participantText.pickUser}>
{members.map((member) => (
<option key={member.id} value={`user:${member.id}`}>
{member.displayName}
</option>
))}
</optgroup>
<optgroup label={de.participant.pickPersonnel}>
<optgroup label={participantText.pickPersonnel}>
{files.map((file) => (
<option key={file.id} value={`file:${file.id}`}>
{file.displayName}
@@ -154,14 +155,14 @@ export function Participants({
onClick={add}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{de.participant.add}
{participantText.add}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
</div>
@@ -171,7 +172,7 @@ export function Participants({
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.participant.add}
{participantText.add}
</button>
)
) : null}
+18 -1
View File
@@ -24,6 +24,9 @@ import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { DocumentList } from '@/components/documents/DocumentList'
import { NewDocument } from '@/components/documents/NewDocument'
import { Attachments } from '@/components/files/Attachments'
import { listAttachments } from '@/lib/files/service'
import { toAttachmentViews } from '@/lib/files/view'
import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
import { evaluateEntryEdit, canRetract } from '@/lib/cases/entryPolicy'
import Link from 'next/link'
@@ -155,10 +158,12 @@ async function Relations({
permissions: EffectivePermissions
canEdit: boolean
}) {
const [participants, links, documents, templates, levels, members, files] = await Promise.all([
const [participants, links, documents, anhaenge, templates, levels, members, files] =
await Promise.all([
listParticipants(permissions, akte.id),
listLinks(permissions, akte.id),
listDocuments(permissions, { caseId: akte.id }),
listAttachments(permissions, { caseId: akte.id }),
canEdit ? listTemplatesFor('CASE_DOCUMENT') : Promise.resolve([]),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
canEdit
@@ -204,6 +209,18 @@ async function Relations({
</div>
</section>
<section className="md:col-span-2">
<h2 className="font-condensed">{de.attachment.heading}</h2>
<div className="mt-4">
<Attachments
target={{ caseNumber: akte.caseNumber }}
initial={toAttachmentViews(anhaenge)}
canUpload={canEdit}
canRelease={permissions.isAdmin}
/>
</div>
</section>
<section className="md:col-span-2">
<h2 className="font-condensed">{de.document.heading}</h2>
<div className="mt-4 space-y-4">
+8 -7
View File
@@ -1,7 +1,8 @@
'use client'
import { useActionState } from 'react'
import { de } from '@/lib/i18n/de'
import { caseText } from '@/lib/i18n/cases'
import { clearanceText } from '@/lib/i18n/common'
import { createCaseAction, type CreateState } from '../actions'
export function NewCaseForm({
@@ -21,7 +22,7 @@ export function NewCaseForm({
<form action={action} className="mt-6 space-y-5">
<div>
<label htmlFor="title" className="font-condensed text-xs text-text-dim">
{de.case.title}
{caseText.title}
</label>
<input
id="title"
@@ -35,7 +36,7 @@ export function NewCaseForm({
<div>
<label htmlFor="templateKey" className="font-condensed text-xs text-text-dim">
{de.case.type}
{caseText.type}
</label>
<select
id="templateKey"
@@ -53,7 +54,7 @@ export function NewCaseForm({
<div>
<label htmlFor="classification" className="font-condensed text-xs text-text-dim">
{de.clearance.classification}
{clearanceText.classification}
</label>
{/* Die Auswahl zeigt nur, was erlaubt ist. Geprüft wird trotzdem serverseitig. */}
<select
@@ -73,7 +74,7 @@ export function NewCaseForm({
{people.length > 0 ? (
<div>
<label htmlFor="personnelFileId" className="font-condensed text-xs text-text-dim">
{de.case.subject}
{caseText.subject}
</label>
{/* Gleich beim Anlegen verknüpfen erspart den zweiten Schritt, den
man sonst vergisst. Nachträglich geht es genauso. */}
@@ -83,7 +84,7 @@ export function NewCaseForm({
defaultValue=""
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
>
<option value="">{de.case.subjectNone}</option>
<option value="">{caseText.subjectNone}</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.displayName}
@@ -99,7 +100,7 @@ export function NewCaseForm({
disabled={pending}
className="border border-line bg-raised px-4 py-2 font-condensed text-xs text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.case.create}
{pending ? caseText.saving : caseText.create}
</button>
{state.error ? (
<span className="font-condensed text-2xs text-stamp-4">{state.error}</span>
@@ -1,7 +1,9 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { participantText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common'
import { assignmentText } from '@/lib/i18n/people'
import { ASSIGNMENT_ROLES } from '@/lib/personnel/terms'
import { addAssignmentAction, removeAssignmentAction } from '../actions'
@@ -37,7 +39,7 @@ export function Assignments({
return (
<div>
{assignments.length === 0 ? (
<p className="text-text-dim">{de.assignment.empty}</p>
<p className="text-text-dim">{assignmentText.empty}</p>
) : (
<ul className="border-t border-line">
{assignments.map((zuordnung) => (
@@ -46,7 +48,7 @@ export function Assignments({
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line py-2"
>
<span className="font-condensed text-2xs text-text-dim">
{de.assignment.roles[zuordnung.role] ?? zuordnung.role}
{assignmentText.roles[zuordnung.role] ?? zuordnung.role}
</span>
<span className="grow text-sm">{zuordnung.user.displayName}</span>
{canEdit ? (
@@ -64,7 +66,7 @@ export function Assignments({
}
className="font-condensed text-2xs text-text-dim"
>
{de.assignment.remove}
{assignmentText.remove}
</button>
) : null}
</li>
@@ -78,25 +80,25 @@ export function Assignments({
open ? (
<div className="mt-4 border border-line p-3">
<p className="max-w-(--container-measure) text-2xs text-text-dim">
{de.assignment.hint}
{assignmentText.hint}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(event) => setRole(event.target.value)}
aria-label={de.participant.role}
aria-label={participantText.role}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{ASSIGNMENT_ROLES.map((value) => (
<option key={value} value={value}>
{de.assignment.roles[value] ?? value}
{assignmentText.roles[value] ?? value}
</option>
))}
</select>
<select
value={userId}
onChange={(event) => setUserId(event.target.value)}
aria-label={de.participant.pickUser}
aria-label={participantText.pickUser}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
<option value="">—</option>
@@ -123,14 +125,14 @@ export function Assignments({
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{de.assignment.add}
{assignmentText.add}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
</div>
@@ -140,7 +142,7 @@ export function Assignments({
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.assignment.add}
{assignmentText.add}
</button>
)
) : null}
+11 -9
View File
@@ -1,7 +1,9 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { linkText, participantText } from '@/lib/i18n/cases'
import { commonText } from '@/lib/i18n/common'
import { personnelText } from '@/lib/i18n/people'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { linkCaseToPersonAction } from '../actions'
@@ -27,7 +29,7 @@ export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.personnel.linkCase}
{personnelText.linkCase}
</button>
)
}
@@ -35,26 +37,26 @@ export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
return (
<div className="mt-4 border border-line p-3">
<p className="max-w-(--container-measure) text-2xs text-text-dim">
{de.personnel.linkCaseHint}
{personnelText.linkCaseHint}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(event) => setRole(event.target.value)}
aria-label={de.participant.role}
aria-label={participantText.role}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{PARTICIPANT_ROLES.map((value) => (
<option key={value} value={value}>
{de.participant.roles[value] ?? value}
{participantText.roles[value] ?? value}
</option>
))}
</select>
<input
value={caseNumber}
onChange={(event) => setCaseNumber(event.target.value)}
placeholder={de.link.target}
aria-label={de.link.target}
placeholder={linkText.target}
aria-label={linkText.target}
className="border border-line bg-raised px-2 py-1 font-mono text-2xs"
/>
<button
@@ -78,14 +80,14 @@ export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{de.personnel.linkCase}
{personnelText.linkCase}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null}
@@ -1,7 +1,9 @@
'use client'
import { useActionState, useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { caseText } from '@/lib/i18n/cases'
import { clearanceText, commonText } from '@/lib/i18n/common'
import { personnelText } from '@/lib/i18n/people'
import { StatusTags } from '@/components/StatusTags'
import {
reclassifyPersonnelAction,
@@ -48,21 +50,21 @@ export function PersonnelHead({
return (
<div>
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
<Zeile label={de.personnel.rank} value={file.rank} />
<Zeile label={de.personnel.department} value={file.department} />
<Zeile label={personnelText.rank} value={file.rank} />
<Zeile label={personnelText.department} value={file.department} />
<Zeile
label={de.personnel.linkedAccount}
value={file.userId ? file.userId : de.personnel.noAccount}
label={personnelText.linkedAccount}
value={file.userId ? file.userId : personnelText.noAccount}
mono={Boolean(file.userId)}
/>
</dl>
<div className="mt-3 flex flex-wrap items-baseline gap-3">
<span className="font-condensed text-xs text-text-dim">{de.personnel.status}</span>
<span className="font-condensed text-xs text-text-dim">{personnelText.status}</span>
{file.statusTags.length > 0 ? (
<StatusTags tags={file.statusTags} known={statusTags} />
) : (
<span className="text-sm text-text-dim">{de.personnel.statusNone}</span>
<span className="text-sm text-text-dim">{personnelText.statusNone}</span>
)}
</div>
@@ -79,7 +81,7 @@ export function PersonnelHead({
onClick={() => setEditing(true)}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.personnel.edit}
{personnelText.edit}
</button>
<Reclassify id={file.id} current={file.classification} levels={levels} />
</div>
@@ -95,14 +97,14 @@ export function PersonnelHead({
className="max-w-(--container-measure) space-y-4"
>
<input type="hidden" name="id" value={file.id} />
<Feld id="displayName" label={de.personnel.name} defaultValue={file.displayName} required />
<Feld id="displayName" label={personnelText.name} defaultValue={file.displayName} required />
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="rank" label={de.personnel.rank} defaultValue={file.rank} />
<Feld id="department" label={de.personnel.department} defaultValue={file.department} />
<Feld id="rank" label={personnelText.rank} defaultValue={file.rank} />
<Feld id="department" label={personnelText.department} defaultValue={file.department} />
</div>
<fieldset>
<legend className="font-condensed text-2xs text-text-dim">{de.personnel.status}</legend>
<legend className="font-condensed text-2xs text-text-dim">{personnelText.status}</legend>
<div className="mt-2 flex flex-wrap gap-x-6 gap-y-2">
{statusTags.map((tag) => (
<label key={tag.key} className="flex items-center gap-2 text-sm">
@@ -116,11 +118,11 @@ export function PersonnelHead({
</label>
))}
</div>
<p className="mt-1 text-2xs text-text-dim">{de.personnel.statusHint}</p>
<p className="mt-1 text-2xs text-text-dim">{personnelText.statusHint}</p>
</fieldset>
<div>
<label htmlFor="note" className="font-condensed text-2xs text-text-dim">
{de.personnel.note}
{personnelText.note}
</label>
<textarea
id="note"
@@ -141,14 +143,14 @@ export function PersonnelHead({
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{pending ? de.common.loading : de.common.save}
{pending ? commonText.loading : commonText.save}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
</form>
@@ -179,7 +181,7 @@ function Reclassify({
onClick={() => setOpen(true)}
className="font-condensed text-2xs text-text-dim"
>
{de.personnel.reclassify}
{personnelText.reclassify}
</button>
)
}
@@ -190,7 +192,7 @@ function Reclassify({
<select
value={to}
onChange={(event) => setTo(Number(event.target.value))}
aria-label={de.clearance.classification}
aria-label={clearanceText.classification}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{levels.map((level) => (
@@ -211,14 +213,14 @@ function Reclassify({
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{de.common.save}
{commonText.save}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
@@ -229,8 +231,8 @@ function Reclassify({
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={2}
placeholder={de.case.reclassifyReason}
aria-label={de.case.reclassifyReason}
placeholder={caseText.reclassifyReason}
aria-label={caseText.reclassifyReason}
className="mt-2 block w-full border border-line bg-raised px-3 py-2 text-2xs"
/>
) : null}
@@ -1,7 +1,8 @@
'use client'
import { useActionState } from 'react'
import { de } from '@/lib/i18n/de'
import { clearanceText, commonText } from '@/lib/i18n/common'
import { personnelText } from '@/lib/i18n/people'
import { createPersonnelAction, type ActionResult } from '../actions'
/**
@@ -29,11 +30,11 @@ export function NewPersonnelForm({
return (
<form action={action} className="mt-6 space-y-5">
<Feld id="displayName" label={de.personnel.name} required minLength={2} />
<Feld id="displayName" label={personnelText.name} required minLength={2} />
<div>
<label htmlFor="userId" className="font-condensed text-xs text-text-dim">
{de.personnel.linkedAccount}
{personnelText.linkedAccount}
</label>
<select
id="userId"
@@ -41,7 +42,7 @@ export function NewPersonnelForm({
defaultValue=""
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
>
<option value="">{de.personnel.noAccount}</option>
<option value="">{personnelText.noAccount}</option>
{members.map((member) => (
<option key={member.id} value={member.id}>
{member.displayName}
@@ -51,12 +52,12 @@ export function NewPersonnelForm({
</div>
<div className="grid gap-5 sm:grid-cols-2">
<Feld id="rank" label={de.personnel.rank} />
<Feld id="department" label={de.personnel.department} />
<Feld id="rank" label={personnelText.rank} />
<Feld id="department" label={personnelText.department} />
</div>
<fieldset>
<legend className="font-condensed text-xs text-text-dim">{de.personnel.status}</legend>
<legend className="font-condensed text-xs text-text-dim">{personnelText.status}</legend>
{/* Kästchen statt Auswahlliste: mehrere Etiketten sind der Normalfall,
und eine Mehrfachauswahl mit Strg-Klick findet niemand. */}
<div className="mt-2 flex flex-wrap gap-x-6 gap-y-2">
@@ -67,12 +68,12 @@ export function NewPersonnelForm({
</label>
))}
</div>
<p className="mt-1 text-2xs text-text-dim">{de.personnel.statusHint}</p>
<p className="mt-1 text-2xs text-text-dim">{personnelText.statusHint}</p>
</fieldset>
<div>
<label htmlFor="note" className="font-condensed text-xs text-text-dim">
{de.personnel.note}
{personnelText.note}
</label>
<textarea
id="note"
@@ -80,12 +81,12 @@ export function NewPersonnelForm({
rows={3}
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
<p className="mt-1 text-2xs text-text-dim">{de.personnel.noteHelp}</p>
<p className="mt-1 text-2xs text-text-dim">{personnelText.noteHelp}</p>
</div>
<div>
<label htmlFor="classification" className="font-condensed text-xs text-text-dim">
{de.clearance.classification}
{clearanceText.classification}
</label>
{/* Die Auswahl zeigt nur, was erlaubt ist. Geprüft wird trotzdem serverseitig. */}
<select
@@ -111,7 +112,7 @@ export function NewPersonnelForm({
disabled={pending}
className="border border-line bg-raised px-4 py-2 font-condensed text-sm text-accent disabled:text-text-dim"
>
{pending ? de.common.loading : de.personnel.create}
{pending ? commonText.loading : personnelText.create}
</button>
</form>
)
+111
View File
@@ -0,0 +1,111 @@
import { getCurrentUser } from '@/lib/auth/current'
import { db } from '@/lib/db'
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { ensureListening, subscribe } from '@/lib/realtime/listener'
import { ATTACHMENT_CHANNEL } from '@/lib/realtime/publish'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Damit Zwischenspeicher und Tunnel die Verbindung nicht für tot halten. */
const HEARTBEAT_MS = 25_000
/**
* Ereignisstrom für offene Ansichten.
*
* Nur `attachment.scanned` bislang; der Aktenchat kommt in Phase 6 dazu und
* benutzt denselben Weg. Server-Sent Events und kein WebSocket: die Richtung
* ist einseitig, es geht durch jeden Tunnel, und der Browser baut die
* Verbindung von selbst wieder auf.
*
* **Die Sichtbarkeitsprüfung läuft je Ereignis.** Ein Ereignis über einen
* Anhang, dessen Akte der Empfänger nicht sehen darf, wird nicht zugestellt —
* sonst verriete schon die Kennung die Existenz.
*/
export async function GET(request: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) return new Response(null, { status: 404 })
await ensureListening()
const encoder = new TextEncoder()
let heartbeat: ReturnType<typeof setInterval> | null = null
let unsubscribe: (() => void) | null = null
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) => {
try {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
)
} catch {
// Verbindung schon zu; das Aufräumen übernimmt cancel().
}
}
send('ready', { at: new Date().toISOString() })
heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch {
// s. o.
}
}, HEARTBEAT_MS)
unsubscribe = subscribe((event) => {
if (event.channel !== ATTACHMENT_CHANNEL) return
void zustellen(event.payload, user.permissions, send)
})
request.signal.addEventListener('abort', () => {
controller.close()
})
},
cancel() {
if (heartbeat) clearInterval(heartbeat)
unsubscribe?.()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Der Tunnel darf nicht puffern, sonst kommt alles am Stück.
'X-Accel-Buffering': 'no',
},
})
}
async function zustellen(
attachmentId: string,
permissions: Awaited<ReturnType<typeof getCurrentUser>> extends infer U
? U extends { permissions: infer P }
? P
: never
: never,
send: (event: string, data: unknown) => void,
): Promise<void> {
try {
const anhang = await db.attachment.findFirst({
where: { AND: [visibleAttachmentWhere(permissions), { id: attachmentId }] },
select: {
id: true,
scanStatus: true,
scanMessage: true,
mimeType: true,
sizeBytes: true,
thumbKey: true,
blurhash: true,
width: true,
height: true,
},
})
if (!anhang) return
send('attachment.scanned', { ...anhang, hasThumb: anhang.thumbKey !== null, thumbKey: undefined })
} catch (error) {
console.error('[events] Ereignis nicht zugestellt', error)
}
}
+108
View File
@@ -0,0 +1,108 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth/current'
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
import { loadDeliverable } from '@/lib/files/service'
import { absolutePath } from '@/lib/files/paths'
import { deliveryFileName } from '@/lib/files/delivery'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/**
* Auslieferung. **Niemals als statische Datei** — jeder Abruf läuft durch die
* Sichtbarkeitsprüfung der zugehörigen Akte und wird ab Stufe 3 protokolliert.
* Siehe docs/07-dateien-und-sicherheit.md.
*
* Die Kopfzeilen sind der zweite Schutzwall hinter der Neukodierung: selbst
* eine durchgerutschte Datei kann im Browser nichts ausführen. `sandbox` ohne
* Wert verbietet Skripte, Formulare und eigene Herkunft zugleich.
*/
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const user = await getCurrentUser()
// Nicht angemeldet ist hier 404 und nicht 401: die Existenz einer Datei ist
// selbst eine Information.
if (!user) return new NextResponse(null, { status: 404 })
const { id } = await context.params
const anhang = await loadDeliverable(user.permissions, id)
if (!anhang) return new NextResponse(null, { status: 404 })
const variante = new URL(request.url).searchParams.get('variant')
const vorschau = variante === 'thumb' && anhang.thumbKey !== null
const key = vorschau ? anhang.thumbKey! : anhang.storageKey
let groesse: number
try {
groesse = (await stat(absolutePath(key))).size
} catch {
return new NextResponse(null, { status: 404 })
}
await protokolliereAbruf(user.id, user.permissions.clearanceLevel, anhang)
// Die Endung folgt dem erkannten Typ, nicht dem hochgeladenen Namen.
const name = deliveryFileName(anhang.originalName, anhang.mimeType)
const headers = new Headers({
'Content-Type': vorschau ? 'image/webp' : anhang.mimeType,
'Content-Length': String(groesse),
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Security-Policy': "default-src 'none'; sandbox",
// Vorschaubilder dürfen inline, sie sind serverseitig neu kodiert worden
// und immer WebP. Alles andere wird heruntergeladen, nie angezeigt.
'Content-Disposition': vorschau
? 'inline'
: `attachment; filename="${name}"; filename*=UTF-8''${encodeURIComponent(name)}`,
})
const stream = Readable.toWeb(createReadStream(absolutePath(key))) as ReadableStream
return new NextResponse(stream, { headers })
}
/**
* Ein Anhang erbt die Stufe seines Besitzers. Für die Protokollpflicht ab
* Stufe 3 muss sie deshalb nachgeschlagen werden.
*/
async function protokolliereAbruf(
userId: string,
clearanceLevel: number,
anhang: { id: string; caseId: string | null; entryId: string | null; documentId: string | null },
): Promise<void> {
const stufe = anhang.caseId
? (await db.case.findUnique({ where: { id: anhang.caseId }, select: { classification: true } }))
?.classification
: anhang.entryId
? (
await db.caseEntry.findUnique({
where: { id: anhang.entryId },
select: { case: { select: { classification: true } } },
})
)?.case.classification
: anhang.documentId
? (
await db.document.findUnique({
where: { id: anhang.documentId },
select: { classification: true },
})
)?.classification
: 0
if ((stufe ?? 0) >= READ_AUDIT_FROM_LEVEL) {
recordRead({
actorId: userId,
action: 'attachment.read',
targetType: 'Attachment',
targetId: anhang.id,
clearanceAtTime: clearanceLevel,
meta: { classification: stufe },
})
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/errors'
import { acceptUpload, type AttachmentOwner } from '@/lib/files/service'
import { runCheckChain } from '@/lib/files/process'
import { MAX_FILE_BYTES } from '@/lib/files/limits'
import { de } from '@/lib/i18n/de'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/**
* Upload.
*
* Ein Route Handler und keine Server Action: nur so bekommt der Browser einen
* Fortschrittsbalken, und 25 MB sind mehr, als eine Server Action annimmt.
*
* Die Antwort kommt, sobald die Datei in der Quarantäne liegt. Die Prüfkette
* läuft danach weiter und meldet sich über `/api/events` — niemand wartet mit
* laufendem Balken auf einen Virenscanner. Siehe docs/09.
*/
export async function POST(request: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 })
const laenge = Number(request.headers.get('content-length') ?? 0)
if (laenge > MAX_FILE_BYTES * 1.1) {
return NextResponse.json({ error: de.attachment.errors['file-too-large'] }, { status: 413 })
}
let form: FormData
try {
form = await request.formData()
} catch {
return NextResponse.json({ error: de.actionErrors.default }, { status: 400 })
}
const datei = form.get('file')
if (!(datei instanceof File)) {
return NextResponse.json({ error: de.actionErrors.default }, { status: 400 })
}
const owner = ownerAusForm(form)
if (!owner) return NextResponse.json({ error: de.actionErrors.default }, { status: 400 })
try {
const angelegt = await acceptUpload(user.permissions, {
owner,
fileName: datei.name || 'unbenannt',
declaredType: datei.type || null,
content: new Uint8Array(await datei.arrayBuffer()),
})
// Bewusst nicht abgewartet: die Antwort soll sofort raus, damit die
// Oberfläche den Anhang mit "Wird geprüft" zeigen kann.
void runCheckChain(angelegt.id).catch((error: unknown) => {
console.error('[dateien] Prüfkette abgebrochen', error)
})
return NextResponse.json({ id: angelegt.id }, { status: 201 })
} catch (error) {
if (error instanceof AccessError) {
const status = error.code === 'rate-limited' ? 429 : error.code === 'not-found' ? 404 : 400
return NextResponse.json(
{ error: de.attachment.errors[error.code] ?? de.actionErrors[error.code] ?? de.actionErrors.default },
{ status },
)
}
console.error('[dateien] Upload fehlgeschlagen', error)
return NextResponse.json({ error: de.actionErrors.default }, { status: 500 })
}
}
function ownerAusForm(form: FormData): AttachmentOwner | null {
const caseNumber = form.get('caseNumber')
if (typeof caseNumber === 'string' && caseNumber) return { kind: 'case', caseNumber }
const entryId = form.get('entryId')
if (typeof entryId === 'string' && entryId) return { kind: 'entry', entryId }
const documentNumber = form.get('documentNumber')
if (typeof documentNumber === 'string' && documentNumber) {
return { kind: 'document', documentNumber }
}
return null
}
+4 -4
View File
@@ -1,18 +1,18 @@
'use client'
import { de } from '@/lib/i18n/de'
import { commonText, errorsText } from '@/lib/i18n/common'
export default function Error({ reset }: { error: Error; reset: () => void }) {
return (
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
<h1 className="font-condensed">{de.errors.unexpected.title}</h1>
<p className="mt-2 text-text-dim">{de.errors.unexpected.body}</p>
<h1 className="font-condensed">{errorsText.unexpected.title}</h1>
<p className="mt-2 text-text-dim">{errorsText.unexpected.body}</p>
<button
type="button"
onClick={reset}
className="mt-4 border border-line bg-raised px-3 py-1.5 font-condensed text-xs"
>
{de.common.retry}
{commonText.retry}
</button>
</main>
)
+10 -9
View File
@@ -1,7 +1,8 @@
'use client'
import { useActionState, useState } from 'react'
import { de } from '@/lib/i18n/de'
import { clearanceText, commonText } from '@/lib/i18n/common'
import { documentText } from '@/lib/i18n/documents'
import { createDocumentAction, type ActionResult } from '@/app/(app)/dokumente/actions'
/**
@@ -39,7 +40,7 @@ export function NewDocument({
onClick={() => setOpen(true)}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.document.create}
{documentText.create}
</button>
)
}
@@ -51,12 +52,12 @@ export function NewDocument({
<input type="hidden" name="personnelFileId" value={personnelFileId} />
) : null}
<p className="font-condensed text-xs text-text-dim">{de.document.createTitle}</p>
<p className="font-condensed text-xs text-text-dim">{documentText.createTitle}</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="templateKey" className="font-condensed text-2xs text-text-dim">
{de.document.kind}
{documentText.kind}
</label>
<select
id="templateKey"
@@ -74,7 +75,7 @@ export function NewDocument({
<div>
<label htmlFor="classification" className="font-condensed text-2xs text-text-dim">
{de.clearance.classification}
{clearanceText.classification}
</label>
<select
id="classification"
@@ -92,7 +93,7 @@ export function NewDocument({
<div className="sm:col-span-2">
<label htmlFor="doc-title" className="font-condensed text-2xs text-text-dim">
{de.document.title}
{documentText.title}
</label>
<input
id="doc-title"
@@ -103,7 +104,7 @@ export function NewDocument({
</div>
</div>
<p className="mt-2 text-2xs text-text-dim">{de.document.createHint}</p>
<p className="mt-2 text-2xs text-text-dim">{documentText.createHint}</p>
{state && !state.ok ? (
<p className="mt-2 font-condensed text-2xs text-stamp-4">{state.error}</p>
@@ -115,14 +116,14 @@ export function NewDocument({
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{pending ? de.common.loading : de.document.create}
{pending ? commonText.loading : documentText.create}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
{commonText.cancel}
</button>
</div>
</form>
+22 -4
View File
@@ -30,6 +30,7 @@ export function Sheet({
issuedAt,
foundationLevels,
footerNote,
photoUrls,
}: {
sections: RenderedSection[]
title: string
@@ -39,6 +40,8 @@ export function Sheet({
/** Aus FoundationClearance. Steuert keinen Zugriff, nur den Balken. */
foundationLevels: { level: number; name: string; colorHex: string }[]
footerNote: string
/** Nur geprüfte Lichtbilder, je Feldschlüssel. Serverseitig aufgelöst. */
photoUrls?: Record<string, string>
}) {
const fields = sections.map((section) => section.field)
const byKey = new Map(sections.map((section) => [section.fieldKey, section]))
@@ -96,7 +99,10 @@ export function Sheet({
<div className="sheet-label" style={{ textAlign: 'center' }}>
{photo.label}
</div>
<PhotoBox section={byKey.get(photo.key)} />
<PhotoBox
section={byKey.get(photo.key)}
url={photoUrls?.[photo.key] ?? null}
/>
</td>
) : null}
@@ -130,7 +136,13 @@ export function Sheet({
)
}
function PhotoBox({ section }: { section: RenderedSection | undefined }) {
function PhotoBox({
section,
url,
}: {
section: RenderedSection | undefined
url: string | null
}) {
if (section && !section.visible) {
return (
<div className="sheet-photo">
@@ -140,10 +152,16 @@ function PhotoBox({ section }: { section: RenderedSection | undefined }) {
}
// Der Wert eines Lichtbildfeldes ist die Kennung eines geprüften Anhangs,
// nie das Bild selbst. Die Prüfkette dafür baut Phase 5, siehe docs/07.
// nie das Bild selbst. Die Adresse dazu ist serverseitig aufgelöst worden,
// und nur für einen Anhang, der die Prüfkette bestanden hat.
return (
<div className="sheet-photo">
<span>{de.document.photoEmpty}</span>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={section?.field.label ?? ''} />
) : (
<span>{de.document.photoEmpty}</span>
)}
</div>
)
}
+4 -4
View File
@@ -1,7 +1,7 @@
'use client'
import { useState, type ReactNode } from 'react'
import { de } from '@/lib/i18n/de'
import { documentText } from '@/lib/i18n/documents'
/**
* Umschalter zwischen Aktenblatt und Bearbeitung.
@@ -30,7 +30,7 @@ export function SheetSwitch({
aria-current={modus === 'blatt'}
className={modus === 'blatt' ? 'text-accent' : 'text-text-dim'}
>
{de.document.sheet}
{documentText.sheet}
</button>
{canEdit ? (
<button
@@ -39,7 +39,7 @@ export function SheetSwitch({
aria-current={modus === 'bearbeiten'}
className={modus === 'bearbeiten' ? 'text-accent' : 'text-text-dim'}
>
{de.document.edit}
{documentText.edit}
</button>
) : null}
<button
@@ -52,7 +52,7 @@ export function SheetSwitch({
}}
className="ml-auto text-text-dim"
>
{de.document.print}
{documentText.print}
</button>
</div>
+250
View File
@@ -0,0 +1,250 @@
'use client'
import { useCallback, useRef, useState, useTransition } from 'react'
import { attachmentText } from '@/lib/i18n/documents'
import { MAX_FILE_BYTES, formatBytes } from '@/lib/files/limits'
import { releaseAttachmentAction } from '@/app/(app)/dateien/actions'
import { useScanEvents, type ScanEvent } from './useScanEvents'
export type AttachmentView = {
id: string
originalName: string
mimeType: string
sizeBytes: number
scanStatus: string
scanMessage: string | null
blurhash: string | null
width: number | null
height: number | null
hasThumb: boolean
createdAt: string
}
/** Welchem Besitzer die Datei angehängt wird. Genau eines der drei Felder. */
export type UploadTarget = {
caseNumber?: string
entryId?: string
documentNumber?: string
}
type Laufend = { key: string; name: string; progress: number; error?: string }
/**
* Anhänge einer Akte, eines Eintrags oder eines Dokuments.
*
* Der Ablauf folgt docs/09-performance-und-ux.md: die Datei erscheint sofort
* in der Liste, der Fortschrittsbalken zeigt nur die **Übertragung**, und
* danach steht „Wird geprüft", bis das Ereignis aus `/api/events` kommt. Kein
* Balken wartet auf einen Virenscanner, und kein Spinner blockiert die Seite.
*/
export function Attachments({
target,
initial,
canUpload,
canRelease,
}: {
target: UploadTarget
initial: AttachmentView[]
canUpload: boolean
canRelease: boolean
}) {
const [dateien, setDateien] = useState<AttachmentView[]>(initial)
const [laufend, setLaufend] = useState<Laufend[]>([])
const [pending, startTransition] = useTransition()
const eingabe = useRef<HTMLInputElement>(null)
const uebernehmen = useCallback((event: ScanEvent) => {
setDateien((bisher) =>
bisher.map((datei) =>
datei.id === event.id
? {
...datei,
scanStatus: event.scanStatus,
scanMessage: event.scanMessage,
mimeType: event.mimeType,
sizeBytes: event.sizeBytes,
blurhash: event.blurhash,
width: event.width,
height: event.height,
hasThumb: event.hasThumb,
}
: datei,
),
)
}, [])
useScanEvents(uebernehmen)
function hochladen(datei: File) {
const key = `${datei.name}-${Date.now()}-${Math.random()}`
if (datei.size > MAX_FILE_BYTES) {
setLaufend((l) => [
...l,
{ key, name: datei.name, progress: 0, error: attachmentText.errors['file-too-large'] },
])
return
}
setLaufend((l) => [...l, { key, name: datei.name, progress: 0 }])
const form = new FormData()
form.set('file', datei)
for (const [feld, wert] of Object.entries(target)) {
if (wert) form.set(feld, wert)
}
// XMLHttpRequest und nicht fetch: nur so gibt es einen echten
// Fortschritt beim Senden. `fetch` meldet ihn bis heute nicht.
const anfrage = new XMLHttpRequest()
anfrage.open('POST', '/api/files')
anfrage.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) return
const anteil = Math.round((event.loaded / event.total) * 100)
setLaufend((l) => l.map((e) => (e.key === key ? { ...e, progress: anteil } : e)))
})
anfrage.addEventListener('load', () => {
const fertig = () => setLaufend((l) => l.filter((e) => e.key !== key))
if (anfrage.status === 201) {
const { id } = JSON.parse(anfrage.responseText) as { id: string }
setDateien((bisher) => [
...bisher,
{
id,
originalName: datei.name,
mimeType: datei.type || 'application/octet-stream',
sizeBytes: datei.size,
scanStatus: 'PENDING',
scanMessage: null,
blurhash: null,
width: null,
height: null,
hasThumb: false,
createdAt: new Date().toISOString(),
},
])
fertig()
return
}
let meldung = attachmentText.errors.transfer!
try {
meldung = (JSON.parse(anfrage.responseText) as { error?: string }).error ?? meldung
} catch {
// Antwort ohne JSON, dann bleibt die allgemeine Meldung.
}
setLaufend((l) => l.map((e) => (e.key === key ? { ...e, error: meldung } : e)))
})
anfrage.addEventListener('error', () => {
setLaufend((l) =>
l.map((e) => (e.key === key ? { ...e, error: attachmentText.errors.transfer! } : e)),
)
})
anfrage.send(form)
}
return (
<div>
{dateien.length === 0 && laufend.length === 0 ? (
<p className="text-text-dim">{attachmentText.empty}</p>
) : (
<ul className="border-t border-line">
{dateien.map((datei) => (
<li key={datei.id} className="border-b border-line py-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{datei.hasThumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/files/${datei.id}?variant=thumb`}
alt=""
className="h-10 w-10 border border-line object-cover"
/>
) : null}
<span className="grow text-sm">{datei.originalName}</span>
<span className="font-mono text-2xs text-text-dim">
{formatBytes(datei.sizeBytes)}
</span>
<span
className={`font-condensed text-2xs ${
datei.scanStatus === 'BLOCKED' ? 'text-stamp-4' : 'text-text-dim'
}`}
>
{attachmentText.statusNames[datei.scanStatus] ?? datei.scanStatus}
</span>
{datei.scanStatus === 'CLEAN' ? (
<a href={`/api/files/${datei.id}`} className="font-condensed text-2xs">
{attachmentText.download}
</a>
) : null}
{datei.scanStatus === 'FAILED' && canRelease ? (
<button
type="button"
disabled={pending}
title={attachmentText.releaseHint}
onClick={() =>
startTransition(async () => {
await releaseAttachmentAction({ id: datei.id })
})
}
className="font-condensed text-2xs text-stamp-3"
>
{attachmentText.release}
</button>
) : null}
</div>
{datei.scanMessage ? (
<p className="mt-1 text-2xs text-text-dim">{datei.scanMessage}</p>
) : null}
</li>
))}
{laufend.map((eintrag) => (
<li key={eintrag.key} className="border-b border-line py-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="grow text-sm text-text-dim">{eintrag.name}</span>
<span className="font-condensed text-2xs text-text-dim">
{eintrag.error ?? `${attachmentText.uploading} ${eintrag.progress} %`}
</span>
</div>
{!eintrag.error ? (
<div className="mt-1 h-0.5 w-full bg-raised">
<div
className="h-full bg-accent transition-[width] duration-150"
style={{ width: `${eintrag.progress}%` }}
/>
</div>
) : null}
</li>
))}
</ul>
)}
{canUpload ? (
<div className="mt-4">
<input
ref={eingabe}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,application/pdf,text/plain"
onChange={(event) => {
for (const datei of event.target.files ?? []) hochladen(datei)
event.target.value = ''
}}
className="hidden"
/>
<button
type="button"
onClick={() => eingabe.current?.click()}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{attachmentText.add}
</button>
<p className="mt-1 text-2xs text-text-dim">{attachmentText.allowed}</p>
</div>
) : null}
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { attachmentText, documentText } from '@/lib/i18n/documents'
import { MAX_FILE_BYTES } from '@/lib/files/limits'
import { useScanEvents, type ScanEvent } from './useScanEvents'
import type { SaveSection } from '@/components/sections/types'
/**
* Das Lichtbild im Aktenblatt.
*
* Der Wert des Feldes ist die **Kennung eines geprüften Anhangs**, nie das
* Bild selbst. Ein Bild als Data-URL im Abschnittswert liefe an der ganzen
* Prüfkette vorbei und wäre nachträglich kaum wieder herauszubekommen.
* Siehe docs/07-dateien-und-sicherheit.md.
*
* Deshalb zwei Schritte hintereinander: hochladen, dann die Kennung in den
* Abschnitt schreiben. Angezeigt wird erst, wenn die Prüfung durch ist.
*/
export function PhotoField({
save,
fieldKey,
label,
documentNumber,
initialAttachmentId,
initialUrl,
initialVersion,
readOnly,
}: {
save: SaveSection
fieldKey: string
label: string
documentNumber: string
initialAttachmentId: string | null
initialUrl: string | null
initialVersion: number
readOnly: boolean
}) {
const [url, setUrl] = useState(initialUrl)
const [version, setVersion] = useState(initialVersion)
const [wartetAuf, setWartetAuf] = useState<string | null>(
initialAttachmentId && !initialUrl ? initialAttachmentId : null,
)
const [fortschritt, setFortschritt] = useState<number | null>(null)
const [fehler, setFehler] = useState<string | null>(null)
const eingabe = useRef<HTMLInputElement>(null)
const geprueft = useCallback(
(event: ScanEvent) => {
if (event.id !== wartetAuf) return
if (event.scanStatus === 'CLEAN') {
setUrl(`/api/files/${event.id}?variant=thumb`)
setWartetAuf(null)
return
}
if (event.scanStatus !== 'PENDING') {
setFehler(event.scanMessage ?? attachmentText.statusNames[event.scanStatus]!)
setWartetAuf(null)
}
},
[wartetAuf],
)
useScanEvents(geprueft)
function waehlen(datei: File) {
setFehler(null)
if (datei.size > MAX_FILE_BYTES) {
setFehler(attachmentText.errors['file-too-large']!)
return
}
const form = new FormData()
form.set('file', datei)
form.set('documentNumber', documentNumber)
const anfrage = new XMLHttpRequest()
anfrage.open('POST', '/api/files')
setFortschritt(0)
anfrage.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setFortschritt(Math.round((event.loaded / event.total) * 100))
}
})
anfrage.addEventListener('load', async () => {
setFortschritt(null)
if (anfrage.status !== 201) {
try {
setFehler(
(JSON.parse(anfrage.responseText) as { error?: string }).error ??
attachmentText.errors.transfer!,
)
} catch {
setFehler(attachmentText.errors.transfer!)
}
return
}
const { id } = JSON.parse(anfrage.responseText) as { id: string }
// Die Kennung sofort in den Abschnitt schreiben, auch wenn die Prüfung
// noch läuft: sonst ginge die Zuordnung verloren, wenn der Nutzer die
// Seite verlässt.
const ergebnis = await save({ fieldKey, value: id, baseVersion: version })
if (ergebnis.ok) {
setVersion(ergebnis.version)
setWartetAuf(id)
setUrl(null)
} else if (!ergebnis.conflict) {
setFehler(ergebnis.error)
}
})
anfrage.addEventListener('error', () => {
setFortschritt(null)
setFehler(attachmentText.errors.transfer!)
})
anfrage.send(form)
}
return (
<div>
<p className="font-condensed text-xs text-text-dim">{label}</p>
<div className="mt-1.5 flex items-start gap-4">
<div className="flex h-40 w-30 items-center justify-center border border-line bg-raised text-center text-2xs text-text-dim">
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={label} className="h-full w-full object-cover" />
) : wartetAuf ? (
attachmentText.checking
) : (
documentText.photoEmpty
)}
</div>
{!readOnly ? (
<div>
<input
ref={eingabe}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
const datei = event.target.files?.[0]
if (datei) waehlen(datei)
event.target.value = ''
}}
className="hidden"
/>
<button
type="button"
disabled={fortschritt !== null}
onClick={() => eingabe.current?.click()}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{fortschritt === null
? attachmentText.choose
: `${attachmentText.uploading} ${fortschritt} %`}
</button>
{fehler ? (
<p className="mt-2 max-w-60 font-condensed text-2xs text-stamp-4">{fehler}</p>
) : null}
</div>
) : null}
</div>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
'use client'
import { useEffect } from 'react'
export type ScanEvent = {
id: string
scanStatus: 'PENDING' | 'CLEAN' | 'BLOCKED' | 'FAILED'
scanMessage: string | null
mimeType: string
sizeBytes: number
blurhash: string | null
width: number | null
height: number | null
hasThumb: boolean
}
/**
* Hört auf das Ende von Dateiprüfungen.
*
* Eine einzige Verbindung je Seite, vom Browser selbst wieder aufgebaut, wenn
* sie abreisst. Kein Abfragen im Sekundentakt: eine Prüfung dauert je nach
* Dateigrösse zwischen einer und dreissig Sekunden, und solange im Leerlauf zu
* fragen wäre auf einem Heimserver unhöflich.
*/
export function useScanEvents(onScanned: (event: ScanEvent) => void): void {
useEffect(() => {
const quelle = new EventSource('/api/events')
const handler = (message: MessageEvent<string>) => {
try {
onScanned(JSON.parse(message.data) as ScanEvent)
} catch {
// Kaputte Nutzlast ignorieren, der nächste Seitenaufbau hat den Stand.
}
}
quelle.addEventListener('attachment.scanned', handler as EventListener)
return () => {
quelle.removeEventListener('attachment.scanned', handler as EventListener)
quelle.close()
}
}, [onScanned])
}
+10 -10
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { sectionText } from '@/lib/i18n/cases'
import type { SaveSection } from './types'
type Props = {
@@ -145,24 +145,24 @@ export function SectionEditor(props: Props) {
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
>
{pending ? de.section.saving : de.section.save}
{pending ? sectionText.saving : sectionText.save}
</button>
{version > 0 ? (
<span className="font-mono text-text-dim">{de.section.version(version)}</span>
<span className="font-mono text-text-dim">{sectionText.version(version)}</span>
) : null}
{saved ? <span className="font-condensed text-text-dim">{de.section.saved}</span> : null}
{saved ? <span className="font-condensed text-text-dim">{sectionText.saved}</span> : null}
{draftKept && !saved ? (
<span className="font-condensed text-text-dim">{de.section.draftKept}</span>
<span className="font-condensed text-text-dim">{sectionText.draftKept}</span>
) : null}
{error ? <span className="font-condensed text-stamp-4">{error}</span> : null}
</div>
{conflict ? (
<div className="mt-3 border border-stamp-4 p-3">
<p className="font-condensed text-2xs text-stamp-4">{de.section.conflict}</p>
<p className="font-condensed text-2xs text-stamp-4">{sectionText.conflict}</p>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<p className="font-condensed text-2xs text-text-dim">{de.section.mine}</p>
<p className="font-condensed text-2xs text-text-dim">{sectionText.mine}</p>
<p className="mt-1 border-l-(length:--spacing-edge) border-steel pl-3 text-sm whitespace-pre-wrap">
{value}
</p>
@@ -171,11 +171,11 @@ export function SectionEditor(props: Props) {
onClick={() => submit(parse(value, props.type), conflict.version)}
className="mt-2 font-condensed text-2xs text-accent"
>
{de.section.takeMine}
{sectionText.takeMine}
</button>
</div>
<div>
<p className="font-condensed text-2xs text-text-dim">{de.section.theirs}</p>
<p className="font-condensed text-2xs text-text-dim">{sectionText.theirs}</p>
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap">
{String(conflict.value ?? '')}
</p>
@@ -188,7 +188,7 @@ export function SectionEditor(props: Props) {
}}
className="mt-2 font-condensed text-2xs text-accent"
>
{de.section.takeTheirs}
{sectionText.takeTheirs}
</button>
</div>
</div>
+30 -4
View File
@@ -2,6 +2,7 @@ import { de } from '@/lib/i18n/de'
import { fillPlaceholders, isInputField } from '@/lib/templates/fields'
import { RedactedText } from '@/components/Redacted'
import { SectionEditor } from './SectionEditor'
import { PhotoField } from '@/components/files/PhotoField'
import type { SaveSection } from './types'
import type { RenderedSection } from '@/lib/sections/render'
@@ -18,11 +19,17 @@ export function Sections({
save,
sections,
canEdit,
documentNumber,
photoUrls,
}: {
scope: string
save: SaveSection
sections: RenderedSection[]
canEdit: boolean
/** Nur bei Dokumenten gesetzt: Ziel für das Lichtbild. */
documentNumber?: string
/** Bereits geprüfte Lichtbilder, je Feldschlüssel. */
photoUrls?: Record<string, string>
}) {
if (sections.length === 0) {
return <p className="text-text-dim">{de.section.empty}</p>
@@ -53,11 +60,30 @@ export function Sections({
}
if (field.type === 'photo') {
// Ohne Dokument gibt es kein Ziel für den Upload — Fallabschnitte
// tragen keine Bilder, ihre Dateien hängen an der Akte.
if (!documentNumber) {
return (
<div key={field.key}>
<dt className="font-condensed text-xs text-text-dim">{field.label}</dt>
<dd className="mt-1 text-2xs text-text-dim">{de.document.photoLater}</dd>
</div>
)
}
return (
<div key={field.key}>
<dt className="font-condensed text-xs text-text-dim">{field.label}</dt>
<dd className="mt-1 text-2xs text-text-dim">{de.document.photoLater}</dd>
</div>
<PhotoField
key={field.key}
save={save}
fieldKey={field.key}
label={field.label}
documentNumber={documentNumber}
initialAttachmentId={
section.visible && typeof section.value === 'string' ? section.value : null
}
initialUrl={photoUrls?.[field.key] ?? null}
initialVersion={section.version}
readOnly={!canEdit}
/>
)
}
+25
View File
@@ -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) } },
],
}
}
+3
View File
@@ -40,6 +40,9 @@ export type AuditAction =
| 'document.finalize'
| 'document.retract'
| 'document.reclassify'
| 'attachment.upload'
| 'attachment.read'
| 'attachment.release'
| 'roles.update'
| 'export.pdf'
+93
View File
@@ -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))
})
}
+32
View File
@@ -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
}
+26
View File
@@ -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`
}
+118
View File
@@ -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
}
+58
View File
@@ -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')
}
+168
View File
@@ -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)
}
+160
View File
@@ -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'
}
+284
View File
@@ -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
}
+39
View File
@@ -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 -1
View File
@@ -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'
-24
View File
@@ -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))
})
}
+87
View File
@@ -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
+153
View File
@@ -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
+68
View File
@@ -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
View File
@@ -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
+88
View File
@@ -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
+59
View File
@@ -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
+86
View File
@@ -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
+33 -5
View File
@@ -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)
}
}
}
})
+22
View File
@@ -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)
}
}
+2
View File
@@ -15,11 +15,13 @@
},
"dependencies": {
"@afis/db": "workspace:*",
"blurhash": "2.0.5",
"next": "15.5.25",
"next-auth": "5.0.0-beta.32",
"pg": "8.13.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "0.34.4",
"uuidv7": "1.0.2",
"zod": "^4.0.0"
},
+152
View File
@@ -0,0 +1,152 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { loadDeliverable } from '@/lib/files/service'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Ein Anhang hat keine eigene Freigabestufe, er erbt die seines Besitzers.
* Diese Tests prüfen beide Seiten davon: dass die Stufe wirklich durchschlägt,
* und dass eine Datei, die die Prüfkette nicht bestanden hat, gar nicht erst
* ausgeliefert wird — auch nicht an einen Administrator.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `anhang-${Date.now()}`
function perms(
userId: string,
clearanceLevel: number,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
suite('Sichtbarkeit von Anhängen', () => {
const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` }
const anhang = { offen: '', geheim: '', imEintrag: '', inQuarantaene: '' }
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const id of Object.values(nutzer)) {
await db.user.create({ data: { id, username: id, displayName: id } })
}
const gemeinsam = {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.stufe4,
}
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `${marke}-o`.slice(0, 40),
title: 'Offen',
classification: 1,
status: 'OPEN',
},
})
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `${marke}-g`.slice(0, 40),
title: 'Geheim',
classification: 4,
status: 'OPEN',
},
})
const eintrag = await db.caseEntry.create({
data: {
id: `${marke}-eintrag`,
caseId: offen.id,
type: 'NOTE',
body: 'Nur ab Stufe 4',
authorId: nutzer.stufe4,
minClearance: 4,
},
})
const anlegen = async (
name: string,
besitzer: { caseId?: string; entryId?: string },
scanStatus: 'CLEAN' | 'PENDING' = 'CLEAN',
) =>
(
await db.attachment.create({
data: {
originalName: `${name}.png`,
mimeType: 'image/png',
sizeBytes: 100,
sha256: `${marke}-${name}`,
storageKey: `store/aa/bb/${marke}-${name}`,
uploadedById: nutzer.stufe4,
scanStatus,
...besitzer,
},
select: { id: true },
})
).id
anhang.offen = await anlegen('offen', { caseId: offen.id })
anhang.geheim = await anlegen('geheim', { caseId: geheim.id })
anhang.imEintrag = await anlegen('eintrag', { entryId: eintrag.id })
anhang.inQuarantaene = await anlegen('quarantaene', { caseId: offen.id }, 'PENDING')
})
afterAll(async () => {
await db.$disconnect()
})
async function sichtbar(p: EffectivePermissions): Promise<string[]> {
const rows = await db.attachment.findMany({
where: { AND: [visibleAttachmentWhere(p), { id: { in: Object.values(anhang) } }] },
select: { id: true },
})
return rows.map((r) => r.id).sort()
}
it('verbirgt den Anhang einer zu hoch eingestuften Akte, auch in der Zählung', async () => {
const p = perms(nutzer.stufe1, 1)
const treffer = await sichtbar(p)
expect(treffer).toContain(anhang.offen)
expect(treffer).not.toContain(anhang.geheim)
const anzahl = await db.attachment.count({
where: { AND: [visibleAttachmentWhere(p), { id: { in: Object.values(anhang) } }] },
})
expect(anzahl).toBe(treffer.length)
})
it('verbirgt den Anhang eines Eintrags über der Stufe des Lesers', async () => {
// Die Akte darf er sehen, den Eintrag nicht — und damit auch dessen Datei
// nicht. Sonst verriete die Anhangsliste den Inhalt des Eintrags.
expect(await sichtbar(perms(nutzer.stufe1, 1))).not.toContain(anhang.imEintrag)
expect(await sichtbar(perms(nutzer.stufe4, 4))).toContain(anhang.imEintrag)
})
it('liefert nichts aus, was die Prüfkette nicht bestanden hat', async () => {
// Auch nicht dem Administrator: die Datei liegt in der Quarantäne, dort
// führt kein Auslieferungspfad hin.
expect(await loadDeliverable(perms(nutzer.stufe4, 5, { isAdmin: true }), anhang.inQuarantaene))
.toBeNull()
expect(await loadDeliverable(perms(nutzer.stufe4, 4), anhang.offen)).not.toBeNull()
})
it('liefert einem zu niedrig eingestuften Leser nichts, sondern nichts Findbares', async () => {
// `null` und nicht "verboten": der Endpunkt macht daraus 404, die Existenz
// der Datei bleibt verborgen.
expect(await loadDeliverable(perms(nutzer.stufe1, 1), anhang.geheim)).toBeNull()
})
})
+159
View File
@@ -0,0 +1,159 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { ALLOWED_TYPES, detectType, looksLikeText, matchesDeclared } from '@/lib/files/magic'
import { absolutePath, storageKeyFor } from '@/lib/files/paths'
import { deliveryFileName } from '@/lib/files/delivery'
/**
* Die Typprüfung ist die Stelle, an der eine feindselige Datei auffallen muss.
*
* Geprüft wird deshalb nicht nur, dass die erlaubten Formate durchkommen,
* sondern vor allem, dass die verbotenen es **nicht** tun — und zwar auch
* dann, wenn sie sich als erlaubte ausgeben.
*/
const bytes = (...werte: number[]) => new Uint8Array(werte)
const text = (wert: string) => new TextEncoder().encode(wert)
const PNG = bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13)
const JPEG = bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46)
const WEBP = new Uint8Array([...text('RIFF'), 0, 0, 0, 0, ...text('WEBP'), ...text('VP8 ')])
const PDF = text('%PDF-1.7\n1 0 obj\n')
describe('Erkennung erlaubter Typen', () => {
it('erkennt jedes zugelassene Format am Inhalt', () => {
expect(detectType(PNG)).toEqual({ ok: true, type: 'image/png' })
expect(detectType(JPEG)).toEqual({ ok: true, type: 'image/jpeg' })
expect(detectType(WEBP)).toEqual({ ok: true, type: 'image/webp' })
expect(detectType(PDF)).toEqual({ ok: true, type: 'application/pdf' })
expect(detectType(text('Nur Text, mehr nicht.\n'))).toEqual({
ok: true,
type: 'text/plain',
})
})
it('deckt genau die fünf Typen aus dem Dokument ab', () => {
expect([...ALLOWED_TYPES]).toEqual([
'image/png',
'image/jpeg',
'image/webp',
'application/pdf',
'text/plain',
])
})
})
describe('Abwehr', () => {
it('lehnt SVG ab, auch wenn es wie Text aussieht', () => {
// Der eigentliche Grund für die ganze Kette: SVG führt im Browser Skripte
// aus. Es ist gültiges UTF-8 und käme sonst als text/plain durch — das ist
// in Ordnung, denn als Text wird es nie gerendert, sondern ausgeliefert
// mit Content-Disposition: attachment.
const svg = text('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
const erkannt = detectType(svg)
expect(erkannt).toEqual({ ok: true, type: 'text/plain' })
// Entscheidend: es ist nie image/svg+xml, und der gemeldete Typ passt nicht.
expect(matchesDeclared('text/plain', 'image/svg+xml')).toBe(false)
})
it('lehnt Archive und Ausführbares ab', () => {
expect(detectType(bytes(0x50, 0x4b, 0x03, 0x04, 0, 0))).toEqual({
ok: false,
reason: 'unknown-type',
})
// ELF
expect(detectType(bytes(0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0))).toEqual({
ok: false,
reason: 'unknown-type',
})
// Windows-Programm
expect(detectType(bytes(0x4d, 0x5a, 0x90, 0x00))).toEqual({
ok: false,
reason: 'unknown-type',
})
})
it('lässt sich vom RIFF-Container nicht täuschen', () => {
// WAV ist auch RIFF. Ohne die zweite Prüfung wäre es ein WebP.
const wav = new Uint8Array([...text('RIFF'), 0, 0, 0, 0, ...text('WAVE')])
expect(detectType(wav)).toEqual({ ok: false, reason: 'unknown-type' })
})
it('erkennt ein PDF mit Vorspann nicht als PDF', () => {
// Müll vor %PDF- ist der klassische Weg, eine Datei für zwei Programme
// zugleich lesbar zu machen. Die Spezifikation erlaubt es, wir nicht: die
// Signatur muss an Position 0 stehen.
const getarnt = new Uint8Array([...text('GIF89a'), ...PDF])
const erkannt = detectType(getarnt)
expect(erkannt).not.toEqual({ ok: true, type: 'application/pdf' })
// Es bleibt lesbarer Text und wird deshalb als solcher angenommen. Das ist
// harmlos, weil die Auslieferung die Endung auf den **erkannten** Typ
// setzt: die Datei kommt als .txt an und landet in keinem PDF-Betrachter.
expect(erkannt).toEqual({ ok: true, type: 'text/plain' })
expect(deliveryFileName('bericht.pdf', 'text/plain')).toBe('bericht.txt')
})
it('lehnt einen Vorspann mit Binärdaten ganz ab', () => {
const getarnt = new Uint8Array([0x00, 0x01, ...text('%PDF-1.7')])
expect(detectType(getarnt)).toEqual({ ok: false, reason: 'unknown-type' })
})
it('lässt kein Binärformat als Text durch', () => {
expect(looksLikeText(bytes(0x48, 0x00, 0x69))).toBe(false)
expect(looksLikeText(bytes(0xff, 0xfe, 0x41))).toBe(false)
expect(looksLikeText(text('Umlaute sind erlaubt: äöüß\n\t'))).toBe(true)
})
it('lehnt eine leere Datei ab', () => {
expect(detectType(new Uint8Array())).toEqual({ ok: false, reason: 'empty' })
})
it('erkennt, wenn der gemeldete Typ nicht zum Inhalt passt', () => {
expect(matchesDeclared('image/png', 'application/pdf')).toBe(false)
expect(matchesDeclared('image/png', 'image/png')).toBe(true)
// Der Browser weiss es manchmal selbst nicht; das ist kein Verdacht.
expect(matchesDeclared('image/png', 'application/octet-stream')).toBe(true)
expect(matchesDeclared('image/png', null)).toBe(true)
// Zeichensatzangabe im Kopf stört nicht.
expect(matchesDeclared('text/plain', 'text/plain; charset=utf-8')).toBe(true)
})
})
describe('Ablagepfade', () => {
it('verteilt auf zwei Ebenen und behält die Kennung', () => {
expect(storageKeyFor('store', '0192abcd-ef01-7000-8000-000000000001')).toBe(
'store/01/92/0192abcd-ef01-7000-8000-000000000001',
)
})
it('lässt keinen Pfad aus dem Dateiverzeichnis heraus', () => {
process.env.FILES_DIR ??= '/tmp/afis-test-files'
// Kommt aus der Datenbank, nicht aus einer Eingabe — aber ein
// Verzeichniswechsel wäre der teuerste Fehler an dieser Stelle.
expect(() => absolutePath('../../etc/passwd')).toThrow()
expect(() => absolutePath('store/../../../etc/passwd')).toThrow()
expect(absolutePath('store/ab/cd/ef')).toContain('store/ab/cd/ef')
})
it('wirft Sonderzeichen aus der Kennung, bevor sie ein Pfad wird', () => {
expect(storageKeyFor('quarantine', '../../etc')).not.toContain('..')
})
})
describe('Name beim Ausliefern', () => {
it('setzt die Endung auf den erkannten Typ, nicht auf den hochgeladenen Namen', () => {
// Sonst trüge eine als Text erkannte Datei weiter .pdf und würde beim
// Doppelklick im PDF-Betrachter geöffnet.
expect(deliveryFileName('urlaub.jpeg', 'image/png')).toBe('urlaub.png')
expect(deliveryFileName('notiz', 'text/plain')).toBe('notiz.txt')
expect(deliveryFileName('vertrag.pdf', 'application/pdf')).toBe('vertrag.pdf')
expect(deliveryFileName('bild.JPG', 'image/jpeg')).toBe('bild.jpg')
})
it('lässt keine Kopfzeile zerlegen', () => {
const boes = deliveryFileName('a"; rm -rf /\r\nX: y.txt', 'text/plain')
expect(boes).not.toContain('"')
expect(boes).not.toMatch(/[\r\n]/)
})
})
+2 -2
View File
@@ -11,7 +11,7 @@ vi.mock('@/lib/env', () => ({
getEnv: () => ({ CLAMAV_SOCKET: '/var/run/clamav/clamd.sock' }),
}))
vi.mock('@/lib/health/clamav', () => ({ pingClamav: vi.fn(async () => true) }))
vi.mock('@/lib/files/clamav', () => ({ pingClamav: vi.fn(async () => true) }))
describe('collectHealth', () => {
it('meldet betriebsbereit, wenn alle drei Abhaengigkeiten antworten', async () => {
@@ -24,7 +24,7 @@ describe('collectHealth', () => {
})
it('bleibt nur eingeschraenkt, wenn allein die Virenpruefung ausfaellt', async () => {
const clamav = await import('@/lib/health/clamav')
const clamav = await import('@/lib/files/clamav')
vi.mocked(clamav.pingClamav).mockResolvedValueOnce(false)
const { collectHealth } = await import('@/lib/health/check')
const report = await collectHealth()
+94
View File
@@ -0,0 +1,94 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import sharp from 'sharp'
import { reencode } from '@/lib/files/reencode'
import { detectType } from '@/lib/files/magic'
/**
* Die Neukodierung ist die wirksamste Massnahme der Kette: ein Bild, das durch
* `sharp` gelaufen ist, besteht nur noch aus Pixeln.
*
* Diese Tests prüfen genau das — nicht, dass irgendetwas herauskommt, sondern
* dass das, was nicht Inhalt ist, **weg** ist.
*/
async function testbild(): Promise<Buffer> {
return sharp({
create: { width: 24, height: 16, channels: 3, background: { r: 200, g: 40, b: 40 } },
})
.jpeg()
.withMetadata({
exif: {
IFD0: { Copyright: 'GEHEIMER FOTOGRAF', Software: 'Kamera 9000' },
},
})
.toBuffer()
}
describe('Bilder neu kodieren', () => {
it('wirft die Metadaten weg, statt sie zu filtern', async () => {
const original = await testbild()
// Gegenprobe: das Original trägt die Daten wirklich.
expect(original.includes(Buffer.from('GEHEIMER FOTOGRAF'))).toBe(true)
const ergebnis = await reencode(new Uint8Array(original), 'image/jpeg')
expect(ergebnis.ok).toBe(true)
if (!ergebnis.ok) return
const neu = Buffer.from(ergebnis.result.content)
expect(neu.includes(Buffer.from('GEHEIMER FOTOGRAF'))).toBe(false)
expect(neu.includes(Buffer.from('Kamera 9000'))).toBe(false)
// Und es ist noch dasselbe Bild.
const info = await sharp(neu).metadata()
expect(info.width).toBe(24)
expect(info.height).toBe(16)
expect(ergebnis.result.width).toBe(24)
})
it('zerstört eine angehängte Nutzlast', async () => {
// Der klassische Trick: hinter das Bildende Daten kleben. Nach der
// Neukodierung ist davon nichts mehr da, weil nur die Pixel übernommen
// werden.
const angehaengt = Buffer.concat([
await testbild(),
Buffer.from('<?php system($_GET["c"]); ?>'),
])
const ergebnis = await reencode(new Uint8Array(angehaengt), 'image/jpeg')
expect(ergebnis.ok).toBe(true)
if (!ergebnis.ok) return
expect(Buffer.from(ergebnis.result.content).includes(Buffer.from('<?php'))).toBe(false)
})
it('erzeugt Vorschaubild und Platzhalter', async () => {
const ergebnis = await reencode(new Uint8Array(await testbild()), 'image/jpeg')
expect(ergebnis.ok).toBe(true)
if (!ergebnis.ok) return
expect(ergebnis.result.thumb).toBeDefined()
// Das Vorschaubild ist immer WebP, damit die Auslieferung es gefahrlos
// inline schicken kann.
expect(detectType(ergebnis.result.thumb!)).toEqual({ ok: true, type: 'image/webp' })
expect(ergebnis.result.blurhash).toMatch(/^[\w#$%*+,\-.:;=?@[\]^_{|}~]{6,}$/)
})
it('lehnt ab, was kein Bild ist', async () => {
const ergebnis = await reencode(new TextEncoder().encode('kein Bild'), 'image/png')
expect(ergebnis.ok).toBe(false)
})
})
describe('Text normalisieren', () => {
it('entfernt die Stückliste am Anfang', async () => {
const mitBom = new TextEncoder().encode('Hallo Welt')
const ergebnis = await reencode(mitBom, 'text/plain')
expect(ergebnis.ok).toBe(true)
if (!ergebnis.ok) return
expect(new TextDecoder().decode(ergebnis.result.content)).toBe('Hallo Welt')
})
it('lehnt ungültiges UTF-8 ab', async () => {
const ergebnis = await reencode(new Uint8Array([0xff, 0xfe, 0x41]), 'text/plain')
expect(ergebnis.ok).toBe(false)
})
})
+180
View File
@@ -0,0 +1,180 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { readFile } from 'node:fs/promises'
import sharp from 'sharp'
import { db } from '@/lib/db'
import { acceptUpload, loadDeliverable, releaseAttachment } from '@/lib/files/service'
import { runCheckChain } from '@/lib/files/process'
import { absolutePath } from '@/lib/files/paths'
import { AccessError } from '@/lib/errors'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Die ganze Kette, an einer echten Datei.
*
* Auf dem Entwicklungsrechner läuft kein clamav. Genau das macht den Test
* wertvoll: er hält fest, dass ein fehlender Scanner eine Datei **nicht**
* sauber macht. Wäre das je anders, stünde hier ein roter Test.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `kette-${Date.now()}`
function perms(
userId: string,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel: 4,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
suite('Prüfkette', () => {
const nutzer = perms(`${marke}-nutzer`, { permissions: ['entry.create', 'case.edit'] })
const admin = perms(`${marke}-admin`, { isAdmin: true })
let caseNumber = ''
async function bild(): Promise<Uint8Array> {
const buffer = await sharp({
create: { width: 20, height: 20, channels: 3, background: { r: 10, g: 90, b: 200 } },
})
.jpeg()
.withMetadata({ exif: { IFD0: { Copyright: 'VERTRAULICHE QUELLE' } } })
.toBuffer()
return new Uint8Array(buffer)
}
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const p of [nutzer, admin]) {
await db.user.create({ data: { id: p.userId, username: p.userId, displayName: p.userId } })
}
const akte = await db.case.create({
data: {
caseNumber: `${marke}`.slice(0, 40),
title: 'Anhangprüfung',
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.userId,
classification: 1,
status: 'OPEN',
},
})
caseNumber = akte.caseNumber
})
afterAll(async () => {
await db.$disconnect()
})
it('nimmt die Datei an und legt sie in der Quarantäne ab', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'urlaubsbild.jpg',
declaredType: 'image/jpeg',
content: await bild(),
})
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
expect(anhang.scanStatus).toBe('PENDING')
expect(anhang.storageKey).toMatch(/^quarantine\//)
// Der Dateiname des Nutzers taucht im Pfad nicht auf.
expect(anhang.storageKey).not.toContain('urlaubsbild')
await expect(readFile(absolutePath(anhang.storageKey))).resolves.toBeDefined()
// Und ausgeliefert wird sie nicht, solange sie dort liegt.
expect(await loadDeliverable(nutzer, id)).toBeNull()
})
it('macht aus einem fehlenden Scanner keine saubere Datei', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'zweites.jpg',
declaredType: 'image/jpeg',
content: await bild(),
})
await runCheckChain(id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
// Ohne clamd: unentschieden, nicht sauber und nicht blockiert.
expect(anhang.scanStatus).toBe('FAILED')
expect(anhang.scanMessage).toBeTruthy()
expect(await loadDeliverable(admin, id)).toBeNull()
})
it('blockiert einen Typ, der nicht auf der Liste steht', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'archiv.zip',
declaredType: 'application/zip',
content: new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0]),
})
await runCheckChain(id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
// Endgültig: das ist kein "wissen wir nicht", das ist ein Nein.
expect(anhang.scanStatus).toBe('BLOCKED')
expect(anhang.scanMessage).toContain('nicht erlaubt')
})
it('blockiert, wenn der gemeldete Typ nicht zum Inhalt passt', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'getarnt.pdf',
declaredType: 'application/pdf',
content: await bild(),
})
await runCheckChain(id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
expect(anhang.scanStatus).toBe('BLOCKED')
})
it('gibt eine unvollständige Prüfung nur für Admins frei, und kodiert dabei neu', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'freizugeben.jpg',
declaredType: 'image/jpeg',
content: await bild(),
})
await runCheckChain(id)
await expect(releaseAttachment(nutzer, id)).rejects.toThrow(AccessError)
await releaseAttachment(admin, id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
expect(anhang.scanStatus).toBe('CLEAN')
expect(anhang.storageKey).toMatch(/^store\//)
expect(anhang.releasedById).toBe(admin.userId)
// Die Freigabe überspringt nur den Scanner. Die Neukodierung lief mit:
// die Metadaten des Bildes sind weg.
expect(anhang.scanMessage).toContain('Ohne Virenprüfung')
const abgelegt = await readFile(absolutePath(anhang.storageKey))
expect(abgelegt.includes(Buffer.from('VERTRAULICHE QUELLE'))).toBe(false)
expect(anhang.thumbKey).toBeTruthy()
expect(anhang.blurhash).toBeTruthy()
expect(anhang.width).toBe(20)
// Jetzt, und erst jetzt, ist sie abrufbar.
expect(await loadDeliverable(nutzer, id)).not.toBeNull()
})
it('lässt eine blockierte Datei auch vom Admin nicht freigeben', async () => {
const blockiert = await db.attachment.findFirstOrThrow({
where: { uploadedById: nutzer.userId, scanStatus: 'BLOCKED' },
select: { id: true },
})
await expect(releaseAttachment(admin, blockiert.id)).rejects.toThrow(AccessError)
})
})