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.
419 lines
14 KiB
TypeScript
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)
|
|
}
|
|
}
|