import { getCurrentUser } from '@/lib/auth/current' import { db } from '@/lib/db' import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter' import { ensureListening, subscribe } from '@/lib/realtime/listener' import { ATTACHMENT_CHANNEL } from '@/lib/realtime/publish' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' /** Damit Zwischenspeicher und Tunnel die Verbindung nicht für tot halten. */ const HEARTBEAT_MS = 25_000 /** * Ereignisstrom für offene Ansichten. * * Nur `attachment.scanned` bislang; der Aktenchat kommt in Phase 6 dazu und * benutzt denselben Weg. 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. * * **Die Sichtbarkeitsprüfung läuft je Ereignis.** Ein Ereignis über einen * Anhang, dessen Akte der Empfänger nicht sehen darf, wird nicht zugestellt — * sonst verriete schon die Kennung die Existenz. */ export async function GET(request: Request): Promise { const user = await getCurrentUser() if (!user) return new Response(null, { status: 404 }) await ensureListening() const encoder = new TextEncoder() let heartbeat: ReturnType | null = null let unsubscribe: (() => void) | null = null const stream = new ReadableStream({ start(controller) { const send = (event: string, data: unknown) => { try { controller.enqueue( encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), ) } catch { // Verbindung schon zu; das Aufräumen übernimmt cancel(). } } send('ready', { at: new Date().toISOString() }) heartbeat = setInterval(() => { try { controller.enqueue(encoder.encode(': ping\n\n')) } catch { // s. o. } }, HEARTBEAT_MS) unsubscribe = subscribe((event) => { if (event.channel !== ATTACHMENT_CHANNEL) return void zustellen(event.payload, user.permissions, send) }) request.signal.addEventListener('abort', () => { controller.close() }) }, cancel() { 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', }, }) } async function zustellen( attachmentId: string, permissions: Awaited> extends infer U ? U extends { permissions: infer P } ? P : never : never, send: (event: string, data: unknown) => void, ): Promise { try { const anhang = await db.attachment.findFirst({ where: { AND: [visibleAttachmentWhere(permissions), { id: attachmentId }] }, select: { id: true, scanStatus: true, scanMessage: true, mimeType: true, sizeBytes: true, thumbKey: true, blurhash: true, width: true, height: true, }, }) if (!anhang) return send('attachment.scanned', { ...anhang, hasThumb: anhang.thumbKey !== null, thumbKey: undefined }) } catch (error) { console.error('[events] Ereignis nicht zugestellt', error) } }