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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
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)
|
|
}
|
|
}
|