Files
AfiS-Aktensystem/web/app/api/stream/route.ts
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

419 lines
14 KiB
TypeScript

import { getCurrentUser, type CurrentUser } from '@/lib/auth/current'
import { loadSession } from '@/lib/auth/session'
import { resolvePermissions } from '@/lib/auth/effective'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { visibleDepartmentWhere } from '@/lib/access/departmentFilter'
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { resolveCasePreviews, type CasePreview } from '@/lib/chat/preview'
import { extractCaseNumbers, type ChatMessageView } from '@/lib/chat/view'
import { ensureListening, subscribe } from '@/lib/realtime/listener'
import type { StreamEvent, Topic } from '@/lib/realtime/publish'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Gegen Zwischenspeicher und Tunnel, die eine stille Verbindung für tot halten. */
const HEARTBEAT_MS = 25_000
/**
* Ereignisstrom für offene Ansichten.
*
* GET /api/stream?topics=case:<id>,personnel:<id>
*
* 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. Siehe docs/06-chat-und-realtime.md.
*
* Drei Prüfungen, alle nötig:
* 1. **Beim Verbinden**: welche der angefragten Themen darf dieser Nutzer
* überhaupt abonnieren. Wer ein fremdes Thema nennt, bekommt es nicht -
* und erfährt auch nicht, ob es existiert.
* 2. **Je Ereignis**: der Inhalt wird mit den Rechten des Empfängers geladen.
* Eine Umklassifizierung mitten in einer offenen Verbindung wirkt dadurch
* sofort, ohne dass jemand neu laden muss.
* 3. **Laufend**: Sitzung und Rechte werden neu aufgelöst, nicht einmal beim
* Verbinden eingefroren. Eine offene Verbindung ist sonst der eine Ort im
* System, an dem ein Entzug nicht ankommt: der Worker löscht die Sitzung,
* jeder Seitenaufruf ist abgemeldet - und der Strom läuft weiter, weil
* niemand mehr fragt. Siehe docs/03-auth-und-rechte.md.
*/
export async function GET(request: Request): Promise<Response> {
const angemeldet = await getCurrentUser()
if (!angemeldet) return new Response(null, { status: 404 })
// Eigene Konstante: die Verengung auf „nicht null" überlebt den Sprung in
// eine Closure sonst nicht.
const user = angemeldet
const url = new URL(request.url)
const gewuenscht = (url.searchParams.get('topics') ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 20)
let rechte = user.permissions
let erlaubt = await erlaubteThemen(user, gewuenscht)
await ensureListening()
/**
* Ist der Nutzer noch der, der er beim Verbinden war?
*
* Gibt die **aktuellen** Rechte zurück oder `null`, wenn die Sitzung weg
* ist, das Konto stillgelegt wurde oder die Mitgliedschaft fehlt. Die
* Auflösung ist zwischengespeichert und wird bei jeder Rollenänderung über
* `member_changed` verworfen, kostet also im Normalfall nichts.
*/
async function nochBerechtigt(): Promise<EffectivePermissions | null> {
const sitzung = await loadSession(user.sessionId)
if (!sitzung) return null
const konto = await db.user.findUnique({
where: { id: user.id },
select: { isActive: true },
})
if (!konto?.isActive) return null
const frisch = await resolvePermissions(user.id)
if (!frisch.hasMembership && !frisch.isAdmin) return null
return frisch
}
/** Ob sich an den Rechten etwas geändert hat, das die Themen betrifft. */
function abdruck(p: EffectivePermissions): string {
return `${p.clearanceLevel}|${p.isAdmin}|${[...p.permissions].sort().join(',')}`
}
const encoder = new TextEncoder()
let heartbeat: ReturnType<typeof setInterval> | null = null
let unsubscribe: (() => void) | null = null
let offen = true
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown, id?: string) => {
if (!offen) return
try {
const zeilen = [
...(id ? [`id: ${id}`] : []),
`event: ${event}`,
`data: ${JSON.stringify(data)}`,
'',
'',
]
controller.enqueue(encoder.encode(zeilen.join('\n')))
} catch {
offen = false
}
}
// Verpasstes nachliefern. Der Browser schickt beim Wiederverbinden von
// selbst die zuletzt gesehene Kennung mit; die Kennungen sind UUIDv7 und
// damit zeitlich sortiert, ein Vergleich darauf reicht.
const seit = request.headers.get('last-event-id') ?? url.searchParams.get('since')
if (seit) await nachliefern(user, erlaubt, seit, send)
send('ready', { topics: erlaubt })
const schliessen = () => {
offen = false
if (heartbeat) clearInterval(heartbeat)
unsubscribe?.()
try {
controller.close()
} catch {
// schon zu
}
}
heartbeat = setInterval(() => {
if (!offen) return
void (async () => {
// Der Herzschlag ist zugleich die regelmässige Rückfrage: wer
// hinausgeworfen wurde, verliert spätestens hier die Verbindung.
const frisch = await nochBerechtigt()
if (!frisch) {
send('revoked', {})
schliessen()
return
}
if (abdruck(frisch) !== abdruck(rechte)) {
rechte = frisch
erlaubt = await erlaubteThemen({ ...user, permissions: frisch }, gewuenscht)
}
try {
controller.enqueue(encoder.encode(': ping\n\n'))
} catch {
offen = false
}
})()
}, HEARTBEAT_MS)
unsubscribe = subscribe((event) => {
void (async () => {
const frisch = await nochBerechtigt()
if (!frisch) {
send('revoked', {})
schliessen()
return
}
// Rechte geändert: die erlaubten Themen gelten nicht mehr. Sie neu
// zu bestimmen ist wichtiger als die eine Zustellung - ein Ereignis
// trägt bei Verlaufseinträgen nur eine Kennung, das Thema ist dort
// die **einzige** Schranke.
if (abdruck(frisch) !== abdruck(rechte)) {
rechte = frisch
erlaubt = await erlaubteThemen({ ...user, permissions: frisch }, gewuenscht)
}
if (!erlaubt.includes(event.t)) return
await zustellen({ ...user, permissions: rechte }, event, send)
})()
})
request.signal.addEventListener('abort', () => {
offen = false
try {
controller.close()
} catch {
// schon zu
}
})
},
cancel() {
offen = false
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',
},
})
}
/**
* Filtert die angefragten Themen auf die, die der Nutzer sehen darf.
*
* Das eigene Nutzerthema ist immer dabei, alles andere muss belegt werden.
*/
async function erlaubteThemen(user: CurrentUser, gewuenscht: string[]): Promise<Topic[]> {
const themen: Topic[] = [`user:${user.id}`]
const fallIds = gewuenscht
.filter((t) => t.startsWith('case:'))
.map((t) => t.slice(5))
.filter(Boolean)
const personalIds = gewuenscht
.filter((t) => t.startsWith('personnel:'))
.map((t) => t.slice(10))
.filter(Boolean)
if (fallIds.length > 0) {
const sichtbar = await db.case.findMany({
where: { AND: [visibleCaseWhere(user.permissions), { id: { in: fallIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((akte) => `case:${akte.id}` as Topic))
}
// Personalakten setzen neben der Stufe ein eigenes Recht voraus.
const darfPersonal =
user.permissions.isAdmin || user.permissions.permissions.has('personnel.read')
if (personalIds.length > 0 && darfPersonal) {
const sichtbar = await db.personnelFile.findMany({
where: { AND: [visiblePersonnelWhere(user.permissions), { id: { in: personalIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((akte) => `personnel:${akte.id}` as Topic))
}
const abteilungIds = gewuenscht
.filter((t) => t.startsWith('department:'))
.map((t) => t.slice(11))
.filter(Boolean)
if (abteilungIds.length > 0) {
const sichtbar = await db.department.findMany({
where: { AND: [visibleDepartmentWhere(user.permissions), { id: { in: abteilungIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((abteilung) => `department:${abteilung.id}` as Topic))
}
return themen
}
type Send = (event: string, data: unknown, id?: string) => void
/** Was seit `seit` in den abonnierten Themen passiert ist. */
async function nachliefern(
user: CurrentUser,
themen: Topic[],
seit: string,
send: Send,
): Promise<void> {
const fallIds = themen.filter((t) => t.startsWith('case:')).map((t) => t.slice(5))
const personalIds = themen.filter((t) => t.startsWith('personnel:')).map((t) => t.slice(10))
const abteilungIds = themen.filter((t) => t.startsWith('department:')).map((t) => t.slice(11))
if (fallIds.length === 0 && personalIds.length === 0 && abteilungIds.length === 0) return
try {
const verpasst = await db.chatMessage.findMany({
where: {
AND: [
visibleMessageWhere(user.permissions),
{ id: { gt: seit } },
{
OR: [
{ caseId: { in: fallIds } },
{ personnelFileId: { in: personalIds } },
{ departmentId: { in: abteilungIds } },
],
},
],
},
orderBy: { id: 'asc' },
take: 200,
select: NACHRICHT,
})
for (const angereichert of await anreichern(user, verpasst)) {
send('chat.new', angereichert, angereichert.id)
}
} catch (error) {
console.error('[stream] Nachlieferung fehlgeschlagen', error)
}
}
const NACHRICHT = {
id: true,
body: true,
authorId: true,
replyToId: true,
editedAt: true,
withdrawnAt: true,
adoptedEntryId: true,
mentions: true,
createdAt: true,
caseId: true,
personnelFileId: true,
departmentId: true,
} as const
/**
* Ergänzt, was die Anzeige braucht und was **je Empfänger verschieden** ist:
* den Namen des Autors und die Vorschaukarten zu erwähnten Aktenzeichen. Die
* Karten werden mit den Rechten des Empfängers aufgelöst, deshalb hier und
* nicht beim Absender.
*/
function vorschauenFuer(
body: string,
alle: Map<string, CasePreview>,
): Record<string, CasePreview> {
const treffer: Record<string, CasePreview> = {}
for (const nummer of extractCaseNumbers(body)) {
const vorschau = alle.get(nummer)
if (vorschau) treffer[nummer] = vorschau
}
return treffer
}
async function anreichern(
user: CurrentUser,
nachrichten: {
id: string
body: string
authorId: string
replyToId: string | null
editedAt: Date | null
withdrawnAt: Date | null
adoptedEntryId: string | null
mentions: string[]
createdAt: Date
}[],
): Promise<ChatMessageView[]> {
if (nachrichten.length === 0) return []
const [autoren, vorschauen] = await Promise.all([
db.user.findMany({
where: { id: { in: [...new Set(nachrichten.map((n) => n.authorId))] } },
select: { id: true, displayName: true, username: true },
}),
resolveCasePreviews(
user.permissions,
nachrichten.flatMap((n) => extractCaseNumbers(n.body)),
),
])
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
return nachrichten.map((nachricht) => ({
id: nachricht.id,
body: nachricht.withdrawnAt ? '' : nachricht.body,
authorId: nachricht.authorId,
authorName: nameVon.get(nachricht.authorId) ?? nachricht.authorId,
replyToId: nachricht.replyToId,
editedAt: nachricht.editedAt?.toISOString() ?? null,
withdrawnAt: nachricht.withdrawnAt?.toISOString() ?? null,
adoptedEntryId: nachricht.adoptedEntryId,
createdAt: nachricht.createdAt.toISOString(),
previews: vorschauenFuer(nachricht.body, vorschauen),
}))
}
/**
* Lädt den Inhalt zu einem Ereignis **mit den Rechten des Empfängers**.
*
* Findet die Abfrage nichts, wird nichts geschickt: weder Inhalt noch die
* Kennung. Sonst verriete schon die Kennung, dass es etwas gibt.
*/
async function zustellen(user: CurrentUser, event: StreamEvent, send: Send): Promise<void> {
try {
if (event.k.startsWith('chat.')) {
const nachricht = await db.chatMessage.findFirst({
where: { AND: [visibleMessageWhere(user.permissions), { id: event.i }] },
select: NACHRICHT,
})
if (!nachricht) return
const [angereichert] = await anreichern(user, [nachricht])
if (angereichert) send(event.k, angereichert, angereichert.id)
return
}
if (event.k === 'attachment.scanned') {
const anhang = await db.attachment.findFirst({
where: { AND: [visibleAttachmentWhere(user.permissions), { id: event.i }] },
select: {
id: true,
scanStatus: true,
scanMessage: true,
mimeType: true,
sizeBytes: true,
thumbKey: true,
blurhash: true,
width: true,
height: true,
},
})
if (anhang) {
send('attachment.scanned', { ...anhang, hasThumb: anhang.thumbKey !== null, thumbKey: undefined })
}
return
}
// Verlaufseinträge und Kopfdaten: nur die Kennung. Die Ansicht lädt
// daraufhin nach, statt dass hier ein zweiter Renderpfad entsteht.
send(event.k, { id: event.i })
} catch (error) {
console.error('[stream] Ereignis nicht zugestellt', error)
}
}