/** * WebSocket endpoint for the staff live-chat module — /chat/ws * * One socket per browser tab carries everything live: messages, presence, * heartbeats and activity (active/idle) signals. REST (server/api/chat/*) is * used only for history / unread seed / mark-read. * * Auth + staff gate run in open() using the cookie on the upgrade request, so * a non-staff / limited-role connection is rejected before it can do anything. * * Kill-switch: when runtimeConfig.chatEnabled !== true every connection is * closed immediately. Removing the chat module = delete the chat files; nothing * else references this handler. * * Client frames: { type:'ping' } | { type:'activity', active } * | { type:'read', peer } | { type:'typing', to, typing } * | { type:'message', to, body, clientMsgId, fromName, meta? } * | { type:'react', messageId, emoji, on } * Server frames: connected | presence-snapshot | presence | message * | message-update | reaction | pong | read | read-receipt * | typing | error * (Voice messages are uploaded over REST — server/api/chat/voice — which then * fans out the `message` / `message-update` frames the same way.) */ import { parseCookieHeader, decodeJwt, assertStaffAccess, isStaffUser } from '../../utils/chatAccess' import * as presence from '../../utils/chatPresence' import { insertMessage, markConversationRead, getMessageById, setReaction } from '../../utils/chatDb' import { sendPushToUser, buildChatMessagePayload } from '../../utils/pushNotifier' import { sendFcmToUser } from '../../utils/fcmNotifier' const chatEnabled = () => useRuntimeConfig().chatEnabled === true const getCookieHeader = (peer: any): string => { const h = peer?.request?.headers if (!h) return '' if (typeof h.get === 'function') return h.get('cookie') || '' return h.cookie || '' } // Bundled app fallback: the WebView WS upgrade can't send an Authorization header or app.logship.de // cookies, so the client puts the bearer token in the upgrade URL query (?access_token=...). const getQueryToken = (peer: any): string => { try { const url = peer?.request?.url || '' if (!url) return '' return new URL(url, 'http://localhost').searchParams.get('access_token') || '' } catch { return '' } } const toText = (message: any): string => { if (typeof message === 'string') return message if (message && typeof message.text === 'function') return message.text() return String(message ?? '') } const send = (peer: any, frame: any) => { try { peer.send(JSON.stringify(frame)) } catch { /* gone */ } } // Per-socket send rate-limit (sliding window). In-memory, per-process — a // backstop against a runaway tab, not a security boundary. Cleared on close. const RATE_MAX = 20 const RATE_WINDOW_MS = 10000 const sendTimes = new Map() const allowSend = (peerId: string): boolean => { const now = Date.now() const recent = (sendTimes.get(peerId) || []).filter((t) => now - t < RATE_WINDOW_MS) if (recent.length >= RATE_MAX) { sendTimes.set(peerId, recent); return false } recent.push(now) sendTimes.set(peerId, recent) return true } export default defineWebSocketHandler({ async open(peer) { if (!chatEnabled()) { try { peer.close(3001, 'chat disabled') } catch {}; return } try { const cookies = parseCookieHeader(getCookieHeader(peer)) // Web sends the httpOnly logship_it cookie; the bundled app passes ?access_token (see above). const token = cookies['logship_it'] || getQueryToken(peer) if (!token) throw new Error('no token') const payload = decodeJwt(token) const userId = Number(payload.AD_User_ID) const roleId = Number(payload.AD_Role_ID) if (!userId || !roleId) throw new Error('bad token') await assertStaffAccess(userId, roleId, token) // throws if not staff / limited role presence.addPeer({ peer, peerId: peer.id, userId, roleId, token }) console.log(`[ChatWS] connected user=${userId} peer=${peer.id}`) } catch (err: any) { console.warn('[ChatWS] rejected:', err?.message) try { peer.close(3000, 'unauthorized') } catch {} } }, async message(peer, message) { if (!chatEnabled()) return const meta = presence.getPeer(peer.id) if (!meta) return // not authed (gate failed or still pending) let frame: any try { frame = JSON.parse(toText(message)) } catch { return } if (!frame || typeof frame !== 'object') return switch (frame.type) { case 'ping': presence.touch(peer.id) send(peer, { type: 'pong' }) return case 'activity': presence.setActive(peer.id, !!frame.active) return case 'typing': { // Ephemeral, never stored. Relay to the recipient only if they're online. const to = Number(frame.to) if (!to || to === meta.userId) return if (presence.isConnected(to)) { presence.sendToUser(to, { type: 'typing', from: meta.userId, typing: !!frame.typing }) } return } case 'read': { const peerId = Number(frame.peer) if (!peerId) return try { markConversationRead(meta.userId, peerId, Date.now()) } catch {} // Sync the reader's own other tabs. presence.sendToUser(meta.userId, { type: 'read', peer: peerId }) // Tell the original sender their messages were read (✓✓). From the // sender's perspective `peer` is the reader (the other party). presence.sendToUser(peerId, { type: 'read-receipt', peer: meta.userId }) return } case 'react': { // Toggle an emoji reaction on a message. Only the two parties of that // message may react; relay the change to both so chips update live. const messageId = Number(frame.messageId) const emoji = String(frame.emoji || '').slice(0, 16) if (!messageId || !emoji) return const target = getMessageById(messageId) if (!target) return if (target.senderUserId !== meta.userId && target.recipientUserId !== meta.userId) return const on = frame.on !== false setReaction(messageId, meta.userId, emoji, on) const reaction = { type: 'reaction', messageId, userId: meta.userId, emoji, on, senderUserId: target.senderUserId, recipientUserId: target.recipientUserId } presence.sendToUser(target.senderUserId, reaction) if (target.recipientUserId !== target.senderUserId) presence.sendToUser(target.recipientUserId, reaction) return } case 'message': { if (!allowSend(peer.id)) { send(peer, { type: 'error', message: 'rate limited' }) return } const to = Number(frame.to) const body = (frame.body ?? '').toString().trim() if (!to || !body) return if (to === meta.userId) return // no self-DM // Recipient must be staff. If they're connected they already passed the // gate; otherwise verify (cached). Only reject on a definitive "no". if (!presence.isConnected(to)) { const staff = await isStaffUser(to, meta.token) if (staff === false) { send(peer, { type: 'error', message: 'recipient not allowed' }) return } } // Optional structured payload (e.g. a shared ERP entity card). The human // `body` is still required above, so push text / search / fallbacks all work. const shareMeta = (frame.meta && typeof frame.meta === 'object') ? frame.meta : null const createdAt = Date.now() const trimmed = body.slice(0, 4000) const saved = insertMessage({ senderUserId: meta.userId, recipientUserId: to, body: trimmed, createdAt, clientMsgId: frame.clientMsgId ?? null, meta: shareMeta }) const msg = saved ?? { id: null, senderUserId: meta.userId, recipientUserId: to, body: trimmed, createdAt, readAt: null, clientMsgId: frame.clientMsgId ?? null, meta: shareMeta } const out = { type: 'message', message: msg } presence.sendToUser(to, out) // recipient's tabs (live) presence.sendToUser(meta.userId, out) // sender's tabs (echo → reconcile optimistic) // Native FCM to ALL the recipient's devices, regardless of WS presence. Presence is per-USER, // so a 2nd device that's backgrounded/killed gets NOTHING from the live socket while another // device keeps the user "online" — the push is what reaches it. Foreground devices receive it // silently (no pushNotificationReceived handler → no tray; the live socket already showed it), // so there is no double-notify. sendFcmToUser(to, { title: frame.fromName || 'Neue Nachricht', body: trimmed, data: { senderUserId: String(meta.userId), url: `/chat?chat=${meta.userId}` }, }).catch((e: any) => console.error('[ChatWS] FCM failed:', e?.message)) // Web push (browsers/PWA) stays offline-only — an active browser tab already showed it live. if (!presence.isConnected(to)) { const payload = buildChatMessagePayload(frame.fromName || '', trimmed, meta.userId) sendPushToUser(to, payload).catch((e: any) => console.error('[ChatWS] offline push failed:', e?.message)) } return } default: return } }, close(peer) { presence.removePeer(peer.id) sendTimes.delete(peer.id) }, error(peer) { try { presence.removePeer(peer.id) } catch {} try { sendTimes.delete(peer.id) } catch {} } })