Files
AfiS-Aktensystem/web/app/api/stream/route.ts
T
Harlekin 79d2cc4753 Entzug greift auch im offenen Ereignisstrom
Nachgesehen, was bei Herabstufung, Rollenentzug, Rauswurf und Austritt
passiert. Für Requests war alles abgedeckt: der Worker löscht die Sitzungen,
ein Trigger verwirft die zwischengespeicherten Rechte, getCurrentUser prüft bei
jedem Request Sitzungszeile, isActive und Mitgliedschaft nach. Die Zeile in der
Datenbank ist der Nachweis, nicht das Cookie - und eine Zeile lässt sich
zurückziehen.

Eine Stelle fiel dabei durch: der Ereignisstrom stellt keine neuen Requests. Er
prüfte einmal beim Verbinden und lief danach mit einer eingefrorenen
Rechtekopie weiter. Wer mit offenem Tab hinausgeworfen wurde, bekam weiter
Chatnachrichten zugestellt, mit vollem Text - jeder Seitenaufruf wäre
abgemeldet gewesen, aber es machte ja keiner mehr einen.

/api/stream löst Sitzung und Rechte jetzt bei jedem Ereignis und bei jedem
Herzschlag neu auf. Ändern sich die Rechte, werden auch die erlaubten Themen
neu bestimmt: ein Ereignis zu einem Verlaufseintrag trägt nur eine Kennung, das
Thema ist dort die einzige Schranke. Ist die Sitzung weg, geht ein revoked
hinaus und die Verbindung wird geschlossen - Schliessen allein genügt nicht,
EventSource baut sofort wieder auf.

Fünf Tests für die Web-Hälfte der Kette; die Discord-Hälfte deckte
worker/tests/mitgliedschaft.test.ts schon ab.
2026-09-03 22:41: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)
}
}