Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog - harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst - ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt - elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.
109 lines
3.9 KiB
TypeScript
109 lines
3.9 KiB
TypeScript
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/write'
|
|
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 },
|
|
})
|
|
}
|
|
}
|