From 32369180127f048c1e52430b6b34f5a499ffe691 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 21 Mar 2026 10:08:44 +0100 Subject: [PATCH] refactor: server hardening + client decomposition + protocol resilience Server: - Split monolithic voice_handlers.go into voice_join/leave/controls/broadcast - Add metrics endpoint (admin-IP-restricted /api/v1/metrics) - Add orphaned attachment cleanup in maintenance loop - Add sentinel errors (db/errors.go, ws/errors.go) - Add ring buffer for event replay on reconnect - Add heartbeat monitoring with stale connection sweep - Improve hub with panic recovery, graceful shutdown, seq tracking - Typed message structs replace raw map[string]interface{} Client: - Decompose MainPage into ChatArea + SidebarArea controllers - Add disposable.ts lifecycle management pattern - Add member list right-click context menu (kick/ban/role) - Tighten CSP (media-src, font-src, object-src, base-uri) - Improve store with shallowEqual, 500-msg cap, batch updates - Add search API endpoint wiring - Fix LiveKit session cleanup and reconnection Docs: - Add CODEMAPS for architecture, backend, frontend, data, deps - Add protocol-schema.json (machine-readable, 36 message types) - Add platform research report - Update PROTOCOL.md with seq/replay fields --- Client/tauri-client/src-tauri/tauri.conf.json | 2 +- .../tauri-client/src/components/MemberList.ts | 100 ++++- .../src/components/MessageList.ts | 1 + .../src/components/TypingIndicator.ts | 11 +- Client/tauri-client/src/components/UserBar.ts | 23 +- .../src/components/message-list/media.ts | 4 +- .../src/components/message-list/renderers.ts | 13 + .../src/components/settings/LogsTab.ts | 13 +- Client/tauri-client/src/lib/api.ts | 30 ++ Client/tauri-client/src/lib/disposable.ts | 67 +++ Client/tauri-client/src/lib/livekitSession.ts | 25 +- Client/tauri-client/src/lib/profiles.ts | 2 +- Client/tauri-client/src/lib/store.ts | 45 +- Client/tauri-client/src/lib/types.ts | 1 + Client/tauri-client/src/lib/ws.ts | 14 +- Client/tauri-client/src/pages/MainPage.ts | 295 ++----------- .../src/pages/main-page/ChannelController.ts | 28 +- .../src/pages/main-page/ChatArea.ts | 196 +++++++++ .../src/pages/main-page/SidebarArea.ts | 211 +++++++++ .../tauri-client/src/stores/messages.store.ts | 54 ++- Client/tauri-client/tests/unit/chat.test.ts | 13 + .../tests/unit/member-list.test.ts | 12 +- .../tests/unit/message-list.test.ts | 2 + .../tauri-client/tests/unit/renderers.test.ts | 2 + Client/tauri-client/tests/unit/store.test.ts | 28 +- Server/api/metrics_handler.go | 40 ++ Server/api/router.go | 4 + Server/db/admin_queries.go | 2 +- Server/db/attachment_queries.go | 38 ++ Server/db/auth_queries.go | 2 +- Server/db/db.go | 20 + Server/db/errors.go | 18 + Server/db/message_queries.go | 10 +- Server/main.go | 25 +- Server/scripts/voice-test.sh | 79 ++++ Server/storage/storage.go | 5 +- Server/ws/client.go | 40 +- Server/ws/errors.go | 19 + Server/ws/handlers.go | 102 +++-- Server/ws/hub.go | 166 ++++++- Server/ws/hub_test.go | 25 +- Server/ws/messages.go | 410 ++++++++++------- Server/ws/messages_test.go | 10 - Server/ws/ringbuffer.go | 78 ++++ Server/ws/serve.go | 66 ++- Server/ws/voice_broadcast.go | 41 ++ Server/ws/voice_controls.go | 153 +++++++ Server/ws/voice_handlers.go | 335 -------------- Server/ws/voice_join.go | 128 ++++++ Server/ws/voice_leave.go | 32 ++ Server/ws/ws_integration_test.go | 234 ++++++++++ docs/CODEMAPS/architecture.md | 57 +++ docs/CODEMAPS/backend.md | 79 ++++ docs/CODEMAPS/data.md | 52 +++ docs/CODEMAPS/dependencies.md | 53 +++ docs/CODEMAPS/frontend.md | 75 ++++ docs/brain/06-Specs/PROTOCOL.md | 21 +- docs/protocol-schema.json | 349 +++++++++++++++ docs/research/2026-03-20-platform-research.md | 412 ++++++++++++++++++ 59 files changed, 3455 insertions(+), 917 deletions(-) create mode 100644 Client/tauri-client/src/lib/disposable.ts create mode 100644 Client/tauri-client/src/pages/main-page/ChatArea.ts create mode 100644 Client/tauri-client/src/pages/main-page/SidebarArea.ts create mode 100644 Server/api/metrics_handler.go create mode 100644 Server/db/errors.go create mode 100644 Server/scripts/voice-test.sh create mode 100644 Server/ws/errors.go create mode 100644 Server/ws/ringbuffer.go create mode 100644 Server/ws/voice_broadcast.go create mode 100644 Server/ws/voice_controls.go delete mode 100644 Server/ws/voice_handlers.go create mode 100644 Server/ws/voice_join.go create mode 100644 Server/ws/voice_leave.go create mode 100644 docs/CODEMAPS/architecture.md create mode 100644 docs/CODEMAPS/backend.md create mode 100644 docs/CODEMAPS/data.md create mode 100644 docs/CODEMAPS/dependencies.md create mode 100644 docs/CODEMAPS/frontend.md create mode 100644 docs/protocol-schema.json create mode 100644 docs/research/2026-03-20-platform-research.md diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index 410031ec..0f1962fa 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ ], "withGlobalTauri": true, "security": { - "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; frame-src https://www.youtube.com https://youtube.com" + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com" } }, "bundle": { diff --git a/Client/tauri-client/src/components/MemberList.ts b/Client/tauri-client/src/components/MemberList.ts index 7dae5fb3..69bea8fd 100644 --- a/Client/tauri-client/src/components/MemberList.ts +++ b/Client/tauri-client/src/components/MemberList.ts @@ -1,13 +1,25 @@ /** * MemberList component — shows server members grouped by role with online status. * Subscribes to membersStore for reactive updates. + * Right-click context menu for admin actions (kick, ban, role change). */ import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; +import { Disposable } from "@lib/disposable"; import { membersStore, type Member } from "@stores/members.store"; +import { authStore } from "@stores/auth.store"; +import { createMemberContextMenu } from "@components/AdminActions"; import type { UserStatus } from "@lib/types"; +/** Options for configuring admin action callbacks on the member list. */ +export interface MemberListOptions { + readonly currentUserRole: string; + readonly onKick: (userId: number, username: string) => Promise; + readonly onBan: (userId: number, username: string) => Promise; + readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise; +} + /** Ordered role groups with display names and CSS color variables. */ const ROLE_GROUPS: readonly { readonly role: string; @@ -39,7 +51,28 @@ function statusColor(status: UserStatus): string { } } -function createMemberItem(member: Member, colorVar: string): HTMLDivElement { +let activeMenu: { element: HTMLDivElement; destroy(): void } | null = null; + +function closeActiveMenu(): void { + if (activeMenu !== null) { + activeMenu.destroy(); + activeMenu = null; + } +} + +function handleOutsideClick(e: MouseEvent): void { + if (activeMenu !== null && !activeMenu.element.contains(e.target as Node)) { + closeActiveMenu(); + document.removeEventListener("mousedown", handleOutsideClick); + } +} + +function createMemberItem( + member: Member, + colorVar: string, + opts: MemberListOptions, + signal: AbortSignal, +): HTMLDivElement { const item = createElement("div", { class: member.status === "offline" ? "member-item offline" : "member-item", "data-testid": `member-${member.id}`, @@ -65,10 +98,51 @@ function createMemberItem(member: Member, colorVar: string): HTMLDivElement { setText(name, member.username); appendChildren(item, avatar, name); + + // Context menu for admin actions + item.addEventListener("contextmenu", (e) => { + e.preventDefault(); + + // Don't show context menu for yourself + const currentUserId = authStore.getState().user?.id ?? 0; + if (member.id === currentUserId) return; + + // Only admins and owners can use admin actions + const role = opts.currentUserRole.toLowerCase(); + if (role !== "owner" && role !== "admin") return; + + closeActiveMenu(); + document.removeEventListener("mousedown", handleOutsideClick); + + const availableRoles = ["admin", "moderator", "member"]; + + activeMenu = createMemberContextMenu({ + userId: member.id, + username: member.username, + currentRole: member.role.toLowerCase(), + availableRoles, + onKick: () => opts.onKick(member.id, member.username), + onBan: () => opts.onBan(member.id, member.username), + onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole), + }); + + // Position at mouse + activeMenu.element.style.position = "fixed"; + activeMenu.element.style.left = `${e.clientX}px`; + activeMenu.element.style.top = `${e.clientY}px`; + activeMenu.element.style.zIndex = "1000"; + document.body.appendChild(activeMenu.element); + + // Close on outside click (deferred so this click doesn't close it) + setTimeout(() => { + document.addEventListener("mousedown", handleOutsideClick); + }, 0); + }, { signal }); + return item; } -function renderList(root: HTMLDivElement): void { +function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: AbortSignal): void { clearChildren(root); const state = membersStore.getState(); @@ -89,25 +163,25 @@ function renderList(root: HTMLDivElement): void { root.appendChild(header); for (const member of groupMembers) { - root.appendChild(createMemberItem(member, group.colorVar)); + root.appendChild(createMemberItem(member, group.colorVar, opts, signal)); } } } -export function createMemberList(): MountableComponent { - const ac = new AbortController(); +export function createMemberList(opts: MemberListOptions): MountableComponent { + const disposable = new Disposable(); let root: HTMLDivElement | null = null; - let unsubscribe: (() => void) | null = null; function mount(container: Element): void { root = createElement("div", { class: "member-list", "data-testid": "member-list" }); - renderList(root); + renderList(root, opts, disposable.signal); - unsubscribe = membersStore.subscribeSelector( + disposable.onStoreChange( + membersStore, (s) => s.members, () => { if (root !== null) { - renderList(root); + renderList(root, opts, disposable.signal); } }, ); @@ -116,11 +190,9 @@ export function createMemberList(): MountableComponent { } function destroy(): void { - ac.abort(); - if (unsubscribe !== null) { - unsubscribe(); - unsubscribe = null; - } + closeActiveMenu(); + document.removeEventListener("mousedown", handleOutsideClick); + disposable.destroy(); if (root !== null) { root.remove(); root = null; diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index f652b786..78c24a8b 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -25,6 +25,7 @@ export interface MessageListOptions { readonly onEditClick: (messageId: number) => void; readonly onDeleteClick: (messageId: number) => void; readonly onReactionClick: (messageId: number, emoji: string) => void; + readonly onPinClick: (messageId: number, channelId: number, currentlyPinned: boolean) => void; } // -- Constants ---------------------------------------------------------------- diff --git a/Client/tauri-client/src/components/TypingIndicator.ts b/Client/tauri-client/src/components/TypingIndicator.ts index 7e5e76a6..a39198dc 100644 --- a/Client/tauri-client/src/components/TypingIndicator.ts +++ b/Client/tauri-client/src/components/TypingIndicator.ts @@ -6,6 +6,7 @@ import { createElement, appendChildren, setText, clearChildren } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; +import { Disposable } from "@lib/disposable"; import { membersStore, getTypingUsers } from "@stores/members.store"; import type { Member } from "@stores/members.store"; @@ -27,8 +28,8 @@ function formatTypingText(users: readonly Member[]): string { export function createTypingIndicator( options: TypingIndicatorOptions, ): MountableComponent { + const disposable = new Disposable(); let root: HTMLDivElement | null = null; - let unsubscribe: (() => void) | null = null; function updateFromState(): void { if (root === null) return; @@ -61,7 +62,8 @@ export function createTypingIndicator( updateFromState(); - unsubscribe = membersStore.subscribeSelector( + disposable.onStoreChange( + membersStore, (s) => s.typingUsers, () => { updateFromState(); }, ); @@ -70,10 +72,7 @@ export function createTypingIndicator( } function destroy(): void { - if (unsubscribe !== null) { - unsubscribe(); - unsubscribe = null; - } + disposable.destroy(); if (root !== null) { root.remove(); root = null; diff --git a/Client/tauri-client/src/components/UserBar.ts b/Client/tauri-client/src/components/UserBar.ts index de7bb0c8..c8800590 100644 --- a/Client/tauri-client/src/components/UserBar.ts +++ b/Client/tauri-client/src/components/UserBar.ts @@ -5,15 +5,15 @@ import { createElement, appendChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; +import { Disposable } from "@lib/disposable"; import { authStore } from "@stores/auth.store"; import { openSettings } from "@stores/ui.store"; export type UserBarOptions = Record; export function createUserBar(options?: UserBarOptions): MountableComponent { - const ac = new AbortController(); + const disposable = new Disposable(); let root: HTMLDivElement | null = null; - let unsubscribe: (() => void) | null = null; // Element references for targeted updates let avatarEl: HTMLDivElement | null = null; @@ -66,13 +66,9 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { "\u2699", ); - settingsBtn.addEventListener( - "click", - () => { - openSettings(); - }, - { signal: ac.signal }, - ); + disposable.onEvent(settingsBtn, "click", () => { + openSettings(); + }); buttons.appendChild(settingsBtn); appendChildren(root, avatarEl, info, buttons); @@ -81,7 +77,8 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { updateFromState(); // Subscribe to auth changes - unsubscribe = authStore.subscribeSelector( + disposable.onStoreChange( + authStore, (s) => s.user, () => updateFromState(), ); @@ -90,11 +87,7 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { } function destroy(): void { - ac.abort(); - if (unsubscribe !== null) { - unsubscribe(); - unsubscribe = null; - } + disposable.destroy(); if (root !== null) { root.remove(); root = null; diff --git a/Client/tauri-client/src/components/message-list/media.ts b/Client/tauri-client/src/components/message-list/media.ts index f46f1335..2fa8424a 100644 --- a/Client/tauri-client/src/components/message-list/media.ts +++ b/Client/tauri-client/src/components/message-list/media.ts @@ -159,7 +159,7 @@ export function renderInlineImage(url: string): HTMLDivElement { if (isGifUrl(url)) { attrs.crossorigin = "anonymous"; } - const img = createElement("img", attrs) as unknown as HTMLImageElement; + const img = createElement("img", attrs); // Observe GIFs for visibility-based freeze/unfreeze + play/pause button if (isGifUrl(url)) { @@ -169,7 +169,7 @@ export function renderInlineImage(url: string): HTMLDivElement { img.addEventListener("click", () => { const lightbox = createElement("div", { class: "image-lightbox" }); const lbWrap = createElement("div", { class: "image-lightbox-wrap" }); - const lbImg = createElement("img", { src: url, alt: "Image" }) as unknown as HTMLImageElement; + const lbImg = createElement("img", { src: url, alt: "Image" }); const closeBtn = createElement("button", { class: "image-lightbox-close" }, "\u00D7"); lbWrap.appendChild(lbImg); diff --git a/Client/tauri-client/src/components/message-list/renderers.ts b/Client/tauri-client/src/components/message-list/renderers.ts index d964d9c7..fddcbba6 100644 --- a/Client/tauri-client/src/components/message-list/renderers.ts +++ b/Client/tauri-client/src/components/message-list/renderers.ts @@ -202,6 +202,19 @@ export function renderMessage( replyBtn.addEventListener("click", () => opts.onReplyClick(msg.id), { signal }); actionsBar.appendChild(replyBtn); + const pinBtn = createElement( + "button", + { "data-testid": `msg-pin-${msg.id}` }, + msg.pinned ? "\uD83D\uDCCC\u2717" : "\uD83D\uDCCC", + ); + pinBtn.title = msg.pinned ? "Unpin" : "Pin"; + pinBtn.addEventListener( + "click", + () => opts.onPinClick(msg.id, msg.channelId, msg.pinned), + { signal }, + ); + actionsBar.appendChild(pinBtn); + if (msg.user.id === opts.currentUserId) { const editBtn = createElement("button", { "data-testid": `msg-edit-${msg.id}` }, "\u270E"); editBtn.title = "Edit"; diff --git a/Client/tauri-client/src/components/settings/LogsTab.ts b/Client/tauri-client/src/components/settings/LogsTab.ts index 4d3f538f..c5a0f91d 100644 --- a/Client/tauri-client/src/components/settings/LogsTab.ts +++ b/Client/tauri-client/src/components/settings/LogsTab.ts @@ -61,7 +61,7 @@ export function createLogsTab( signal: AbortSignal, ): LogsTabHandle { let logListEl: HTMLDivElement | null = null; - let logFilterLevel: LogLevel | "all" = "all"; + let logFilterLevel: LogLevel | "all" = (localStorage.getItem("logs_filter_level") as LogLevel | "all") ?? "all"; let unsubLogListener: (() => void) | null = null; function renderLogEntries(): void { @@ -108,8 +108,10 @@ export function createLogsTab( if (lvl === logFilterLevel) opt.setAttribute("selected", ""); filterSelect.appendChild(opt); } + filterSelect.value = logFilterLevel; filterSelect.addEventListener("change", () => { logFilterLevel = filterSelect.value as LogLevel | "all"; + localStorage.setItem("logs_filter_level", logFilterLevel); renderLogEntries(); }, { signal }); @@ -123,8 +125,15 @@ export function createLogsTab( const opt = createElement("option", { value: lvl }, lvl.toUpperCase()); levelSelect.appendChild(opt); } + const savedMinLevel = localStorage.getItem("logs_min_level") as LogLevel | null; + if (savedMinLevel !== null) { + levelSelect.value = savedMinLevel; + setLogLevel(savedMinLevel); + } levelSelect.addEventListener("change", () => { - setLogLevel(levelSelect.value as LogLevel); + const level = levelSelect.value as LogLevel; + setLogLevel(level); + localStorage.setItem("logs_min_level", level); }, { signal }); // Copy All button diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index 39d7968e..049a8257 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -518,6 +518,36 @@ export function createApiClient( ): Promise { return adminRequest("DELETE", `/channels/${id}`, undefined, signal); }, + + // ── Admin: Members ────────────────────────────────────── + + adminKickMember( + userId: number, + signal?: AbortSignal, + ): Promise { + return adminRequest("DELETE", `/users/${userId}/sessions`, undefined, signal); + }, + + adminBanMember( + userId: number, + reason?: string, + signal?: AbortSignal, + ): Promise { + return adminRequest("PATCH", `/users/${userId}`, { + banned: true, + ban_reason: reason ?? "", + }, signal); + }, + + adminChangeRole( + userId: number, + roleId: number, + signal?: AbortSignal, + ): Promise { + return adminRequest("PATCH", `/users/${userId}`, { + role_id: roleId, + }, signal); + }, }; } diff --git a/Client/tauri-client/src/lib/disposable.ts b/Client/tauri-client/src/lib/disposable.ts new file mode 100644 index 00000000..7c9ffd09 --- /dev/null +++ b/Client/tauri-client/src/lib/disposable.ts @@ -0,0 +1,67 @@ +/** + * Disposable — automatic cleanup manager for component lifecycles. + * Tracks subscriptions, event listeners, and intervals. Calling destroy() + * flushes all cleanups at once, preventing memory leaks from forgotten unsubs. + */ + +type CleanupFn = () => void; + +export class Disposable { + private readonly cleanups: CleanupFn[] = []; + private readonly ac = new AbortController(); + private destroyed = false; + + /** The AbortSignal for this disposable — pass to addEventListener({ signal }). */ + get signal(): AbortSignal { + return this.ac.signal; + } + + /** Register an arbitrary cleanup function. */ + addCleanup(fn: CleanupFn): void { + if (this.destroyed) { + fn(); + return; + } + this.cleanups.push(fn); + } + + /** Subscribe to a store with a selector, auto-tracked for cleanup. */ + onStoreChange( + store: { subscribeSelector(selector: (s: S) => R, callback: (val: R) => void): () => void }, + selector: (s: S) => R, + callback: (val: R) => void, + ): void { + const unsub = store.subscribeSelector(selector, callback); + this.addCleanup(unsub); + } + + /** Add an event listener auto-tracked via AbortController signal. */ + onEvent( + target: HTMLElement | Window | Document, + event: K, + handler: (e: HTMLElementEventMap[K]) => void, + options?: AddEventListenerOptions, + ): void { + target.addEventListener(event, handler as EventListener, { + ...options, + signal: this.ac.signal, + }); + } + + /** Set an interval, auto-tracked for cleanup. */ + onInterval(fn: () => void, ms: number): void { + const id = setInterval(fn, ms); + this.addCleanup(() => clearInterval(id)); + } + + /** Flush all cleanups: abort listeners, run cleanup fns. */ + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.ac.abort(); + for (const fn of this.cleanups) { + fn(); + } + this.cleanups.length = 0; + } +} diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index df3d3ad9..75c4e677 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -534,12 +534,25 @@ export async function handleVoiceToken( // Enable microphone: use RNNoise if Enhanced Noise Suppression is on const enhancedNS = loadPref("enhancedNoiseSuppression", false); - if (enhancedNS) { - await publishWithNoiseSuppression(); - log.info("Published mic with RNNoise noise suppression"); - } else { - await room.localParticipant.setMicrophoneEnabled(true); - log.info("Published mic via LiveKit native capture"); + try { + if (enhancedNS) { + await publishWithNoiseSuppression(); + log.info("Published mic with RNNoise noise suppression"); + } else { + await room.localParticipant.setMicrophoneEnabled(true); + log.info("Published mic via LiveKit native capture"); + } + } catch (micErr) { + if (micErr instanceof DOMException && micErr.name === "NotAllowedError") { + log.warn("Microphone permission denied — joined in listen-only mode"); + onErrorCallback?.("Microphone permission denied — joined in listen-only mode"); + } else if (micErr instanceof DOMException && micErr.name === "NotFoundError") { + log.warn("No microphone found — joined in listen-only mode"); + onErrorCallback?.("No microphone found — joined in listen-only mode"); + } else { + log.warn("Microphone unavailable — joined in listen-only mode", micErr); + onErrorCallback?.("Microphone unavailable — joined in listen-only mode"); + } } // Apply saved input device diff --git a/Client/tauri-client/src/lib/profiles.ts b/Client/tauri-client/src/lib/profiles.ts index 6244093a..770f8cdc 100644 --- a/Client/tauri-client/src/lib/profiles.ts +++ b/Client/tauri-client/src/lib/profiles.ts @@ -185,7 +185,7 @@ export function createProfileManager( const store = createStore(initialState); // Resolve which fetch to use: injected mock, Tauri plugin, or global - const doFetch: FetchFn = fetchFn ?? (fetch as unknown as FetchFn); + const doFetch: FetchFn = fetchFn ?? fetch; // ── Helpers ──────────────────────────────────────────────── diff --git a/Client/tauri-client/src/lib/store.ts b/Client/tauri-client/src/lib/store.ts index 1b80aac8..d2f357f8 100644 --- a/Client/tauri-client/src/lib/store.ts +++ b/Client/tauri-client/src/lib/store.ts @@ -56,6 +56,49 @@ export interface Store { flush(): void; } +/** Shallow-compare two values. Returns true if they are structurally equal + * at the top level (same keys/length and identical element references). */ +function shallowEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; + + // Map: compare by size and entry identity + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false; + for (const [key, val] of a) { + if (!b.has(key) || b.get(key) !== val) return false; + } + return true; + } + + // Set: compare by size and membership + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false; + for (const val of a) { + if (!b.has(val)) return false; + } + return true; + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if ((a as Record)[key] !== (b as Record)[key]) return false; + } + return true; +} + +export { shallowEqual }; + export function createStore(initialState: T): Store { let state: T = initialState; const listeners: Set<(state: T) => void> = new Set(); @@ -88,7 +131,7 @@ export function createStore(initialState: T): Store { function subscribeSelector( selector: (state: T) => S, listener: (selected: S) => void, - isEqual: (a: S, b: S) => boolean = (a, b) => a === b, + isEqual: (a: S, b: S) => boolean = (a, b) => shallowEqual(a, b), ): () => void { let prev: S = selector(state); return subscribe((newState) => { diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index 32f81a1b..32544b5e 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -312,6 +312,7 @@ export interface ErrorPayload { export interface AuthPayload { readonly token: string; + readonly last_seq?: number; } export interface ChatSendPayload { diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index 687eb6ab..ed26be75 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -78,6 +78,7 @@ export function createWsClient() { let intentionalClose = false; let certMismatchBlock = false; // blocks reconnect on TOFU mismatch let proxyOpen = false; + let lastSeq = 0; // Tauri event unsubscribe functions const eventUnsubs: Array<() => void> = []; @@ -151,14 +152,20 @@ export function createWsClient() { return; } - let parsed: { type?: string; payload?: unknown; id?: string }; + let parsed: { type?: string; payload?: unknown; id?: string; seq?: number }; try { - parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string }; + parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string; seq?: number }; } catch { log.warn("Failed to parse WS message", { data: raw }); return; } + // Track the highest sequence number for reconnection replay. + const seq = typeof parsed.seq === "number" ? parsed.seq : 0; + if (seq > lastSeq) { + lastSeq = seq; + } + // Server pong messages have no payload — silently ignore. if (parsed.type === "pong") return; @@ -227,7 +234,7 @@ export function createWsClient() { proxyOpen = true; log.info("WebSocket open, sending auth"); setState("authenticating"); - send({ type: "auth", payload: { token: config!.token } }); + send({ type: "auth", payload: { token: config!.token, last_seq: lastSeq } }); } else if (rustState === "closed") { proxyOpen = false; log.info("WebSocket closed (proxy)"); @@ -354,6 +361,7 @@ export function createWsClient() { function disconnect(): void { intentionalClose = true; certMismatchBlock = false; + lastSeq = 0; cancelReconnect(); stopHeartbeat(); cleanupEventListeners(); diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 399c9c16..875a3ef5 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -1,5 +1,6 @@ // MainPage — primary app layout after login. // Composes standalone components; never sets innerHTML with user content. +// Delegates sidebar and chat-area DOM construction to sub-orchestrators. import { createElement, appendChildren } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; @@ -7,23 +8,14 @@ import type { WsClient } from "@lib/ws"; import type { ApiClient } from "@lib/api"; import { createLogger } from "@lib/logger"; import { createRateLimiterSet } from "@lib/rate-limiter"; -import { createServerStrip } from "@components/ServerStrip"; -import { createChannelSidebar } from "@components/ChannelSidebar"; -import { createCreateChannelModal } from "@components/CreateChannelModal"; -import { createEditChannelModal } from "@components/EditChannelModal"; -import { createDeleteChannelModal } from "@components/DeleteChannelModal"; -import { createUserBar } from "@components/UserBar"; -import { createVideoGrid } from "@components/VideoGrid"; import type { VideoGridComponent } from "@components/VideoGrid"; -import { createVoiceWidget } from "@components/VoiceWidget"; -import { createMemberList } from "@components/MemberList"; import { createServerBanner } from "@components/ServerBanner"; import type { ServerBannerControl } from "@components/ServerBanner"; import { createSettingsOverlay } from "@components/SettingsOverlay"; import { createToastContainer } from "@components/Toast"; import type { ToastContainer } from "@components/Toast"; import { authStore, clearAuth, updateUser } from "@stores/auth.store"; -import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store"; +import { closeSettings } from "@stores/ui.store"; import { channelsStore, getActiveChannel } from "@stores/channels.store"; import { voiceStore } from "@stores/voice.store"; import { @@ -36,15 +28,8 @@ import { setOnError as setVoiceOnError, clearOnError as clearVoiceOnError, } from "@lib/livekitSession"; -import { buildChatHeader } from "./main-page/ChatHeader"; import { setServerHost } from "@components/message-list/renderers"; -import { - createQuickSwitcherManager, - createInviteManagerController, - createPinnedPanelController, - createSearchOverlayController, -} from "./main-page/OverlayManagers"; -import type { SearchOverlayController } from "./main-page/OverlayManagers"; +import { createQuickSwitcherManager } from "./main-page/OverlayManagers"; import { createMessageController, createPendingDeleteManager, @@ -54,10 +39,11 @@ import { createReactionController } from "./main-page/ReactionController"; import type { ReactionController } from "./main-page/ReactionController"; import { createVideoModeController } from "./main-page/VideoModeController"; import type { VideoModeController } from "./main-page/VideoModeController"; -import { createVoiceWidgetCallbacks, createSidebarVoiceCallbacks } from "./main-page/VoiceCallbacks"; import { createChannelController } from "./main-page/ChannelController"; import type { ChannelController } from "./main-page/ChannelController"; import { createUpdateNotifier } from "@components/UpdateNotifier"; +import { createSidebarArea } from "./main-page/SidebarArea"; +import { createChatArea } from "./main-page/ChatArea"; const log = createLogger("main-page"); @@ -98,16 +84,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // Refs we need to update reactively let banner: ServerBannerControl | null = null; - let chatHeaderName: HTMLSpanElement | null = null; - // Containers for swappable sub-components - let messagesSlot: HTMLDivElement | null = null; - let typingSlot: HTMLDivElement | null = null; - let inputSlot: HTMLDivElement | null = null; - - // Video grid (owned by mount, controller manages toggle state) + // Video grid (owned by ChatArea, referenced for remote video wiring) let videoGrid: VideoGridComponent | null = null; - let videoGridSlot: HTMLDivElement | null = null; // Pending delete confirmations (double-click to delete pattern) const pendingDeleteManager = createPendingDeleteManager(); @@ -121,14 +100,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // Toast container for user-facing error feedback let toast: ToastContainer | null = null; - // Active modal (channel create/edit/delete) — tracked for cleanup - let activeModal: MountableComponent | null = null; - - // Overlay controllers — created in mount() - let pinnedCtrl: ReturnType | null = null; - let inviteCtrl: ReturnType | null = null; - let searchCtrl: SearchOverlayController | null = null; - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -137,12 +108,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent { return authStore.getState().user?.id ?? 0; } - // --------------------------------------------------------------------------- - // Channel switching — rebuild channel-dependent components - // --------------------------------------------------------------------------- - - // mountChannelComponents / destroyChannelComponents delegated to channelCtrl - // --------------------------------------------------------------------------- // Mount / Destroy // --------------------------------------------------------------------------- @@ -181,231 +146,42 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // --- Main .app row --- const app = createElement("div", { class: "app", "data-testid": "app-layout" }); - // Server strip - const serverStripSlot = createElement("div", {}); - const serverStrip = createServerStrip(); - serverStrip.mount(serverStripSlot); - children.push(serverStrip); - - // Channel sidebar (composed: sidebar + voice widget + user bar) - const sidebarWrapper = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" }); - - const channelSidebarSlot = createElement("div", {}); - - const sidebarVoice = createSidebarVoiceCallbacks(ws); - const channelSidebar = createChannelSidebar({ - onVoiceJoin: sidebarVoice.onVoiceJoin, - onVoiceLeave: sidebarVoice.onVoiceLeave, - onCreateChannel: (category) => { - if (activeModal !== null) { - return; - } - const modal = createCreateChannelModal({ - category, - onCreate: async (data) => { - try { - await api.adminCreateChannel(data); - // Server broadcasts channel_create via WS — store updates automatically - modal.destroy?.(); - activeModal = null; - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to create channel"; - toast?.show(msg, "error"); - } - }, - onClose: () => { - modal.destroy?.(); - activeModal = null; - }, - }); - activeModal = modal; - modal.mount(document.body); - }, - onEditChannel: (channel) => { - if (activeModal !== null) { - return; - } - const modal = createEditChannelModal({ - channelId: channel.id, - channelName: channel.name, - channelType: channel.type, - onSave: async (data) => { - try { - await api.adminUpdateChannel(channel.id, data); - // Server broadcasts channel_update via WS — store updates automatically - modal.destroy?.(); - activeModal = null; - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to update channel"; - toast?.show(msg, "error"); - } - }, - onClose: () => { - modal.destroy?.(); - activeModal = null; - }, - }); - activeModal = modal; - modal.mount(document.body); - }, - onDeleteChannel: (channel) => { - if (activeModal !== null) { - return; - } - const modal = createDeleteChannelModal({ - channelId: channel.id, - channelName: channel.name, - onConfirm: async () => { - try { - await api.adminDeleteChannel(channel.id); - // Server broadcasts channel_delete via WS — store updates automatically - modal.destroy?.(); - activeModal = null; - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to delete channel"; - toast?.show(msg, "error"); - } - }, - onClose: () => { - modal.destroy?.(); - activeModal = null; - }, - }); - activeModal = modal; - modal.mount(document.body); - }, - onReorderChannel: (reorders) => { - for (const r of reorders) { - void api.adminUpdateChannel(r.channelId, { position: r.newPosition }); - } - }, - }); - channelSidebar.mount(channelSidebarSlot); - children.push(channelSidebar); - - const mountedSidebar = channelSidebarSlot.firstElementChild; - if (mountedSidebar !== null) { - while (mountedSidebar.firstChild !== null) { - sidebarWrapper.appendChild(mountedSidebar.firstChild); - } - } - - // Invite button in sidebar header - inviteCtrl = createInviteManagerController({ + // --- Sidebar (server strip + channel sidebar + voice widget + user bar) --- + const sidebar = createSidebarArea({ + ws, api, + limiters, getRoot: () => root, getToast: () => toast, }); - const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header"); - if (sidebarHeader !== null) { - const inviteBtn = createElement("button", { - class: "invite-btn", - title: "Invite", - }, "Invite"); - inviteBtn.addEventListener("click", () => { - void inviteCtrl!.open(); - }); - sidebarHeader.appendChild(inviteBtn); - } - unsubscribers.push(() => { inviteCtrl?.cleanup(); }); + children.push(...sidebar.children); + unsubscribers.push(...sidebar.unsubscribers); - // Voice widget - const voiceWidgetSlot = createElement("div", {}); - const voiceWidget = createVoiceWidget( - createVoiceWidgetCallbacks(ws, limiters), - ); - voiceWidget.mount(voiceWidgetSlot); - children.push(voiceWidget); - sidebarWrapper.appendChild(voiceWidgetSlot); - - // User bar - const userBarSlot = createElement("div", {}); - const userBar = createUserBar(); - userBar.mount(userBarSlot); - children.push(userBar); - sidebarWrapper.appendChild(userBarSlot); - - // Chat area - const chatArea = createElement("div", { class: "chat-area", "data-testid": "chat-area" }); - - pinnedCtrl = createPinnedPanelController({ + // --- Chat area + member list --- + const chatAreaResult = createChatArea({ api, getRoot: () => root, getToast: () => toast, - getCurrentChannelId: () => channelCtrl?.currentChannelId ?? null, - onJumpToMessage: (msgId: number) => { - if (channelCtrl?.messageList === null || channelCtrl?.messageList === undefined) return false; - return channelCtrl.messageList.scrollToMessage(msgId); - }, + getChannelCtrl: () => channelCtrl, }); - unsubscribers.push(() => { pinnedCtrl?.cleanup(); }); - - // Search overlay controller - searchCtrl = createSearchOverlayController({ - api, - getRoot: () => root, - getToast: () => toast, - getCurrentChannelId: () => channelCtrl?.currentChannelId ?? null, - onJumpToMessage: (_channelId: number, msgId: number) => { - if (channelCtrl?.messageList === null || channelCtrl?.messageList === undefined) return false; - return channelCtrl.messageList.scrollToMessage(msgId); - }, - }); - unsubscribers.push(() => { searchCtrl?.cleanup(); }); - - const chatHeader = buildChatHeader({ - onTogglePins: () => { void pinnedCtrl!.toggle(); }, - onToggleMembers: () => toggleMemberList(), - onSearchFocus: () => { searchCtrl?.open(); }, - }); - chatHeaderName = chatHeader.refs.nameEl; - chatArea.appendChild(chatHeader.element); - - messagesSlot = createElement("div", { class: "messages-slot", "data-testid": "messages-slot" }); - typingSlot = createElement("div", { class: "typing-slot", "data-testid": "typing-slot" }); - inputSlot = createElement("div", { class: "input-slot", "data-testid": "input-slot" }); - - videoGridSlot = createElement("div", { - class: "video-grid-slot", - "data-testid": "video-grid-slot", - style: "display:none;flex:1;min-height:0", - }) as HTMLDivElement; - videoGrid = createVideoGrid(); - videoGrid.mount(videoGridSlot); - children.push(videoGrid); + children.push(...chatAreaResult.children); + unsubscribers.push(...chatAreaResult.unsubscribers); + videoGrid = chatAreaResult.videoGrid; // Video mode controller (chat/video toggle + tile management) videoModeCtrl = createVideoModeController({ - slots: { - messagesSlot: messagesSlot as HTMLDivElement, - typingSlot: typingSlot as HTMLDivElement, - inputSlot: inputSlot as HTMLDivElement, - videoGridSlot: videoGridSlot as HTMLDivElement, - }, - videoGrid, + slots: chatAreaResult.slots, + videoGrid: chatAreaResult.videoGrid, getCurrentUserId, }); - appendChildren(chatArea, messagesSlot, typingSlot, inputSlot, videoGridSlot); - - // Member list - const memberListSlot = createElement("div", {}); - const memberList = createMemberList(); - memberList.mount(memberListSlot); - children.push(memberList); - - const memberListEl = memberListSlot.querySelector(".member-list"); - const unsubMemberList = uiStore.subscribeSelector( - (s) => s.memberListVisible, - (visible) => { - if (memberListEl !== null) { - memberListEl.classList.toggle("hidden", !visible); - } - }, + appendChildren( + app, + sidebar.serverStripSlot, + sidebar.sidebarWrapper, + chatAreaResult.chatArea, + chatAreaResult.memberListSlot, ); - unsubscribers.push(unsubMemberList); - - appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot); root.appendChild(app); // Settings overlay @@ -471,11 +247,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent { showToast: (msg, type) => toast?.show(msg, type as "success" | "error" | "info"), getCurrentUserId, slots: { - messagesSlot: messagesSlot as HTMLDivElement, - typingSlot: typingSlot as HTMLDivElement, - inputSlot: inputSlot as HTMLDivElement, + messagesSlot: chatAreaResult.slots.messagesSlot, + typingSlot: chatAreaResult.slots.typingSlot, + inputSlot: chatAreaResult.slots.inputSlot, }, - chatHeaderName, + chatHeaderName: chatAreaResult.chatHeaderName, }); // Wire voice error callback to toast @@ -565,16 +341,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { videoModeCtrl?.destroy(); videoModeCtrl = null; - if (activeModal !== null) { - activeModal.destroy?.(); - activeModal = null; - } - - if (videoGrid !== null) { - videoGrid.destroy?.(); - videoGrid = null; - } - videoGridSlot = null; + videoGrid = null; for (const child of children) { child.destroy?.(); diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index 0e7a6eda..63880662 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -14,7 +14,7 @@ import type { MessageListComponent } from "@components/MessageList"; import { createMessageInput } from "@components/MessageInput"; import type { MessageInputComponent } from "@components/MessageInput"; import { createTypingIndicator } from "@components/TypingIndicator"; -import { getChannelMessages } from "@stores/messages.store"; +import { getChannelMessages, setMessagePinned } from "@stores/messages.store"; import type { MessageController } from "./MessageController"; import type { PendingDeleteManager } from "./MessageController"; import type { ReactionController } from "./ReactionController"; @@ -161,6 +161,18 @@ export function createChannelController( onReactionClick: (msgId: number, emoji: string) => { reactionCtrl.handleReaction(msgId, emoji); }, + onPinClick: (msgId: number, chId: number, currentlyPinned: boolean) => { + const action = currentlyPinned + ? api.unpinMessage(chId, msgId) + : api.pinMessage(chId, msgId); + action.then(() => { + setMessagePinned(chId, msgId, !currentlyPinned); + showToast(currentlyPinned ? "Message unpinned" : "Message pinned", "success"); + }).catch((err) => { + log.error("Pin/unpin failed", { error: String(err) }); + showToast("Failed to pin/unpin message", "error"); + }); + }, }); messageList.mount(slots.messagesSlot); @@ -229,6 +241,20 @@ export function createChannelController( }); messageInput.mount(slots.inputSlot); + // Arrow-up edit: listen for edit-last-message bubbling from MessageInput + slots.inputSlot.addEventListener("edit-last-message", () => { + const msgs = getChannelMessages(channelId); + const myId = getCurrentUserId(); + // Find the last message sent by the current user (array is chronological) + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i]!; + if (m.user.id === myId && !m.deleted) { + messageInput?.startEdit(m.id, m.content); + break; + } + } + }, { signal }); + // Update header if (chatHeaderName !== null) { setText(chatHeaderName, channelName); diff --git a/Client/tauri-client/src/pages/main-page/ChatArea.ts b/Client/tauri-client/src/pages/main-page/ChatArea.ts new file mode 100644 index 00000000..c9ccf7aa --- /dev/null +++ b/Client/tauri-client/src/pages/main-page/ChatArea.ts @@ -0,0 +1,196 @@ +/** + * ChatArea — chat column DOM construction and overlay/video wiring. + * Composes ChatHeader, message/typing/input slots, VideoGrid, pinned panel, + * search overlay, and MemberList. Extracted from MainPage to reduce orchestrator size. + */ + +import { createElement, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import type { ApiClient } from "@lib/api"; +import type { ToastContainer } from "@components/Toast"; +import { createVideoGrid } from "@components/VideoGrid"; +import type { VideoGridComponent } from "@components/VideoGrid"; +import { createMemberList } from "@components/MemberList"; +import { authStore } from "@stores/auth.store"; +import { toggleMemberList, uiStore } from "@stores/ui.store"; +import { buildChatHeader } from "./ChatHeader"; +import { + createPinnedPanelController, + createSearchOverlayController, +} from "./OverlayManagers"; +import type { SearchOverlayController } from "./OverlayManagers"; +import type { ChannelController } from "./ChannelController"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ChatAreaOptions { + readonly api: ApiClient; + readonly getRoot: () => HTMLDivElement | null; + readonly getToast: () => ToastContainer | null; + readonly getChannelCtrl: () => ChannelController | null; +} + +export interface ChatAreaResult { + /** The chat area element (center column). */ + readonly chatArea: HTMLDivElement; + /** The member list slot element (right column). */ + readonly memberListSlot: HTMLDivElement; + /** Message/typing/input/videoGrid slots for ChannelController and VideoModeController. */ + readonly slots: { + readonly messagesSlot: HTMLDivElement; + readonly typingSlot: HTMLDivElement; + readonly inputSlot: HTMLDivElement; + readonly videoGridSlot: HTMLDivElement; + }; + /** The VideoGrid component instance. */ + readonly videoGrid: VideoGridComponent; + /** The chat header channel-name element (updated reactively). */ + readonly chatHeaderName: HTMLSpanElement | null; + /** The search overlay controller. */ + readonly searchCtrl: SearchOverlayController; + /** All child MountableComponents for cleanup. */ + readonly children: readonly MountableComponent[]; + /** Unsubscribe / cleanup functions. */ + readonly unsubscribers: readonly (() => void)[]; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createChatArea(opts: ChatAreaOptions): ChatAreaResult { + const { api, getRoot, getToast, getChannelCtrl } = opts; + + const children: MountableComponent[] = []; + const unsubscribers: Array<() => void> = []; + + // --- Overlay controllers --- + const pinnedCtrl = createPinnedPanelController({ + api, + getRoot, + getToast, + getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null, + onJumpToMessage: (msgId: number) => { + const ctrl = getChannelCtrl(); + if (ctrl?.messageList === null || ctrl?.messageList === undefined) return false; + return ctrl.messageList.scrollToMessage(msgId); + }, + }); + unsubscribers.push(() => { pinnedCtrl.cleanup(); }); + + const searchCtrl = createSearchOverlayController({ + api, + getRoot, + getToast, + getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null, + onJumpToMessage: (_channelId: number, msgId: number) => { + const ctrl = getChannelCtrl(); + if (ctrl?.messageList === null || ctrl?.messageList === undefined) return false; + return ctrl.messageList.scrollToMessage(msgId); + }, + }); + unsubscribers.push(() => { searchCtrl.cleanup(); }); + + // --- Chat header --- + const chatHeader = buildChatHeader({ + onTogglePins: () => { void pinnedCtrl.toggle(); }, + onToggleMembers: () => toggleMemberList(), + onSearchFocus: () => { searchCtrl.open(); }, + }); + const chatHeaderName = chatHeader.refs.nameEl; + + // --- Chat area element --- + const chatArea = createElement("div", { + class: "chat-area", + "data-testid": "chat-area", + }) as HTMLDivElement; + chatArea.appendChild(chatHeader.element); + + // --- Slots --- + const messagesSlot = createElement("div", { + class: "messages-slot", + "data-testid": "messages-slot", + }) as HTMLDivElement; + const typingSlot = createElement("div", { + class: "typing-slot", + "data-testid": "typing-slot", + }) as HTMLDivElement; + const inputSlot = createElement("div", { + class: "input-slot", + "data-testid": "input-slot", + }) as HTMLDivElement; + const videoGridSlot = createElement("div", { + class: "video-grid-slot", + "data-testid": "video-grid-slot", + style: "display:none;flex:1;min-height:0", + }) as HTMLDivElement; + + // --- Video grid --- + const videoGrid = createVideoGrid(); + videoGrid.mount(videoGridSlot); + children.push(videoGrid); + + appendChildren(chatArea, messagesSlot, typingSlot, inputSlot, videoGridSlot); + + // --- Member list --- + const memberListSlot = createElement("div", {}) as HTMLDivElement; + const memberList = createMemberList({ + currentUserRole: authStore.getState().user?.role ?? "member", + onKick: async (userId, username) => { + try { + await api.adminKickMember(userId); + getToast()?.show(`Kicked ${username}`, "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to kick member"; + getToast()?.show(msg, "error"); + } + }, + onBan: async (userId, username) => { + try { + await api.adminBanMember(userId); + getToast()?.show(`Banned ${username}`, "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to ban member"; + getToast()?.show(msg, "error"); + } + }, + onChangeRole: async (userId, username, newRole) => { + const roleNameToId: Record = { owner: 1, admin: 2, moderator: 3, member: 4 }; + const roleId = roleNameToId[newRole]; + if (roleId === undefined) return; + try { + await api.adminChangeRole(userId, roleId); + getToast()?.show(`Changed ${username}'s role to ${newRole}`, "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to change role"; + getToast()?.show(msg, "error"); + } + }, + }); + memberList.mount(memberListSlot); + children.push(memberList); + + const memberListEl = memberListSlot.querySelector(".member-list"); + const unsubMemberList = uiStore.subscribeSelector( + (s) => s.memberListVisible, + (visible) => { + if (memberListEl !== null) { + memberListEl.classList.toggle("hidden", !visible); + } + }, + ); + unsubscribers.push(unsubMemberList); + + return { + chatArea, + memberListSlot, + slots: { messagesSlot, typingSlot, inputSlot, videoGridSlot }, + videoGrid, + chatHeaderName, + searchCtrl, + children, + unsubscribers, + }; +} diff --git a/Client/tauri-client/src/pages/main-page/SidebarArea.ts b/Client/tauri-client/src/pages/main-page/SidebarArea.ts new file mode 100644 index 00000000..1500dcad --- /dev/null +++ b/Client/tauri-client/src/pages/main-page/SidebarArea.ts @@ -0,0 +1,211 @@ +/** + * SidebarArea — sidebar DOM construction and component wiring. + * Composes ServerStrip, ChannelSidebar (with modal callbacks), invite button, + * VoiceWidget, and UserBar. Extracted from MainPage to reduce orchestrator size. + */ + +import { createElement } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import type { WsClient } from "@lib/ws"; +import type { ApiClient } from "@lib/api"; +import type { RateLimiterSet } from "@lib/rate-limiter"; +import type { ToastContainer } from "@components/Toast"; +import { createServerStrip } from "@components/ServerStrip"; +import { createChannelSidebar } from "@components/ChannelSidebar"; +import { createCreateChannelModal } from "@components/CreateChannelModal"; +import { createEditChannelModal } from "@components/EditChannelModal"; +import { createDeleteChannelModal } from "@components/DeleteChannelModal"; +import { createUserBar } from "@components/UserBar"; +import { createVoiceWidget } from "@components/VoiceWidget"; +import { createVoiceWidgetCallbacks, createSidebarVoiceCallbacks } from "./VoiceCallbacks"; +import { createInviteManagerController } from "./OverlayManagers"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SidebarAreaOptions { + readonly ws: WsClient; + readonly api: ApiClient; + readonly limiters: RateLimiterSet; + readonly getRoot: () => HTMLDivElement | null; + readonly getToast: () => ToastContainer | null; +} + +export interface SidebarAreaResult { + /** The server strip slot element (left column). */ + readonly serverStripSlot: HTMLDivElement; + /** The composed sidebar wrapper element (channel list + voice + user bar). */ + readonly sidebarWrapper: HTMLDivElement; + /** All child MountableComponents for cleanup. */ + readonly children: readonly MountableComponent[]; + /** Unsubscribe / cleanup functions. */ + readonly unsubscribers: readonly (() => void)[]; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult { + const { ws, api, limiters, getRoot, getToast } = opts; + + const children: MountableComponent[] = []; + const unsubscribers: Array<() => void> = []; + + // Track active modal for channel create/edit/delete + let activeModal: MountableComponent | null = null; + + // --- Server strip --- + const serverStripSlot = createElement("div", {}) as HTMLDivElement; + const serverStrip = createServerStrip(); + serverStrip.mount(serverStripSlot); + children.push(serverStrip); + + // --- Channel sidebar wrapper --- + const sidebarWrapper = createElement("div", { + class: "channel-sidebar", + "data-testid": "channel-sidebar", + }) as HTMLDivElement; + + const channelSidebarSlot = createElement("div", {}); + + const sidebarVoice = createSidebarVoiceCallbacks(ws); + const channelSidebar = createChannelSidebar({ + onVoiceJoin: sidebarVoice.onVoiceJoin, + onVoiceLeave: sidebarVoice.onVoiceLeave, + onCreateChannel: (category) => { + if (activeModal !== null) return; + const modal = createCreateChannelModal({ + category, + onCreate: async (data) => { + try { + await api.adminCreateChannel(data); + modal.destroy?.(); + activeModal = null; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to create channel"; + getToast()?.show(msg, "error"); + } + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onEditChannel: (channel) => { + if (activeModal !== null) return; + const modal = createEditChannelModal({ + channelId: channel.id, + channelName: channel.name, + channelType: channel.type, + onSave: async (data) => { + try { + await api.adminUpdateChannel(channel.id, data); + modal.destroy?.(); + activeModal = null; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to update channel"; + getToast()?.show(msg, "error"); + } + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onDeleteChannel: (channel) => { + if (activeModal !== null) return; + const modal = createDeleteChannelModal({ + channelId: channel.id, + channelName: channel.name, + onConfirm: async () => { + try { + await api.adminDeleteChannel(channel.id); + modal.destroy?.(); + activeModal = null; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to delete channel"; + getToast()?.show(msg, "error"); + } + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onReorderChannel: (reorders) => { + for (const r of reorders) { + void api.adminUpdateChannel(r.channelId, { position: r.newPosition }); + } + }, + }); + channelSidebar.mount(channelSidebarSlot); + children.push(channelSidebar); + + const mountedSidebar = channelSidebarSlot.firstElementChild; + if (mountedSidebar !== null) { + while (mountedSidebar.firstChild !== null) { + sidebarWrapper.appendChild(mountedSidebar.firstChild); + } + } + + // --- Invite button in sidebar header --- + const inviteCtrl = createInviteManagerController({ + api, + getRoot, + getToast, + }); + const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header"); + if (sidebarHeader !== null) { + const inviteBtn = createElement("button", { + class: "invite-btn", + title: "Invite", + }, "Invite"); + inviteBtn.addEventListener("click", () => { + void inviteCtrl.open(); + }); + sidebarHeader.appendChild(inviteBtn); + } + unsubscribers.push(() => { inviteCtrl.cleanup(); }); + + // --- Voice widget --- + const voiceWidgetSlot = createElement("div", {}); + const voiceWidget = createVoiceWidget( + createVoiceWidgetCallbacks(ws, limiters), + ); + voiceWidget.mount(voiceWidgetSlot); + children.push(voiceWidget); + sidebarWrapper.appendChild(voiceWidgetSlot); + + // --- User bar --- + const userBarSlot = createElement("div", {}); + const userBar = createUserBar(); + userBar.mount(userBarSlot); + children.push(userBar); + sidebarWrapper.appendChild(userBarSlot); + + // --- Cleanup for active modal --- + unsubscribers.push(() => { + if (activeModal !== null) { + activeModal.destroy?.(); + activeModal = null; + } + }); + + return { + serverStripSlot, + sidebarWrapper, + children, + unsubscribers, + }; +} diff --git a/Client/tauri-client/src/stores/messages.store.ts b/Client/tauri-client/src/stores/messages.store.ts index c909fcf4..393413d9 100644 --- a/Client/tauri-client/src/stores/messages.store.ts +++ b/Client/tauri-client/src/stores/messages.store.ts @@ -28,6 +28,7 @@ export interface Message { readonly replyTo: number | null; readonly attachments: readonly Attachment[]; readonly reactions: readonly ReactionSummary[]; + readonly pinned: boolean; readonly editedAt: string | null; readonly deleted: boolean; readonly timestamp: string; @@ -57,6 +58,7 @@ function chatPayloadToMessage(payload: ChatMessagePayload): Message { replyTo: payload.reply_to, attachments: payload.attachments, reactions: [], + pinned: false, editedAt: null, deleted: false, timestamp: payload.timestamp, @@ -72,12 +74,16 @@ function messageResponseToMessage(response: MessageResponse): Message { replyTo: response.reply_to, attachments: response.attachments, reactions: response.reactions, + pinned: response.pinned, editedAt: response.edited_at, deleted: response.deleted, timestamp: response.timestamp, }; } +/** Maximum messages retained per channel. Oldest messages are evicted when exceeded. */ +const MAX_MESSAGES_PER_CHANNEL = 500; + // ----------------------------------------------------------------------------- // Initial state // ----------------------------------------------------------------------------- @@ -105,9 +111,19 @@ export function addMessage(payload: ChatMessagePayload): void { messagesStore.setState((prev) => { const channelId = message.channelId; const existing = prev.messagesByChannel.get(channelId) ?? []; + let updatedMsgs = [...existing, message]; + // Evict oldest messages if over the cap + if (updatedMsgs.length > MAX_MESSAGES_PER_CHANNEL) { + updatedMsgs = updatedMsgs.slice(updatedMsgs.length - MAX_MESSAGES_PER_CHANNEL); + } const updated = new Map(prev.messagesByChannel); - updated.set(channelId, [...existing, message]); - return { ...prev, messagesByChannel: updated }; + updated.set(channelId, updatedMsgs); + // If we evicted, there are now more messages on the server above + const updatedHasMore = new Map(prev.hasMore); + if (existing.length + 1 > MAX_MESSAGES_PER_CHANNEL) { + updatedHasMore.set(channelId, true); + } + return { ...prev, messagesByChannel: updated, hasMore: updatedHasMore }; }); } @@ -119,15 +135,18 @@ export function setMessages( hasMore: boolean, ): void { const converted = messages.map(messageResponseToMessage).reverse(); + const trimmed = converted.length > MAX_MESSAGES_PER_CHANNEL + ? converted.slice(converted.length - MAX_MESSAGES_PER_CHANNEL) + : converted; messagesStore.setState((prev) => { const updatedMessages = new Map(prev.messagesByChannel); - updatedMessages.set(channelId, converted); + updatedMessages.set(channelId, trimmed); const updatedLoaded = new Set(prev.loadedChannels); updatedLoaded.add(channelId); const updatedHasMore = new Map(prev.hasMore); - updatedHasMore.set(channelId, hasMore); + updatedHasMore.set(channelId, hasMore || converted.length > MAX_MESSAGES_PER_CHANNEL); return { ...prev, @@ -148,8 +167,13 @@ export function prependMessages( const converted = messages.map(messageResponseToMessage).reverse(); messagesStore.setState((prev) => { const existing = prev.messagesByChannel.get(channelId) ?? []; + let combined = [...converted, ...existing]; + // Keep only the newest messages if combined exceeds the cap + if (combined.length > MAX_MESSAGES_PER_CHANNEL) { + combined = combined.slice(combined.length - MAX_MESSAGES_PER_CHANNEL); + } const updatedMessages = new Map(prev.messagesByChannel); - updatedMessages.set(channelId, [...converted, ...existing]); + updatedMessages.set(channelId, combined); const updatedHasMore = new Map(prev.hasMore); updatedHasMore.set(channelId, hasMore); @@ -196,6 +220,26 @@ export function deleteMessage(payload: ChatDeletedPayload): void { }); } +/** Toggle the pinned state of a message (optimistic update after API call). */ +export function setMessagePinned( + channelId: number, + messageId: number, + pinned: boolean, +): void { + messagesStore.setState((prev) => { + const channelMessages = prev.messagesByChannel.get(channelId); + if (!channelMessages) return prev; + + const updatedList = channelMessages.map((msg) => + msg.id === messageId ? { ...msg, pinned } : msg, + ); + + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(channelId, updatedList); + return { ...prev, messagesByChannel: updatedMessages }; + }); +} + /** Track a pending outbound message send. */ export function addPendingSend( correlationId: string, diff --git a/Client/tauri-client/tests/unit/chat.test.ts b/Client/tauri-client/tests/unit/chat.test.ts index ce3984d7..e5ac7fa9 100644 --- a/Client/tauri-client/tests/unit/chat.test.ts +++ b/Client/tauri-client/tests/unit/chat.test.ts @@ -97,6 +97,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); const messagesContainer = container.querySelector(".messages-container"); @@ -120,6 +121,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -146,6 +148,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -171,6 +174,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -193,6 +197,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -217,6 +222,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -240,6 +246,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -262,6 +269,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -285,6 +293,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -308,6 +317,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -331,6 +341,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -349,6 +360,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); @@ -379,6 +391,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }); list.mount(container); list.destroy?.(); diff --git a/Client/tauri-client/tests/unit/member-list.test.ts b/Client/tauri-client/tests/unit/member-list.test.ts index 5d8fa441..763e0d11 100644 --- a/Client/tauri-client/tests/unit/member-list.test.ts +++ b/Client/tauri-client/tests/unit/member-list.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createMemberList } from "@components/MemberList"; +import type { MemberListOptions } from "@components/MemberList"; import { membersStore } from "@stores/members.store"; import type { Member } from "@stores/members.store"; import type { UserStatus } from "../../src/lib/types"; @@ -37,6 +38,15 @@ const testMembers: Member[] = [ makeMember({ id: 6, username: "Frank", role: "admin", status: "online" as UserStatus }), ]; +function defaultOpts(): MemberListOptions { + return { + currentUserRole: "admin", + onKick: vi.fn().mockResolvedValue(undefined), + onBan: vi.fn().mockResolvedValue(undefined), + onChangeRole: vi.fn().mockResolvedValue(undefined), + }; +} + describe("MemberList", () => { let container: HTMLDivElement; let memberList: ReturnType; @@ -45,7 +55,7 @@ describe("MemberList", () => { resetStore(); container = document.createElement("div"); document.body.appendChild(container); - memberList = createMemberList(); + memberList = createMemberList(defaultOpts()); }); afterEach(() => { diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 6a966d71..9a47184e 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -36,6 +36,7 @@ function makeMessage(overrides: Partial & { id: number }): Message { replyTo: null, attachments: [], reactions: [], + pinned: false, editedAt: null, deleted: false, timestamp: "2024-01-15T12:00:00Z", @@ -78,6 +79,7 @@ describe("MessageList", () => { onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), }; msgList = createMessageList(options); }); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index 74ac4ea0..d6056ecc 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -29,6 +29,7 @@ function makeMessage(overrides: Partial = {}): Message { replyTo: null, attachments: [], reactions: [], + pinned: false, editedAt: null, deleted: false, timestamp: "2025-01-15T12:30:00Z", @@ -45,6 +46,7 @@ function makeOpts(overrides: Partial = {}): MessageListOptio onEditClick: vi.fn(), onDeleteClick: vi.fn(), onReactionClick: vi.fn(), + onPinClick: vi.fn(), ...overrides, }; } diff --git a/Client/tauri-client/tests/unit/store.test.ts b/Client/tauri-client/tests/unit/store.test.ts index ad17ee7b..1eb2debb 100644 --- a/Client/tauri-client/tests/unit/store.test.ts +++ b/Client/tauri-client/tests/unit/store.test.ts @@ -239,20 +239,40 @@ describe('subscribeSelector', () => { expect(results).toEqual(['count:1', 'name:updated']); }); - it('warns about unstable selectors (creates new ref every time)', () => { + it('shallow-equal default prevents firing for structurally identical selectors', () => { const store = freshStore(); const listener = vi.fn(); - // BAD selector: creates new object every time + // Selector creates a new object ref each time, but shallowEqual + // detects that the content is unchanged and skips the notification. store.subscribeSelector( (s) => ({ count: s.count }), listener, ); - // Even changing just name will fire because selector returns new object + // Changing just name does NOT fire because { count: 0 } shallow-equals { count: 0 } store.setState((prev) => ({ ...prev, name: 'changed' })); store.flush(); + expect(listener).toHaveBeenCalledTimes(0); - // This DOES fire because { count: 0 } !== { count: 0 } (different refs) + // Changing count DOES fire because { count: 1 } !== { count: 0 } + store.setState((prev) => ({ ...prev, count: 1 })); + store.flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('allows strict reference equality via custom comparator', () => { + const store = freshStore(); + const listener = vi.fn(); + // Opt in to strict === comparison to get the old behavior + store.subscribeSelector( + (s) => ({ count: s.count }), + listener, + (a, b) => a === b, + ); + + // New object ref with same content DOES fire with strict === + store.setState((prev) => ({ ...prev, name: 'changed' })); + store.flush(); expect(listener).toHaveBeenCalledTimes(1); }); }); diff --git a/Server/api/metrics_handler.go b/Server/api/metrics_handler.go new file mode 100644 index 00000000..ed1375c8 --- /dev/null +++ b/Server/api/metrics_handler.go @@ -0,0 +1,40 @@ +package api + +import ( + "net/http" + "runtime" + "time" +) + +// ServerMetrics holds runtime metrics for the /api/v1/metrics endpoint. +type ServerMetrics struct { + Uptime string `json:"uptime"` + UptimeSeconds float64 `json:"uptime_seconds"` + GoRoutines int `json:"goroutines"` + HeapAllocMB float64 `json:"heap_alloc_mb"` + HeapSysMB float64 `json:"heap_sys_mb"` + NumGC uint32 `json:"num_gc"` + ConnectedUsers int `json:"connected_users"` +} + +// handleMetrics returns an HTTP handler that reports runtime server metrics. +// getConnectedUsers is a callback to retrieve the current WebSocket client count. +func handleMetrics(getConnectedUsers func() int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + uptime := time.Since(serverStartTime) + metrics := ServerMetrics{ + Uptime: uptime.Truncate(time.Second).String(), + UptimeSeconds: uptime.Seconds(), + GoRoutines: runtime.NumGoroutine(), + HeapAllocMB: float64(m.HeapAlloc) / 1024 / 1024, + HeapSysMB: float64(m.HeapSys) / 1024 / 1024, + NumGC: m.NumGC, + ConnectedUsers: getConnectedUsers(), + } + + writeJSON(w, http.StatusOK, metrics) + } +} diff --git a/Server/api/router.go b/Server/api/router.go index eb9d94dd..b15e58e4 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -98,6 +98,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri go hub.Run() r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins)) + // Metrics endpoint — admin-IP-restricted, returns runtime stats as JSON. + r.With(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs)). + Get("/api/v1/metrics", handleMetrics(func() int { return hub.ClientCount() })) + // Admin panel: static files + REST API (Phase 6). // Restrict /admin to configured CIDRs (default: private networks only). u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord") diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go index 34a6857e..3c7d6eee 100644 --- a/Server/db/admin_queries.go +++ b/Server/db/admin_queries.go @@ -254,7 +254,7 @@ func (d *DB) GetSetting(key string) (string, error) { var value string err := d.sqlDB.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) if errors.Is(err, sql.ErrNoRows) { - return "", fmt.Errorf("GetSetting: key %q not found", key) + return "", fmt.Errorf("GetSetting: key %q: %w", key, ErrNotFound) } if err != nil { return "", fmt.Errorf("GetSetting: %w", err) diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index 5e846852..637ee5e0 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -115,3 +115,41 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI } return result, nil } + +// DeleteOrphanedAttachments removes attachment records where message_id IS NULL +// and uploaded_at is older than the given cutoff time string (ISO 8601). +// Returns the stored_as filenames of deleted records so the caller can remove files. +func (d *DB) DeleteOrphanedAttachments(cutoff string) ([]string, error) { + rows, err := d.sqlDB.Query( + `SELECT stored_as FROM attachments WHERE message_id IS NULL AND uploaded_at < ?`, + cutoff, + ) + if err != nil { + return nil, fmt.Errorf("DeleteOrphanedAttachments query: %w", err) + } + defer rows.Close() //nolint:errcheck + + var files []string + for rows.Next() { + var storedAs string + if scanErr := rows.Scan(&storedAs); scanErr != nil { + return nil, fmt.Errorf("DeleteOrphanedAttachments scan: %w", scanErr) + } + files = append(files, storedAs) + } + if rows.Err() != nil { + return nil, fmt.Errorf("DeleteOrphanedAttachments rows: %w", rows.Err()) + } + + if len(files) > 0 { + _, err = d.sqlDB.Exec( + `DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ?`, + cutoff, + ) + if err != nil { + return nil, fmt.Errorf("DeleteOrphanedAttachments delete: %w", err) + } + } + + return files, nil +} diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 76109dc7..a7b85dee 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -309,7 +309,7 @@ func (d *DB) UseInviteAtomic(code string) error { return fmt.Errorf("UseInviteAtomic rows: %w", err) } if rows == 0 { - return fmt.Errorf("UseInviteAtomic: invite not found, revoked, expired, or exhausted") + return fmt.Errorf("UseInviteAtomic: invite not found, revoked, expired, or exhausted: %w", ErrNotFound) } return nil } diff --git a/Server/db/db.go b/Server/db/db.go index 70d58997..922e7457 100644 --- a/Server/db/db.go +++ b/Server/db/db.go @@ -53,6 +53,24 @@ func Open(path string) (*DB, error) { return nil, fmt.Errorf("enabling foreign keys: %w", err) } + // Performance tuning (safe with WAL mode). + if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL;"); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("setting synchronous mode: %w", err) + } + if _, err := sqlDB.Exec("PRAGMA temp_store=MEMORY;"); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("setting temp_store: %w", err) + } + if _, err := sqlDB.Exec("PRAGMA mmap_size=268435456;"); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("setting mmap_size: %w", err) + } + if _, err := sqlDB.Exec("PRAGMA cache_size=-64000;"); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("setting cache_size: %w", err) + } + return &DB{sqlDB: sqlDB}, nil } @@ -66,6 +84,8 @@ func Migrate(database *DB) error { // Close releases the underlying database connection. func (d *DB) Close() error { + // Run PRAGMA optimize to analyze and update query planner statistics. + _, _ = d.sqlDB.Exec("PRAGMA optimize;") return d.sqlDB.Close() } diff --git a/Server/db/errors.go b/Server/db/errors.go new file mode 100644 index 00000000..28c1c374 --- /dev/null +++ b/Server/db/errors.go @@ -0,0 +1,18 @@ +package db + +import "errors" + +// Sentinel errors for the db package. Use errors.Is() to check. +var ( + // ErrNotFound indicates the requested resource does not exist. + ErrNotFound = errors.New("not found") + + // ErrForbidden indicates the caller lacks permission for the operation. + ErrForbidden = errors.New("forbidden") + + // ErrConflict indicates a uniqueness constraint violation (e.g., duplicate username). + ErrConflict = errors.New("conflict") + + // ErrBanned indicates the user is banned. + ErrBanned = errors.New("banned") +) diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index 4a4e7bd6..e569850f 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -89,10 +89,10 @@ func (d *DB) EditMessage(id, userID int64, content string) error { return err } if msg == nil { - return fmt.Errorf("EditMessage: message %d not found", id) + return fmt.Errorf("EditMessage: message %d: %w", id, ErrNotFound) } if msg.UserID != userID { - return fmt.Errorf("EditMessage: user %d does not own message %d", userID, id) + return fmt.Errorf("EditMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) } _, err = d.sqlDB.Exec( @@ -113,10 +113,10 @@ func (d *DB) DeleteMessage(id, userID int64, ismod bool) error { return err } if msg == nil { - return fmt.Errorf("DeleteMessage: message %d not found", id) + return fmt.Errorf("DeleteMessage: message %d: %w", id, ErrNotFound) } if !ismod && msg.UserID != userID { - return fmt.Errorf("DeleteMessage: user %d does not own message %d", userID, id) + return fmt.Errorf("DeleteMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) } _, err = d.sqlDB.Exec(`UPDATE messages SET deleted = 1 WHERE id = ?`, id) @@ -149,7 +149,7 @@ func (d *DB) RemoveReaction(messageID, userID int64, emoji string) error { } n, _ := res.RowsAffected() if n == 0 { - return fmt.Errorf("RemoveReaction: reaction not found") + return fmt.Errorf("RemoveReaction: reaction: %w", ErrNotFound) } return nil } diff --git a/Server/main.go b/Server/main.go index 8d038238..2b77610f 100644 --- a/Server/main.go +++ b/Server/main.go @@ -23,6 +23,7 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" + "github.com/owncord/server/storage" ) // version is overridden at build time via -ldflags "-X main.version=1.0.0". @@ -137,7 +138,12 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { } // ── 7. Background maintenance ──────────────────────────────────────── - // Periodically purge expired sessions to prevent unbounded growth. + // Periodically purge expired sessions and orphaned attachments. + fileStorage, fileStorageErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB) + if fileStorageErr != nil { + log.Warn("failed to create file storage for maintenance; orphan file cleanup disabled", "error", fileStorageErr) + } + stopMaintenance := make(chan struct{}) go func() { ticker := time.NewTicker(15 * time.Minute) @@ -148,6 +154,23 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { if err := database.DeleteExpiredSessions(); err != nil { log.Warn("failed to delete expired sessions", "error", err) } + + // Clean up orphaned attachments (uploaded but never linked to a message). + cutoff := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + orphanFiles, orphanErr := database.DeleteOrphanedAttachments(cutoff) + if orphanErr != nil { + log.Warn("failed to delete orphaned attachments", "error", orphanErr) + } else if len(orphanFiles) > 0 { + // Best-effort file cleanup. + if fileStorage != nil { + for _, filename := range orphanFiles { + if delErr := fileStorage.Delete(filename); delErr != nil { + log.Warn("failed to delete orphan file", "file", filename, "error", delErr) + } + } + } + log.Info("cleaned up orphaned attachments", "count", len(orphanFiles)) + } case <-stopMaintenance: return } diff --git a/Server/scripts/voice-test.sh b/Server/scripts/voice-test.sh new file mode 100644 index 00000000..8276dfdd --- /dev/null +++ b/Server/scripts/voice-test.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# voice-test.sh — LiveKit voice integration smoke test +# +# Prerequisites: +# - LiveKit CLI: go install github.com/livekit/livekit-cli/cmd/lk@latest +# - OwnCord server running with LiveKit enabled +# - LIVEKIT_URL and LIVEKIT_API_KEY/SECRET set (or pass via flags) +# +# Usage: +# ./voice-test.sh +# LIVEKIT_URL=ws://remote:7880 ./voice-test.sh + +set -euo pipefail + +LIVEKIT_URL="${LIVEKIT_URL:-ws://localhost:7880}" +API_KEY="${LIVEKIT_API_KEY:-devkey}" +API_SECRET="${LIVEKIT_API_SECRET:-secret}" +TEST_ROOM="voice-test-$(date +%s)" + +echo "=== LiveKit Voice Integration Test ===" +echo "URL: $LIVEKIT_URL" +echo "Room: $TEST_ROOM" +echo "" + +# Verify lk CLI is available +if ! command -v lk &>/dev/null; then + echo "ERROR: lk (LiveKit CLI) not found." + echo "Install: go install github.com/livekit/livekit-cli/cmd/lk@latest" + exit 1 +fi + +# 1. Create a test room +echo "[1/4] Creating test room..." +lk room create "$TEST_ROOM" \ + --url "$LIVEKIT_URL" \ + --api-key "$API_KEY" \ + --api-secret "$API_SECRET" \ + 2>/dev/null && echo " OK" || echo " SKIP (room may not need explicit creation)" + +# 2. Generate tokens for 2 test participants +echo "[2/4] Generating participant tokens..." +TOKEN_A=$(lk token create \ + --api-key "$API_KEY" \ + --api-secret "$API_SECRET" \ + --join --room "$TEST_ROOM" \ + --identity "test-user-a" \ + --valid-for 5m 2>/dev/null) +echo " Token A: ${TOKEN_A:0:20}..." + +TOKEN_B=$(lk token create \ + --api-key "$API_KEY" \ + --api-secret "$API_SECRET" \ + --join --room "$TEST_ROOM" \ + --identity "test-user-b" \ + --valid-for 5m 2>/dev/null) +echo " Token B: ${TOKEN_B:0:20}..." + +# 3. Load test with synthetic participants +echo "[3/4] Running load test (2 publishers, 2 subscribers, 10s)..." +lk load-test \ + --url "$LIVEKIT_URL" \ + --api-key "$API_KEY" \ + --api-secret "$API_SECRET" \ + --room "$TEST_ROOM" \ + --audio-publishers 2 \ + --subscribers 2 \ + --duration 10s \ + 2>&1 | tail -5 + +# 4. Cleanup +echo "[4/4] Cleaning up test room..." +lk room delete "$TEST_ROOM" \ + --url "$LIVEKIT_URL" \ + --api-key "$API_KEY" \ + --api-secret "$API_SECRET" \ + 2>/dev/null && echo " OK" || echo " SKIP" + +echo "" +echo "=== Voice test complete ===" diff --git a/Server/storage/storage.go b/Server/storage/storage.go index f71549c1..82a2a21b 100644 --- a/Server/storage/storage.go +++ b/Server/storage/storage.go @@ -5,6 +5,7 @@ import ( "bytes" "fmt" "io" + "log/slog" "os" "path/filepath" "strings" @@ -135,7 +136,9 @@ func (s *Storage) Save(uuid string, r io.Reader) error { if written > maxBytes { // File exceeds limit — remove the partial write and reject. _ = f.Close() - _ = os.Remove(dst) + if removeErr := os.Remove(dst); removeErr != nil { + slog.Error("storage: failed to remove oversized file", "path", dst, "err", removeErr) + } return fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB) } return nil diff --git a/Server/ws/client.go b/Server/ws/client.go index e6687ca5..1f706553 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -2,6 +2,7 @@ package ws import ( "sync" + "time" "github.com/owncord/server/db" ) @@ -24,11 +25,13 @@ type Client struct { voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu roleName string // cached role name for chat_message broadcasts tokenHash string // SHA-256 hex of the session token; used for periodic revalidation - msgCount int // count of messages processed; resets after session check - sendClosed bool // true after the send channel has been closed - send chan []byte - mu sync.Mutex // guards sendClosed, msgCount, channelID - voiceMu sync.Mutex // guards voiceChID + msgCount int // count of messages processed; resets after session check + invalidCount int // consecutive invalid messages; reset on valid parse + lastActivity time.Time // last message received from this client; guarded by mu + sendClosed bool // true after the send channel has been closed + send chan []byte + mu sync.Mutex // guards sendClosed, msgCount, channelID, lastActivity + voiceMu sync.Mutex // guards voiceChID } // wsConn is the subset of nhooyr.io/websocket.Conn used by writePump/readPump. @@ -41,12 +44,13 @@ type wsConn interface { // newClient creates a real client wrapping a WebSocket connection (set by serve.go). func newClient(hub *Hub, conn wsConn, user *db.User, tokenHash string) *Client { return &Client{ - hub: hub, - conn: conn, - userID: user.ID, - user: user, - tokenHash: tokenHash, - send: make(chan []byte, sendBufSize), + hub: hub, + conn: conn, + userID: user.ID, + user: user, + tokenHash: tokenHash, + lastActivity: time.Now(), + send: make(chan []byte, sendBufSize), } } @@ -108,6 +112,20 @@ func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, chann } } +// touch updates the last activity timestamp to now. +func (c *Client) touch() { + c.mu.Lock() + c.lastActivity = time.Now() + c.mu.Unlock() +} + +// getLastActivity returns the last activity timestamp under mu. +func (c *Client) getLastActivity() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.lastActivity +} + // getChannelID returns the currently focused channel ID under mu. func (c *Client) getChannelID() int64 { c.mu.Lock() diff --git a/Server/ws/errors.go b/Server/ws/errors.go new file mode 100644 index 00000000..4d73ce5d --- /dev/null +++ b/Server/ws/errors.go @@ -0,0 +1,19 @@ +package ws + +// WebSocket error codes used in buildErrorMsg calls. +const ( + ErrCodeBadRequest = "BAD_REQUEST" + ErrCodeInternal = "INTERNAL" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeForbidden = "FORBIDDEN" + ErrCodeRateLimited = "RATE_LIMITED" + ErrCodeAlreadyJoined = "ALREADY_JOINED" + ErrCodeChannelFull = "CHANNEL_FULL" + ErrCodeVoiceError = "VOICE_ERROR" + ErrCodeVideoLimit = "VIDEO_LIMIT" + ErrCodeBanned = "BANNED" + ErrCodeInvalidJSON = "INVALID_JSON" + ErrCodeUnknownType = "UNKNOWN_TYPE" + ErrCodeSlowMode = "SLOW_MODE" + ErrCodeConflict = "CONFLICT" +) diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index a2430f58..4d19d285 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -63,7 +63,7 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires} if auth.IsEffectivelyBanned(tempUser) { slog.Info("ws user banned, closing connection", "user_id", c.userID) - c.sendMsg(buildErrorMsg("BANNED", "you are banned")) + c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned")) h.kickClient(c) return } @@ -71,12 +71,34 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { var env envelope if err := json.Unmarshal(raw, &env); err != nil { - slog.Warn("ws handleMessage invalid JSON", "user_id", c.userID, "err", err) - c.sendMsg(buildErrorMsg("INVALID_JSON", "message must be valid JSON")) + c.mu.Lock() + c.invalidCount++ + count := c.invalidCount + c.mu.Unlock() + + slog.Warn("ws handleMessage invalid JSON", "user_id", c.userID, "err", err, "invalid_count", count) + c.sendMsg(buildErrorMsg(ErrCodeInvalidJSON, "message must be valid JSON")) + + if count >= 10 { + slog.Warn("ws too many invalid messages, closing connection", "user_id", c.userID, "invalid_count", count) + h.kickClient(c) + } return } - slog.Debug("ws ← client message", "type", env.Type, "user_id", c.userID, "id", env.ID) + // Valid parse — reset consecutive invalid counter. + c.mu.Lock() + c.invalidCount = 0 + c.mu.Unlock() + + // Request-scoped logger with correlation context. + reqLog := slog.With( + "user_id", c.userID, + "msg_type", env.Type, + "req_id", env.ID, + ) + + reqLog.Debug("ws ← client message") switch env.Type { case "chat_send": @@ -110,8 +132,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { case "ping": c.sendMsg(buildJSON(map[string]any{"type": "pong"})) default: - slog.Warn("ws handleMessage unknown type", "type", env.Type, "user_id", c.userID) - c.sendMsg(buildErrorMsg("UNKNOWN_TYPE", fmt.Sprintf("unknown message type: %s", env.Type))) + reqLog.Warn("ws handleMessage unknown type") + c.sendMsg(buildErrorMsg(ErrCodeUnknownType, fmt.Sprintf("unknown message type: %s", env.Type))) } } @@ -131,19 +153,19 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { Attachments []string `json:"attachments"` } if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_send payload")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_send payload")) return } channelID, err := p.ChannelID.Int64() if err != nil || channelID <= 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be a positive integer")) return } // Check channel exists. ch, err := h.db.GetChannel(channelID) if err != nil || ch == nil { - c.sendMsg(buildErrorMsg("NOT_FOUND", "channel not found")) + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) return } @@ -156,7 +178,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { if ch.SlowMode > 0 && !h.hasChannelPerm(c, channelID, permissions.ManageMessages) { slowKey := fmt.Sprintf("slow:%d:%d", c.userID, channelID) if !h.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { - c.sendMsg(buildErrorMsg("SLOW_MODE", fmt.Sprintf("channel has %ds slow mode", ch.SlowMode))) + c.sendMsg(buildErrorMsg(ErrCodeSlowMode, fmt.Sprintf("channel has %ds slow mode", ch.SlowMode))) return } } @@ -164,11 +186,11 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { // Sanitize and validate content length. content := sanitizer.Sanitize(p.Content) if content == "" && len(p.Attachments) == 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content cannot be empty")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message content cannot be empty")) return } if len([]rune(content)) > 4000 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content exceeds maximum length of 4000 characters")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message content exceeds maximum length of 4000 characters")) return } @@ -183,7 +205,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { msgID, err := h.db.CreateMessage(channelID, c.userID, content, p.ReplyTo) if err != nil { slog.Error("ws handleChatSend CreateMessage", "err", err) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to save message")) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to save message")) return } @@ -216,7 +238,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { msg, err := h.db.GetMessage(msgID) if err != nil || msg == nil { slog.Error("ws handleChatSend GetMessage after create", "err", err) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to retrieve message")) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to retrieve message")) return } @@ -227,7 +249,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { avatar = c.user.Avatar } - slog.Info("message sent", "user", username, "channel_id", channelID, "msg_id", msgID) + slog.Debug("message sent", "user", username, "channel_id", channelID, "msg_id", msgID) // Ack sender. c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp)) @@ -250,31 +272,31 @@ func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) { Content string `json:"content"` } if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_edit payload")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_edit payload")) return } msgID, err := p.MessageID.Int64() if err != nil || msgID <= 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer")) return } content := sanitizer.Sanitize(p.Content) if content == "" { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "content cannot be empty")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "content cannot be empty")) return } // EditMessage checks ownership internally. if err := h.db.EditMessage(msgID, c.userID, content); err != nil { - c.sendMsg(buildErrorMsg("FORBIDDEN", "cannot edit this message")) + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message")) return } msg, err := h.db.GetMessage(msgID) if err != nil || msg == nil { slog.Error("ws handleChatEdit GetMessage after edit", "err", err, "msg_id", msgID) - c.sendMsg(buildErrorMsg("INTERNAL", "edit saved but broadcast failed")) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "edit saved but broadcast failed")) return } @@ -282,7 +304,7 @@ func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) { if msg.EditedAt != nil { editedAt = *msg.EditedAt } - slog.Info("message edited", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID) + slog.Debug("message edited", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID) h.BroadcastToChannel(msg.ChannelID, buildChatEdited(msgID, msg.ChannelID, content, editedAt)) } @@ -298,28 +320,28 @@ func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) { MessageID json.Number `json:"message_id"` } if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_delete payload")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_delete payload")) return } msgID, err := p.MessageID.Int64() if err != nil || msgID <= 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer")) return } msg, err := h.db.GetMessage(msgID) if err != nil || msg == nil { - c.sendMsg(buildErrorMsg("NOT_FOUND", "message not found")) + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "message not found")) return } isMod := h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages) if err := h.db.DeleteMessage(msgID, c.userID, isMod); err != nil { - c.sendMsg(buildErrorMsg("FORBIDDEN", "cannot delete this message")) + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot delete this message")) return } - slog.Info("message deleted", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) + slog.Debug("message deleted", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) _ = h.db.LogAudit(c.userID, "message_delete", "message", msgID, fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) h.BroadcastToChannel(msg.ChannelID, buildChatDeleted(msgID, msg.ChannelID)) @@ -338,26 +360,26 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { Emoji string `json:"emoji"` } if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid reaction payload")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid reaction payload")) return } msgID, err := p.MessageID.Int64() if err != nil || msgID <= 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer")) return } if p.Emoji == "" { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji cannot be empty")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji cannot be empty")) return } if len(p.Emoji) > 32 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji too long")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji too long")) return } // Reject control characters (U+0000–U+001F, U+007F) to prevent injection. for _, r := range p.Emoji { if r < 0x20 || r == 0x7F { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji contains invalid characters")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji contains invalid characters")) return } } @@ -366,7 +388,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { if err != nil || msg == nil { // Normalize: return same error whether message doesn't exist or is in // a channel the user can't see (prevents IDOR information leak). - c.sendMsg(buildErrorMsg("BAD_REQUEST", "reaction failed")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "reaction failed")) return } @@ -384,7 +406,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { if err != nil { // Sanitize: never leak raw DB constraint errors to client. slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", c.userID, "err", err) - c.sendMsg(buildErrorMsg("CONFLICT", "reaction failed")) + c.sendMsg(buildErrorMsg(ErrCodeConflict, "reaction failed")) return } @@ -395,7 +417,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { func (h *Hub) handleTyping(c *Client, payload json.RawMessage) { channelID, err := parseChannelID(payload) if err != nil || channelID <= 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be positive integer")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be positive integer")) return } @@ -425,17 +447,19 @@ func (h *Hub) handlePresence(c *Client, payload json.RawMessage) { Status string `json:"status"` } if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid presence_update payload")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid presence_update payload")) return } validStatuses := map[string]bool{"online": true, "idle": true, "dnd": true, "offline": true} if !validStatuses[p.Status] { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "status must be online|idle|dnd|offline")) + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "status must be online|idle|dnd|offline")) return } if err := h.db.UpdateUserStatus(c.userID, p.Status); err != nil { - slog.Error("ws handlePresence UpdateUserStatus", "err", err) + slog.Error("ws handlePresence UpdateUserStatus", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update status")) + return } h.BroadcastToAll(buildPresenceMsg(c.userID, p.Status)) @@ -471,7 +495,7 @@ func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLab return true } slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel) - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing "+permLabel+" permission")) + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing "+permLabel+" permission")) return false } @@ -510,7 +534,7 @@ func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { c.channelID = chID c.mu.Unlock() - slog.Info("channel_focus", "user_id", c.userID, "channel_id", chID, "prev_channel_id", prevCh) + slog.Debug("channel_focus", "user_id", c.userID, "channel_id", chID, "prev_channel_id", prevCh) // Mark channel as read by updating read_states to the latest message. latestID, latestErr := h.db.GetLatestMessageID(chID) diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 6618d361..39832b82 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -2,8 +2,11 @@ package ws import ( + "fmt" "log/slog" + "runtime" "sync" + "sync/atomic" "time" "github.com/owncord/server/auth" @@ -31,6 +34,9 @@ type Hub struct { livekit *LiveKitClient lkProcess *LiveKitProcess + seq uint64 // atomic monotonic sequence counter + replayBuf *EventRingBuffer // recent broadcast events for reconnection replay + // Settings cache — avoids per-connection DB queries for server_name/motd. settingsMu sync.RWMutex settingsName string @@ -49,6 +55,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub { register: make(chan *Client, 32), unregister: make(chan *Client, 32), stop: make(chan struct{}), + replayBuf: NewEventRingBuffer(1000), settingsName: "OwnCord Server", settingsMotd: "Welcome!", } @@ -104,28 +111,75 @@ func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) { // Run starts the hub's dispatch loop. It blocks until Stop is called. // Must be called in its own goroutine. +// +// A panic recovery wrapper restarts the select loop automatically. If the hub +// panics more than 5 times within a 60-second window it stops permanently to +// avoid a tight crash loop. func (h *Hub) Run() { + var panicCount int + var lastPanicReset time.Time + for { + func() { + staleTicker := time.NewTicker(30 * time.Second) + defer staleTicker.Stop() + + defer func() { + if r := recover(); r != nil { + panicCount++ + now := time.Now() + if lastPanicReset.IsZero() || now.Sub(lastPanicReset) > 60*time.Second { + panicCount = 1 + lastPanicReset = now + } + + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + slog.Error("hub: panic recovered", + "panic", r, + "panic_count", panicCount, + "stack", string(buf[:n])) + + if panicCount >= 5 { + slog.Error("hub: too many panics in 60s, stopping") + return + } + } + }() + + for { + select { + case <-h.stop: + return + case c := <-h.register: + h.mu.Lock() + h.clients[c.userID] = c + slog.Info("hub: client registered", "user_id", c.userID, "total_clients", len(h.clients)) + h.mu.Unlock() + case c := <-h.unregister: + h.mu.Lock() + if current, ok := h.clients[c.userID]; ok && current == c { + delete(h.clients, c.userID) + slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients)) + } + h.mu.Unlock() + case bm := <-h.broadcast: + h.deliverBroadcast(bm) + case <-staleTicker.C: + h.sweepStaleClients() + } + } + }() + + // If we reach here without a panic recovery continuing, stop. + if panicCount >= 5 { + return + } + // If stop was signaled, exit. select { case <-h.stop: return - - case c := <-h.register: - h.mu.Lock() - h.clients[c.userID] = c - slog.Info("hub: client registered", "user_id", c.userID, "total_clients", len(h.clients)) - h.mu.Unlock() - - case c := <-h.unregister: - h.mu.Lock() - if current, ok := h.clients[c.userID]; ok && current == c { - delete(h.clients, c.userID) - slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients)) - } - h.mu.Unlock() - - case bm := <-h.broadcast: - h.deliverBroadcast(bm) + default: } } } @@ -137,9 +191,25 @@ func (h *Hub) Stop() { // GracefulStop stops the LiveKit process (if managed) and then stops the hub. func (h *Hub) GracefulStop() { + // Broadcast restart notice to all connected clients. + h.BroadcastServerRestart("shutdown", 5) + + // Stop LiveKit process. if h.lkProcess != nil { h.lkProcess.Stop() } + + // Give clients 5 seconds to disconnect gracefully. + time.Sleep(5 * time.Second) + + // Close all remaining client connections. + h.mu.Lock() + for _, c := range h.clients { + c.closeSend() + } + h.mu.Unlock() + + // Stop the hub dispatch loop. h.stopOnce.Do(func() { close(h.stop) }) } @@ -280,8 +350,64 @@ func (h *Hub) kickClient(c *Client) { c.closeSend() } -// deliverBroadcast sends bm.msg to the appropriate clients. +// nextSeq returns the next monotonic sequence number for broadcast messages. +func (h *Hub) nextSeq() uint64 { + return atomic.AddUint64(&h.seq, 1) +} + +// ReplayBuffer returns the hub's event ring buffer for reconnection replay. +func (h *Hub) ReplayBuffer() *EventRingBuffer { + return h.replayBuf +} + +// wrapWithSeq injects a "seq" field into a JSON message without re-serializing. +func wrapWithSeq(msg []byte, seq uint64) []byte { + // Fast path: inject seq after the opening brace. + // e.g., {"type":"chat_message",...} → {"seq":123,"type":"chat_message",...} + if len(msg) > 0 && msg[0] == '{' { + prefix := fmt.Sprintf(`{"seq":%d,`, seq) + result := make([]byte, 0, len(prefix)+len(msg)-1) + result = append(result, prefix...) + result = append(result, msg[1:]...) // skip opening brace + return result + } + return msg +} + +// staleClientTimeout is the maximum duration a client can go without sending +// any message before being considered stale and disconnected. The client sends +// a ping every 30s, so 90s (3x) gives plenty of margin. +const staleClientTimeout = 90 * time.Second + +// sweepStaleClients iterates over all connected clients and kicks any that +// have not sent a message within staleClientTimeout. +func (h *Hub) sweepStaleClients() { + now := time.Now() + h.mu.RLock() + var stale []*Client + for _, c := range h.clients { + if now.Sub(c.getLastActivity()) > staleClientTimeout { + stale = append(stale, c) + } + } + h.mu.RUnlock() + + for _, c := range stale { + slog.Warn("hub: closing stale connection (no activity)", + "user_id", c.userID, "last_activity", c.getLastActivity()) + h.kickClient(c) + } +} + +// deliverBroadcast stamps bm.msg with a monotonic sequence number, stores it +// in the replay buffer, and sends it to the appropriate clients. func (h *Hub) deliverBroadcast(bm broadcastMsg) { + seq := h.nextSeq() + msg := wrapWithSeq(bm.msg, seq) + + // Store in replay buffer for reconnection recovery. + h.replayBuf.Push(seq, msg) + h.mu.RLock() defer h.mu.RUnlock() @@ -293,11 +419,11 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) { skipped++ continue } - c.sendMsg(bm.msg) + c.sendMsg(msg) delivered++ } if bm.channelID != 0 { slog.Debug("hub: channel broadcast", - "channel_id", bm.channelID, "delivered", delivered, "skipped", skipped) + "channel_id", bm.channelID, "delivered", delivered, "skipped", skipped, "seq", seq) } } diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index c336b428..b78a2560 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -425,12 +425,33 @@ func TestHub_GetClient(t *testing.T) { // ─── assertion helpers ──────────────────────────────────────────────────────── +// assertReceived checks that a message was received and contains the same JSON +// fields as want (ignoring the "seq" field injected by broadcast delivery). func assertReceived(t *testing.T, ch <-chan []byte, want []byte, label string) { t.Helper() select { case got := <-ch: - if string(got) != string(want) { - t.Errorf("%s: got %q, want %q", label, got, want) + var gotMap map[string]json.RawMessage + if err := json.Unmarshal(got, &gotMap); err != nil { + t.Errorf("%s: unmarshal got: %v", label, err) + return + } + var wantMap map[string]json.RawMessage + if err := json.Unmarshal(want, &wantMap); err != nil { + t.Errorf("%s: unmarshal want: %v", label, err) + return + } + // Strip seq before comparing — broadcasts have it, direct sends don't. + delete(gotMap, "seq") + for k, wv := range wantMap { + gv, ok := gotMap[k] + if !ok { + t.Errorf("%s: missing key %q in received message", label, k) + continue + } + if string(gv) != string(wv) { + t.Errorf("%s: key %q: got %s, want %s", label, k, gv, wv) + } } case <-time.After(500 * time.Millisecond): t.Errorf("%s: did not receive expected message within timeout", label) diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 1baf1ed0..95bc7fca 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -14,6 +14,143 @@ type envelope struct { Payload json.RawMessage `json:"payload,omitempty"` } +// wsMsg is the generic envelope for outbound WebSocket messages. +type wsMsg struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Payload any `json:"payload,omitempty"` +} + +// --------------------------------------------------------------------------- +// Payload structs — one per outbound message type. +// --------------------------------------------------------------------------- + +type presencePayload struct { + UserID int64 `json:"user_id"` + Status string `json:"status"` +} + +type memberUserPayload struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + Role string `json:"role"` +} + +type memberJoinPayload struct { + User memberUserPayload `json:"user"` +} + +type chatMessagePayload struct { + ID int64 `json:"id"` + ChannelID int64 `json:"channel_id"` + User memberUserPayload `json:"user"` + Content string `json:"content"` + ReplyTo *int64 `json:"reply_to"` + Timestamp string `json:"timestamp"` + Attachments []map[string]any `json:"attachments"` + Reactions []any `json:"reactions"` + Pinned bool `json:"pinned"` +} + +type memberUpdatePayload struct { + UserID int64 `json:"user_id"` + Role string `json:"role"` +} + +type memberBanPayload struct { + UserID int64 `json:"user_id"` +} + +type chatSendOKPayload struct { + MessageID int64 `json:"message_id"` + Timestamp string `json:"timestamp"` +} + +type chatEditedPayload struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + Content string `json:"content"` + EditedAt string `json:"edited_at"` +} + +type chatDeletedPayload struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` +} + +type reactionUpdatePayload struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + Emoji string `json:"emoji"` + UserID int64 `json:"user_id"` + Action string `json:"action"` +} + +type typingPayload struct { + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` +} + +type voiceStatePayload struct { + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Muted bool `json:"muted"` + Deafened bool `json:"deafened"` + Speaking bool `json:"speaking"` + Camera bool `json:"camera"` + Screenshare bool `json:"screenshare"` +} + +type voiceConfigPayload struct { + ChannelID int64 `json:"channel_id"` + Quality string `json:"quality"` + Bitrate int `json:"bitrate"` + MaxUsers int `json:"max_users"` +} + +type voiceTokenPayload struct { + ChannelID int64 `json:"channel_id"` + Token string `json:"token"` + URL string `json:"url"` + DirectURL string `json:"direct_url"` +} + +type voiceSpeakersPayload struct { + ChannelID int64 `json:"channel_id"` + Speakers []int64 `json:"speakers"` + ThresholdMode string `json:"threshold_mode"` +} + +type voiceLeavePayload struct { + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` +} + +type channelPayload struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Topic string `json:"topic"` + Position int `json:"position"` +} + +type channelDeletePayload struct { + ID int64 `json:"id"` +} + +type serverRestartPayload struct { + Reason string `json:"reason"` + DelaySeconds int `json:"delay_seconds"` +} + +// --------------------------------------------------------------------------- +// Builder helpers (kept as maps per task spec). +// --------------------------------------------------------------------------- + // buildJSON marshals v into a JSON byte slice, logging on failure. func buildJSON(v any) []byte { b, err := json.Marshal(v) @@ -58,31 +195,28 @@ func buildAuthError(message string) []byte { }) } +// --------------------------------------------------------------------------- +// Typed message builders. +// --------------------------------------------------------------------------- + // buildPresenceMsg constructs a presence broadcast payload. func buildPresenceMsg(userID int64, status string) []byte { - return buildJSON(map[string]any{ - "type": "presence", - "payload": map[string]any{ - "user_id": userID, - "status": status, - }, + return buildJSON(wsMsg{ + Type: "presence", + Payload: presencePayload{UserID: userID, Status: status}, }) } // buildMemberJoin constructs a member_join broadcast for when a user comes online. func buildMemberJoin(user *db.User, roleName string) []byte { - var avatarVal any - if user.Avatar != nil { - avatarVal = *user.Avatar - } - return buildJSON(map[string]any{ - "type": "member_join", - "payload": map[string]any{ - "user": map[string]any{ - "id": user.ID, - "username": user.Username, - "avatar": avatarVal, - "role": roleName, + return buildJSON(wsMsg{ + Type: "member_join", + Payload: memberJoinPayload{ + User: memberUserPayload{ + ID: user.ID, + Username: user.Username, + Avatar: user.Avatar, + Role: roleName, }, }, }) @@ -91,142 +225,128 @@ func buildMemberJoin(user *db.User, roleName string) []byte { // buildChatMessage constructs a chat_message broadcast envelope. // Includes role in user object and empty reactions array for consistency with REST API. func buildChatMessage(msgID, channelID, userID int64, username string, avatar *string, roleName string, content string, timestamp string, replyTo *int64, attachments []map[string]any) []byte { - var avatarVal any - if avatar != nil { - avatarVal = *avatar - } if attachments == nil { attachments = []map[string]any{} } - return buildJSON(map[string]any{ - "type": "chat_message", - "payload": map[string]any{ - "id": msgID, - "channel_id": channelID, - "user": map[string]any{ - "id": userID, - "username": username, - "avatar": avatarVal, - "role": roleName, + return buildJSON(wsMsg{ + Type: "chat_message", + Payload: chatMessagePayload{ + ID: msgID, + ChannelID: channelID, + User: memberUserPayload{ + ID: userID, + Username: username, + Avatar: avatar, + Role: roleName, }, - "content": content, - "reply_to": replyTo, - "timestamp": timestamp, - "attachments": attachments, - "reactions": []any{}, + Content: content, + ReplyTo: replyTo, + Timestamp: timestamp, + Attachments: attachments, + Reactions: []any{}, + Pinned: false, }, }) } // buildMemberUpdate constructs a member_update broadcast per PROTOCOL.md. func buildMemberUpdate(userID int64, roleName string) []byte { - return buildJSON(map[string]any{ - "type": "member_update", - "payload": map[string]any{ - "user_id": userID, - "role": roleName, - }, + return buildJSON(wsMsg{ + Type: "member_update", + Payload: memberUpdatePayload{UserID: userID, Role: roleName}, }) } // buildMemberBan constructs a member_ban broadcast per PROTOCOL.md. func buildMemberBan(userID int64) []byte { - return buildJSON(map[string]any{ - "type": "member_ban", - "payload": map[string]any{ - "user_id": userID, - }, + return buildJSON(wsMsg{ + Type: "member_ban", + Payload: memberBanPayload{UserID: userID}, }) } // buildChatSendOK constructs a chat_send_ok ack. func buildChatSendOK(requestID string, msgID int64, timestamp string) []byte { - return buildJSON(map[string]any{ - "type": "chat_send_ok", - "id": requestID, - "payload": map[string]any{ - "message_id": msgID, - "timestamp": timestamp, - }, + return buildJSON(wsMsg{ + Type: "chat_send_ok", + ID: requestID, + Payload: chatSendOKPayload{MessageID: msgID, Timestamp: timestamp}, }) } // buildChatEdited constructs a chat_edited broadcast. func buildChatEdited(msgID, channelID int64, content, editedAt string) []byte { - return buildJSON(map[string]any{ - "type": "chat_edited", - "payload": map[string]any{ - "message_id": msgID, - "channel_id": channelID, - "content": content, - "edited_at": editedAt, + return buildJSON(wsMsg{ + Type: "chat_edited", + Payload: chatEditedPayload{ + MessageID: msgID, + ChannelID: channelID, + Content: content, + EditedAt: editedAt, }, }) } // buildChatDeleted constructs a chat_deleted broadcast. func buildChatDeleted(msgID, channelID int64) []byte { - return buildJSON(map[string]any{ - "type": "chat_deleted", - "payload": map[string]any{ - "message_id": msgID, - "channel_id": channelID, - }, + return buildJSON(wsMsg{ + Type: "chat_deleted", + Payload: chatDeletedPayload{MessageID: msgID, ChannelID: channelID}, }) } // buildReactionUpdate constructs a reaction_update broadcast. func buildReactionUpdate(msgID, channelID, userID int64, emoji, action string) []byte { - return buildJSON(map[string]any{ - "type": "reaction_update", - "payload": map[string]any{ - "message_id": msgID, - "channel_id": channelID, - "emoji": emoji, - "user_id": userID, - "action": action, + return buildJSON(wsMsg{ + Type: "reaction_update", + Payload: reactionUpdatePayload{ + MessageID: msgID, + ChannelID: channelID, + Emoji: emoji, + UserID: userID, + Action: action, }, }) } // buildTypingMsg constructs a typing broadcast. func buildTypingMsg(channelID, userID int64, username string) []byte { - return buildJSON(map[string]any{ - "type": "typing", - "payload": map[string]any{ - "channel_id": channelID, - "user_id": userID, - "username": username, + return buildJSON(wsMsg{ + Type: "typing", + Payload: typingPayload{ + ChannelID: channelID, + UserID: userID, + Username: username, }, }) } // buildVoiceState constructs a voice_state server->client broadcast. func buildVoiceState(state db.VoiceState) []byte { - return buildJSON(map[string]any{ - "type": "voice_state", - "payload": map[string]any{ - "channel_id": state.ChannelID, - "user_id": state.UserID, - "username": state.Username, - "muted": state.Muted, - "deafened": state.Deafened, - "speaking": state.Speaking, - "camera": state.Camera, - "screenshare": state.Screenshare, + return buildJSON(wsMsg{ + Type: "voice_state", + Payload: voiceStatePayload{ + ChannelID: state.ChannelID, + UserID: state.UserID, + Username: state.Username, + Muted: state.Muted, + Deafened: state.Deafened, + Speaking: state.Speaking, + Camera: state.Camera, + Screenshare: state.Screenshare, }, }) } // buildVoiceConfig constructs a voice_config message sent after voice_join acceptance. func buildVoiceConfig(channelID int64, quality string, bitrate int, maxUsers int) []byte { - return buildJSON(map[string]any{ - "type": "voice_config", - "payload": map[string]any{ - "channel_id": channelID, - "quality": quality, - "bitrate": bitrate, - "max_users": maxUsers, + return buildJSON(wsMsg{ + Type: "voice_config", + Payload: voiceConfigPayload{ + ChannelID: channelID, + Quality: quality, + Bitrate: bitrate, + MaxUsers: maxUsers, }, }) } @@ -235,88 +355,82 @@ func buildVoiceConfig(channelID int64, quality string, bitrate int, maxUsers int // url is the proxy path ("/livekit") for remote clients; direct_url is the raw // LiveKit URL (e.g. "ws://localhost:7880") for localhost clients. func buildVoiceToken(channelID int64, token string, proxyPath string, directURL string) []byte { - return buildJSON(map[string]any{ - "type": "voice_token", - "payload": map[string]any{ - "channel_id": channelID, - "token": token, - "url": proxyPath, - "direct_url": directURL, + return buildJSON(wsMsg{ + Type: "voice_token", + Payload: voiceTokenPayload{ + ChannelID: channelID, + Token: token, + URL: proxyPath, + DirectURL: directURL, }, }) } // buildVoiceSpeakers constructs a voice_speakers broadcast. func buildVoiceSpeakers(channelID int64, speakers []int64, mode string) []byte { - return buildJSON(map[string]any{ - "type": "voice_speakers", - "payload": map[string]any{ - "channel_id": channelID, - "speakers": speakers, - "threshold_mode": mode, + return buildJSON(wsMsg{ + Type: "voice_speakers", + Payload: voiceSpeakersPayload{ + ChannelID: channelID, + Speakers: speakers, + ThresholdMode: mode, }, }) } // buildVoiceLeave constructs a voice_leave server->client broadcast. func buildVoiceLeave(channelID, userID int64) []byte { - return buildJSON(map[string]any{ - "type": "voice_leave", - "payload": map[string]any{ - "channel_id": channelID, - "user_id": userID, - }, + return buildJSON(wsMsg{ + Type: "voice_leave", + Payload: voiceLeavePayload{ChannelID: channelID, UserID: userID}, }) } - // buildChannelCreate constructs a channel_create broadcast. func buildChannelCreate(ch *db.Channel) []byte { - return buildJSON(map[string]any{ - "type": "channel_create", - "payload": map[string]any{ - "id": ch.ID, - "name": ch.Name, - "type": ch.Type, - "category": ch.Category, - "topic": ch.Topic, - "position": ch.Position, + return buildJSON(wsMsg{ + Type: "channel_create", + Payload: channelPayload{ + ID: ch.ID, + Name: ch.Name, + Type: ch.Type, + Category: ch.Category, + Topic: ch.Topic, + Position: ch.Position, }, }) } // buildChannelUpdate constructs a channel_update broadcast. func buildChannelUpdate(ch *db.Channel) []byte { - return buildJSON(map[string]any{ - "type": "channel_update", - "payload": map[string]any{ - "id": ch.ID, - "name": ch.Name, - "type": ch.Type, - "category": ch.Category, - "topic": ch.Topic, - "position": ch.Position, + return buildJSON(wsMsg{ + Type: "channel_update", + Payload: channelPayload{ + ID: ch.ID, + Name: ch.Name, + Type: ch.Type, + Category: ch.Category, + Topic: ch.Topic, + Position: ch.Position, }, }) } // buildChannelDelete constructs a channel_delete broadcast. func buildChannelDelete(channelID int64) []byte { - return buildJSON(map[string]any{ - "type": "channel_delete", - "payload": map[string]any{ - "id": channelID, - }, + return buildJSON(wsMsg{ + Type: "channel_delete", + Payload: channelDeletePayload{ID: channelID}, }) } // buildServerRestartMsg constructs a server_restart broadcast. func buildServerRestartMsg(reason string, delaySeconds int) []byte { - return buildJSON(map[string]any{ - "type": "server_restart", - "payload": map[string]any{ - "reason": reason, - "delay_seconds": delaySeconds, + return buildJSON(wsMsg{ + Type: "server_restart", + Payload: serverRestartPayload{ + Reason: reason, + DelaySeconds: delaySeconds, }, }) } diff --git a/Server/ws/messages_test.go b/Server/ws/messages_test.go index 987a1882..97366da4 100644 --- a/Server/ws/messages_test.go +++ b/Server/ws/messages_test.go @@ -32,16 +32,6 @@ func TestBuildServerRestartMsg(t *testing.T) { // ─── channel CRUD message builders ─────────────────────────────────────────── -// channelPayload is the common shape expected in channel_create/update payloads. -type channelPayload struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Topic string `json:"topic"` - Position int `json:"position"` -} - func sampleChannel() *db.Channel { return &db.Channel{ ID: 42, diff --git a/Server/ws/ringbuffer.go b/Server/ws/ringbuffer.go new file mode 100644 index 00000000..b7c3e4fd --- /dev/null +++ b/Server/ws/ringbuffer.go @@ -0,0 +1,78 @@ +package ws + +import "sync" + +// eventEntry stores a broadcast event for potential replay. +type eventEntry struct { + seq uint64 + data []byte +} + +// EventRingBuffer is a bounded, thread-safe ring buffer for recent broadcast events. +type EventRingBuffer struct { + mu sync.RWMutex + entries []eventEntry + size int + pos int // next write position + count int // total entries stored (up to size) +} + +// NewEventRingBuffer creates a ring buffer with the given capacity. +func NewEventRingBuffer(size int) *EventRingBuffer { + return &EventRingBuffer{ + entries: make([]eventEntry, size), + size: size, + } +} + +// Push adds an event to the ring buffer. +func (rb *EventRingBuffer) Push(seq uint64, data []byte) { + rb.mu.Lock() + defer rb.mu.Unlock() + rb.entries[rb.pos] = eventEntry{seq: seq, data: data} + rb.pos = (rb.pos + 1) % rb.size + if rb.count < rb.size { + rb.count++ + } +} + +// EventsSince returns all events with seq > afterSeq, in order. +// Returns nil if afterSeq is too old (no longer in the buffer). +func (rb *EventRingBuffer) EventsSince(afterSeq uint64) [][]byte { + rb.mu.RLock() + defer rb.mu.RUnlock() + + if rb.count == 0 { + return nil + } + + // Find the oldest entry in the buffer. + oldestIdx := (rb.pos - rb.count + rb.size) % rb.size + oldestSeq := rb.entries[oldestIdx].seq + + // If the requested seq is older than our oldest, we can't replay. + if afterSeq < oldestSeq { + return nil + } + + var result [][]byte + for i := 0; i < rb.count; i++ { + idx := (oldestIdx + i) % rb.size + e := rb.entries[idx] + if e.seq > afterSeq { + result = append(result, e.data) + } + } + return result +} + +// OldestSeq returns the oldest sequence number in the buffer, or 0 if empty. +func (rb *EventRingBuffer) OldestSeq() uint64 { + rb.mu.RLock() + defer rb.mu.RUnlock() + if rb.count == 0 { + return 0 + } + oldestIdx := (rb.pos - rb.count + rb.size) % rb.size + return rb.entries[oldestIdx].seq +} diff --git a/Server/ws/serve.go b/Server/ws/serve.go index a724408a..715e9541 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -36,7 +36,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun } conn.SetReadLimit(1 << 20) // 1 MB — match client-side limit - user, tokenHash, err := authenticateConn(conn, database) + user, tokenHash, lastSeq, err := authenticateConn(conn, database) if err != nil { slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr) _ = conn.Close(websocket.StatusPolicyViolation, "authentication failed") @@ -66,12 +66,44 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun _ = database.LogAudit(user.ID, "ws_connect", "user", user.ID, "WebSocket connected from "+r.RemoteAddr) + ctx := r.Context() + + // Reconnection with state recovery: if the client sent a last_seq, + // try to replay missed events from the ring buffer instead of + // sending a full ready payload. + if lastSeq > 0 { + events := hub.ReplayBuffer().EventsSince(lastSeq) + if events != nil { + // Replay succeeded — send auth_ok then missed events. + slog.Info("ws sending auth_ok (reconnect)", "user_id", user.ID, "username", user.Username, "role", roleName) + _ = conn.Write(ctx, websocket.MessageText, hub.buildAuthOK(user, roleName)) + for _, evt := range events { + _ = conn.Write(ctx, websocket.MessageText, evt) + } + slog.Info("ws replay completed", "user_id", user.ID, "events_replayed", len(events), "from_seq", lastSeq) + + // Update presence but skip member_join — user was already known. + if updateErr := database.UpdateUserStatus(user.ID, "online"); updateErr != nil { + slog.Warn("ws UpdateUserStatus", "err", updateErr) + } + hub.BroadcastToAll(buildPresenceMsg(user.ID, "online")) + + // Start pumps. + writeCtx, writeCancel := context.WithCancel(ctx) + go writePump(writeCtx, conn, c) + readPump(ctx, conn, hub, c) + writeCancel() + return + } + // Replay failed (seq too old) — fall through to full ready payload. + slog.Info("ws replay failed (seq too old), sending full ready", "user_id", user.ID, "last_seq", lastSeq) + } + + // Fresh connection or replay fallback: full auth_ok + ready flow. if updateErr := database.UpdateUserStatus(user.ID, "online"); updateErr != nil { slog.Warn("ws UpdateUserStatus", "err", updateErr) } - // Send auth_ok followed by the ready payload. - ctx := r.Context() slog.Info("ws sending auth_ok", "user_id", user.ID, "username", user.Username, "role", roleName) _ = conn.Write(ctx, websocket.MessageText, hub.buildAuthOK(user, roleName)) if ready, readyErr := hub.buildReady(database, user.ID); readyErr == nil { @@ -80,7 +112,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun } else { slog.Error("buildReady failed", "user_id", user.ID, "err", readyErr) _ = conn.Write(ctx, websocket.MessageText, - buildErrorMsg("INTERNAL", "failed to build ready payload")) + buildErrorMsg(ErrCodeInternal, "failed to build ready payload")) } slog.Info("ws broadcasting member_join and presence", "user_id", user.ID, "username", user.Username) @@ -134,6 +166,7 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { if err != nil { return } + c.touch() hub.handleMessage(c, msg) } } @@ -141,57 +174,58 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { // authenticateConn reads the first WebSocket message and validates the session // token. Returns the authenticated user and the token hash (for later // periodic session revalidation). -func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, error) { +func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, uint64, error) { ctx, cancel := context.WithTimeout(context.Background(), authDeadline) defer cancel() _, raw, err := conn.Read(ctx) if err != nil { - return nil, "", err + return nil, "", 0, err } var env envelope if err := json.Unmarshal(raw, &env); err != nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "invalid message")) - return nil, "", fmt.Errorf("auth: invalid JSON: %w", err) + return nil, "", 0, fmt.Errorf("auth: invalid JSON: %w", err) } if env.Type != "auth" { _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "first message must be auth")) - return nil, "", fmt.Errorf("auth: unexpected type %q", env.Type) + return nil, "", 0, fmt.Errorf("auth: unexpected type %q", env.Type) } var p struct { - Token string `json:"token"` + Token string `json:"token"` + LastSeq uint64 `json:"last_seq"` } if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" { _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "missing token")) - return nil, "", fmt.Errorf("auth: missing token") + return nil, "", 0, fmt.Errorf("auth: missing token") } hash := auth.HashToken(p.Token) sess, err := database.GetSessionByTokenHash(hash) if err != nil || sess == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "invalid token")) - return nil, "", fmt.Errorf("auth: invalid session") + return nil, "", 0, fmt.Errorf("auth: invalid session") } if auth.IsSessionExpired(sess.ExpiresAt) { _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "session expired")) - return nil, "", fmt.Errorf("auth: session expired") + return nil, "", 0, fmt.Errorf("auth: session expired") } user, err := database.GetUserByID(sess.UserID) if err != nil || user == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "user not found")) - return nil, "", fmt.Errorf("auth: user not found") + return nil, "", 0, fmt.Errorf("auth: user not found") } if auth.IsEffectivelyBanned(user) { - _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("BANNED", "you are banned")) - return nil, "", fmt.Errorf("auth: banned user %d", user.ID) + _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeBanned, "you are banned")) + return nil, "", 0, fmt.Errorf("auth: banned user %d", user.ID) } - return user, hash, nil + return user, hash, p.LastSeq, nil } // buildAuthOK constructs the auth_ok server→client message. diff --git a/Server/ws/voice_broadcast.go b/Server/ws/voice_broadcast.go new file mode 100644 index 00000000..9415e0cf --- /dev/null +++ b/Server/ws/voice_broadcast.go @@ -0,0 +1,41 @@ +package ws + +import ( + "log/slog" + "time" +) + +// Voice rate limit settings. +const ( + voiceCameraRateLimit = 2 + voiceCameraWindow = time.Second + voiceScreenshareRateLimit = 2 + voiceScreenshareWindow = time.Second +) + +// qualityBitrate returns the target audio bitrate in bits/s based on a quality preset. +func qualityBitrate(quality string) int { + switch quality { + case "low": + return 32000 + case "high": + return 128000 + default: + return 64000 + } +} + +// broadcastVoiceStateUpdate fetches the current voice state for the client +// and broadcasts it to all members of the voice channel they are in. +func (h *Hub) broadcastVoiceStateUpdate(c *Client) { + state, err := h.db.GetVoiceState(c.userID) + if err != nil { + slog.Error("ws broadcastVoiceStateUpdate GetVoiceState", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to broadcast voice state update")) + return + } + if state == nil { + return // user not in a voice channel — nothing to broadcast + } + h.BroadcastToAll(buildVoiceState(*state)) +} diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go new file mode 100644 index 00000000..6d254315 --- /dev/null +++ b/Server/ws/voice_controls.go @@ -0,0 +1,153 @@ +package ws + +import ( + "encoding/json" + "fmt" + "log/slog" + + "github.com/owncord/server/permissions" +) + +// handleVoiceMute processes a voice_mute message. +// 1. Parses muted bool. +// 2. Updates DB. +// 3. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) { + var p struct { + Muted bool `json:"muted"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_mute payload")) + return + } + + if err := h.db.UpdateVoiceMute(c.userID, p.Muted); err != nil { + slog.Error("ws handleVoiceMute UpdateVoiceMute", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update mute state")) + return + } + slog.Debug("voice mute changed", "user_id", c.userID, "muted", p.Muted) + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceDeafen processes a voice_deafen message. +// 1. Parses deafened bool. +// 2. Updates DB. +// 3. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) { + var p struct { + Deafened bool `json:"deafened"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_deafen payload")) + return + } + + if err := h.db.UpdateVoiceDeafen(c.userID, p.Deafened); err != nil { + slog.Error("ws handleVoiceDeafen UpdateVoiceDeafen", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update deafen state")) + return + } + slog.Debug("voice deafen changed", "user_id", c.userID, "deafened", p.Deafened) + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceCamera processes a voice_camera message. +// 1. Rate limits at 2/sec per user. +// 2. Checks USE_VIDEO permission. +// 3. Parses enabled bool. +// 4. Enforces MaxVideo limit via LiveKit. +// 5. Updates DB. +// 6. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_camera:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) { + c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds())) + return + } + + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel")) + return + } + + if !h.requireChannelPerm(c, voiceChID, permissions.UseVideo, "USE_VIDEO") { + return + } + + var p struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_camera payload")) + return + } + + // Enforce MaxVideo limit when enabling camera. + if p.Enabled { + ch, chErr := h.db.GetChannel(voiceChID) + if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 && h.livekit != nil { + videoCount, countErr := h.livekit.CountVideoTracks(voiceChID) + if countErr != nil { + slog.Error("handleVoiceCamera CountVideoTracks", "err", countErr, "channel_id", voiceChID) + } else if videoCount >= ch.VoiceMaxVideo { + c.sendMsg(buildErrorMsg(ErrCodeVideoLimit, + fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo))) + return + } + } + } + + if err := h.db.UpdateVoiceCamera(c.userID, p.Enabled); err != nil { + slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state")) + return + } + slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled) + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceScreenshare processes a voice_screenshare message. +// 1. Rate limits at 2/sec per user. +// 2. Checks SHARE_SCREEN permission. +// 3. Parses enabled bool. +// 4. Updates DB. +// 5. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) { + c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds())) + return + } + + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel")) + return + } + + if !h.requireChannelPerm(c, voiceChID, permissions.ShareScreen, "SHARE_SCREEN") { + return + } + + var p struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_screenshare payload")) + return + } + + if err := h.db.UpdateVoiceScreenshare(c.userID, p.Enabled); err != nil { + slog.Error("ws handleVoiceScreenshare UpdateVoiceScreenshare", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update screenshare state")) + return + } + slog.Debug("voice screenshare changed", "user_id", c.userID, "enabled", p.Enabled) + + h.broadcastVoiceStateUpdate(c) +} diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go deleted file mode 100644 index cabb45a6..00000000 --- a/Server/ws/voice_handlers.go +++ /dev/null @@ -1,335 +0,0 @@ -package ws - -import ( - "encoding/json" - "fmt" - "log/slog" - "time" - - "github.com/owncord/server/permissions" -) - -// Voice rate limit settings. -const ( - voiceCameraRateLimit = 2 - voiceCameraWindow = time.Second - voiceScreenshareRateLimit = 2 - voiceScreenshareWindow = time.Second -) - -// qualityBitrate returns the target audio bitrate in bits/s based on a quality preset. -func qualityBitrate(quality string) int { - switch quality { - case "low": - return 32000 - case "high": - return 128000 - default: - return 64000 - } -} - -// handleVoiceJoin processes a voice_join message. -// 1. Parses channel_id. -// 2. Checks CONNECT_VOICE permission. -// 3. If already in a different voice channel, leaves it first. -// 4. Checks channel capacity (voice_max_users). -// 5. Persists join in DB. -// 6. Generates LiveKit token and sends voice_token to the client. -// 7. Sends existing voice states to the joiner. -// 8. Broadcasts voice_state to all clients. -// 9. Sends voice_config to the joiner. -func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { - channelID, err := parseChannelID(payload) - if err != nil || channelID <= 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer")) - return - } - - if !h.requireChannelPerm(c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { - return - } - - currentChID := c.getVoiceChID() - - // If user is already in the same voice channel, no-op. - if currentChID == channelID { - c.sendMsg(buildErrorMsg("ALREADY_JOINED", "already in this voice channel")) - return - } - - // If user is already in a different voice channel, leave it first. - if currentChID > 0 { - h.handleVoiceLeave(c) - } - - ch, err := h.db.GetChannel(channelID) - if err != nil || ch == nil { - c.sendMsg(buildErrorMsg("NOT_FOUND", "channel not found")) - return - } - - // Check channel capacity. - maxUsers := ch.VoiceMaxUsers - if maxUsers > 0 { - existing, qErr := h.db.GetChannelVoiceStates(channelID) - if qErr != nil { - slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", qErr, "channel_id", channelID) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to check channel capacity")) - return - } - if len(existing) >= maxUsers { - c.sendMsg(buildErrorMsg("CHANNEL_FULL", "voice channel is full")) - return - } - } - - // Persist to DB. - if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil { - slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to join voice channel")) - return - } - - // Set voice channel on the client. - c.setVoiceChID(channelID) - - // Generate LiveKit token if LiveKit client is available. - if h.livekit != nil { - if c.user == nil { - slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID) - c.sendMsg(buildErrorMsg("INTERNAL", "not authenticated")) - return - } - canPublish := true - canSubscribe := true - token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, canPublish, canSubscribe) - if tokenErr != nil { - slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID) - // Non-fatal: voice join still succeeds at the DB/state level. - } else { - // Send both proxy path and direct URL. The client uses direct_url - // when on localhost (avoids self-signed TLS issues with WebView - // fetch) and falls back to the /livekit proxy for remote clients. - c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL())) - } - } - - // Get and broadcast the joiner's state. - state, err := h.db.GetVoiceState(c.userID) - if err != nil || state == nil { - slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID) - return - } - - // Broadcast the joiner's state to all connected clients. - h.BroadcastToAll(buildVoiceState(*state)) - - // Send existing channel voice states to the joiner. - existing, err := h.db.GetChannelVoiceStates(channelID) - if err != nil { - slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) - return - } - for _, vs := range existing { - if vs.UserID == c.userID { - continue - } - c.sendMsg(buildVoiceState(vs)) - } - - // Send voice_config to the joiner. - quality := "medium" - if ch.VoiceQuality != nil && *ch.VoiceQuality != "" { - quality = *ch.VoiceQuality - } - bitrate := qualityBitrate(quality) - c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers)) - - slog.Info("voice join", "user_id", c.userID, "channel_id", channelID) -} - -// handleVoiceLeave processes an explicit voice_leave message or a disconnect. -// 1. Gets old voiceChID from clearVoiceChID(). -// 2. If was in voice: remove from DB, broadcast voice_leave. -// 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone). -func (h *Hub) handleVoiceLeave(c *Client) { - oldChID := c.clearVoiceChID() - if oldChID == 0 { - slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID) - return - } - - slog.Info("voice leave", "user_id", c.userID, "channel_id", oldChID) - - if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil { - slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID) - } - h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID)) - - // Remove from LiveKit (best-effort). - if h.livekit != nil { - if err := h.livekit.RemoveParticipant(oldChID, c.userID); err != nil { - slog.Debug("handleVoiceLeave RemoveParticipant (may already be gone)", - "err", err, "user_id", c.userID, "channel_id", oldChID) - } - } -} - -// handleVoiceMute processes a voice_mute message. -// 1. Parses muted bool. -// 2. Updates DB. -// 3. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) { - var p struct { - Muted bool `json:"muted"` - } - if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_mute payload")) - return - } - - if err := h.db.UpdateVoiceMute(c.userID, p.Muted); err != nil { - slog.Error("ws handleVoiceMute UpdateVoiceMute", "err", err, "user_id", c.userID) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to update mute state")) - return - } - slog.Debug("voice mute changed", "user_id", c.userID, "muted", p.Muted) - - h.broadcastVoiceStateUpdate(c) -} - -// handleVoiceDeafen processes a voice_deafen message. -// 1. Parses deafened bool. -// 2. Updates DB. -// 3. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) { - var p struct { - Deafened bool `json:"deafened"` - } - if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_deafen payload")) - return - } - - if err := h.db.UpdateVoiceDeafen(c.userID, p.Deafened); err != nil { - slog.Error("ws handleVoiceDeafen UpdateVoiceDeafen", "err", err, "user_id", c.userID) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to update deafen state")) - return - } - slog.Debug("voice deafen changed", "user_id", c.userID, "deafened", p.Deafened) - - h.broadcastVoiceStateUpdate(c) -} - -// handleVoiceCamera processes a voice_camera message. -// 1. Rate limits at 2/sec per user. -// 2. Checks USE_VIDEO permission. -// 3. Parses enabled bool. -// 4. Enforces MaxVideo limit via LiveKit. -// 5. Updates DB. -// 6. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { - ratKey := fmt.Sprintf("voice_camera:%d", c.userID) - if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) { - c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds())) - return - } - - voiceChID := c.getVoiceChID() - if voiceChID == 0 { - c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) - return - } - - if !h.requireChannelPerm(c, voiceChID, permissions.UseVideo, "USE_VIDEO") { - return - } - - var p struct { - Enabled bool `json:"enabled"` - } - if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_camera payload")) - return - } - - // Enforce MaxVideo limit when enabling camera. - if p.Enabled { - ch, chErr := h.db.GetChannel(voiceChID) - if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 && h.livekit != nil { - videoCount, countErr := h.livekit.CountVideoTracks(voiceChID) - if countErr != nil { - slog.Error("handleVoiceCamera CountVideoTracks", "err", countErr, "channel_id", voiceChID) - } else if videoCount >= ch.VoiceMaxVideo { - c.sendMsg(buildErrorMsg("VIDEO_LIMIT", - fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo))) - return - } - } - } - - if err := h.db.UpdateVoiceCamera(c.userID, p.Enabled); err != nil { - slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to update camera state")) - return - } - slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled) - - h.broadcastVoiceStateUpdate(c) -} - -// handleVoiceScreenshare processes a voice_screenshare message. -// 1. Rate limits at 2/sec per user. -// 2. Checks SHARE_SCREEN permission. -// 3. Parses enabled bool. -// 4. Updates DB. -// 5. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { - ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID) - if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) { - c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds())) - return - } - - voiceChID := c.getVoiceChID() - if voiceChID == 0 { - c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) - return - } - - if !h.requireChannelPerm(c, voiceChID, permissions.ShareScreen, "SHARE_SCREEN") { - return - } - - var p struct { - Enabled bool `json:"enabled"` - } - if err := json.Unmarshal(payload, &p); err != nil { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_screenshare payload")) - return - } - - if err := h.db.UpdateVoiceScreenshare(c.userID, p.Enabled); err != nil { - slog.Error("ws handleVoiceScreenshare UpdateVoiceScreenshare", "err", err, "user_id", c.userID) - c.sendMsg(buildErrorMsg("INTERNAL", "failed to update screenshare state")) - return - } - slog.Debug("voice screenshare changed", "user_id", c.userID, "enabled", p.Enabled) - - h.broadcastVoiceStateUpdate(c) -} - -// broadcastVoiceStateUpdate fetches the current voice state for the client -// and broadcasts it to all members of the voice channel they are in. -func (h *Hub) broadcastVoiceStateUpdate(c *Client) { - state, err := h.db.GetVoiceState(c.userID) - if err != nil { - slog.Error("ws broadcastVoiceStateUpdate GetVoiceState", "err", err, "user_id", c.userID) - return - } - if state == nil { - return // user not in a voice channel — nothing to broadcast - } - h.BroadcastToAll(buildVoiceState(*state)) -} diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go new file mode 100644 index 00000000..b91e8adf --- /dev/null +++ b/Server/ws/voice_join.go @@ -0,0 +1,128 @@ +package ws + +import ( + "encoding/json" + "log/slog" + + "github.com/owncord/server/permissions" +) + +// handleVoiceJoin processes a voice_join message. +// 1. Parses channel_id. +// 2. Checks CONNECT_VOICE permission. +// 3. If already in a different voice channel, leaves it first. +// 4. Checks channel capacity (voice_max_users). +// 5. Persists join in DB. +// 6. Generates LiveKit token and sends voice_token to the client. +// 7. Sends existing voice states to the joiner. +// 8. Broadcasts voice_state to all clients. +// 9. Sends voice_config to the joiner. +func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { + channelID, err := parseChannelID(payload) + if err != nil || channelID <= 0 { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be a positive integer")) + return + } + + if !h.requireChannelPerm(c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { + return + } + + currentChID := c.getVoiceChID() + + // If user is already in the same voice channel, no-op. + if currentChID == channelID { + c.sendMsg(buildErrorMsg(ErrCodeAlreadyJoined, "already in this voice channel")) + return + } + + // If user is already in a different voice channel, leave it first. + if currentChID > 0 { + h.handleVoiceLeave(c) + } + + ch, err := h.db.GetChannel(channelID) + if err != nil || ch == nil { + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + return + } + + // Check channel capacity. + maxUsers := ch.VoiceMaxUsers + if maxUsers > 0 { + existing, qErr := h.db.GetChannelVoiceStates(channelID) + if qErr != nil { + slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", qErr, "channel_id", channelID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to check channel capacity")) + return + } + if len(existing) >= maxUsers { + c.sendMsg(buildErrorMsg(ErrCodeChannelFull, "voice channel is full")) + return + } + } + + // Persist to DB. + if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil { + slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel")) + return + } + + // Set voice channel on the client. + c.setVoiceChID(channelID) + + // Generate LiveKit token if LiveKit client is available. + if h.livekit != nil { + if c.user == nil { + slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "not authenticated")) + return + } + canPublish := true + canSubscribe := true + token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, canPublish, canSubscribe) + if tokenErr != nil { + slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID) + // Non-fatal: voice join still succeeds at the DB/state level. + } else { + // Send both proxy path and direct URL. The client uses direct_url + // when on localhost (avoids self-signed TLS issues with WebView + // fetch) and falls back to the /livekit proxy for remote clients. + c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL())) + } + } + + // Get and broadcast the joiner's state. + state, err := h.db.GetVoiceState(c.userID) + if err != nil || state == nil { + slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID) + return + } + + // Broadcast the joiner's state to all connected clients. + h.BroadcastToAll(buildVoiceState(*state)) + + // Send existing channel voice states to the joiner. + existing, err := h.db.GetChannelVoiceStates(channelID) + if err != nil { + slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) + return + } + for _, vs := range existing { + if vs.UserID == c.userID { + continue + } + c.sendMsg(buildVoiceState(vs)) + } + + // Send voice_config to the joiner. + quality := "medium" + if ch.VoiceQuality != nil && *ch.VoiceQuality != "" { + quality = *ch.VoiceQuality + } + bitrate := qualityBitrate(quality) + c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers)) + + slog.Info("voice join", "user_id", c.userID, "channel_id", channelID) +} diff --git a/Server/ws/voice_leave.go b/Server/ws/voice_leave.go new file mode 100644 index 00000000..a2e2e718 --- /dev/null +++ b/Server/ws/voice_leave.go @@ -0,0 +1,32 @@ +package ws + +import "log/slog" + +// handleVoiceLeave processes an explicit voice_leave message or a disconnect. +// 1. Gets old voiceChID from clearVoiceChID(). +// 2. If was in voice: remove from DB, broadcast voice_leave. +// 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone). +func (h *Hub) handleVoiceLeave(c *Client) { + oldChID := c.clearVoiceChID() + if oldChID == 0 { + slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID) + return + } + + slog.Info("voice leave", "user_id", c.userID, "channel_id", oldChID) + + if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil { + slog.Error("ws handleVoiceLeave LeaveVoiceChannel — ghost session may remain in DB", + "err", leaveErr, "user_id", c.userID, "channel_id", oldChID) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice leave partially failed — please rejoin if issues persist")) + } + h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID)) + + // Remove from LiveKit (best-effort). + if h.livekit != nil { + if err := h.livekit.RemoveParticipant(oldChID, c.userID); err != nil { + slog.Debug("handleVoiceLeave RemoveParticipant (may already be gone)", + "err", err, "user_id", c.userID, "channel_id", oldChID) + } + } +} diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index 63e096ea..6a44d502 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -433,6 +433,240 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { } } +// TestIntegration_MessageRoundTrip verifies that two clients can exchange messages +// through the real WebSocket upgrade path: Client A sends chat_send, Client B +// receives chat_message via the hub broadcast. +func TestIntegration_MessageRoundTrip(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + // Seed two users with sessions. + userIDA, err := database.CreateUser("roundtrip-a", "hash", 1) + if err != nil { + t.Fatalf("CreateUser A: %v", err) + } + tokenA, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken A: %v", err) + } + if _, err := database.CreateSession(userIDA, auth.HashToken(tokenA), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession A: %v", err) + } + + userIDB, err := database.CreateUser("roundtrip-b", "hash", 1) + if err != nil { + t.Fatalf("CreateUser B: %v", err) + } + tokenB, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken B: %v", err) + } + if _, err := database.CreateSession(userIDB, auth.HashToken(tokenB), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession B: %v", err) + } + + // Create a text channel for the chat. + chID, err := database.CreateChannel("integration-chat", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + // --- Helper: connect and authenticate a WebSocket client --- + connectAndAuth := func(label, token string) *websocket.Conn { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conn, _, dialErr := websocket.Dial(ctx, wsURL, nil) + if dialErr != nil { + t.Fatalf("%s dial: %v", label, dialErr) + } + authMsg, _ := json.Marshal(map[string]any{ + "type": "auth", + "payload": map[string]string{"token": token}, + }) + if writeErr := conn.Write(ctx, websocket.MessageText, authMsg); writeErr != nil { + t.Fatalf("%s write auth: %v", label, writeErr) + } + // Drain auth_ok + ready. + for i := 0; i < 2; i++ { + if _, _, readErr := conn.Read(ctx); readErr != nil { + t.Fatalf("%s drain initial msg %d: %v", label, i, readErr) + } + } + return conn + } + + connA := connectAndAuth("clientA", tokenA) + defer func() { _ = connA.Close(websocket.StatusNormalClosure, "") }() + + connB := connectAndAuth("clientB", tokenB) + defer func() { _ = connB.Close(websocket.StatusNormalClosure, "") }() + + // Wait for both clients to be registered in the hub. + time.Sleep(50 * time.Millisecond) + + // Client B focuses on the channel so it receives channel-scoped broadcasts. + focusMsg, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{"channel_id": chID}, + }) + ctxB, cancelB := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelB() + if err := connB.Write(ctxB, websocket.MessageText, focusMsg); err != nil { + t.Fatalf("clientB write channel_focus: %v", err) + } + time.Sleep(30 * time.Millisecond) + + // Client A sends a chat message. + chatSend, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "req-1", + "payload": map[string]any{ + "channel_id": chID, + "content": "hello from A", + }, + }) + ctxA, cancelA := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelA() + if err := connA.Write(ctxA, websocket.MessageText, chatSend); err != nil { + t.Fatalf("clientA write chat_send: %v", err) + } + + // Client B should receive a chat_message broadcast. + // Drain a few messages (member_join, presence, etc.) until we find chat_message. + found := false + readCtx, readCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer readCancel() + for i := 0; i < 15 && !found; i++ { + _, raw, readErr := connB.Read(readCtx) + if readErr != nil { + t.Fatalf("clientB read: %v", readErr) + } + var env map[string]any + if json.Unmarshal(raw, &env) != nil { + continue + } + if env["type"] == "chat_message" { + payload, _ := env["payload"].(map[string]any) + if payload == nil { + t.Fatal("chat_message has nil payload") + } + if payload["content"] != "hello from A" { + t.Errorf("content = %q, want 'hello from A'", payload["content"]) + } + user, _ := payload["user"].(map[string]any) + if user == nil { + t.Fatal("chat_message missing user") + } + if user["username"] != "roundtrip-a" { + t.Errorf("username = %q, want 'roundtrip-a'", user["username"]) + } + found = true + } + } + if !found { + t.Error("clientB never received chat_message from clientA") + } +} + +// TestIntegration_SequenceNumbers verifies that broadcast messages delivered via +// the real WebSocket path carry a monotonically increasing `seq` field. +func TestIntegration_SequenceNumbers(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + userID, err := database.CreateUser("seq-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + // Authenticate. + authMsg, _ := json.Marshal(map[string]any{ + "type": "auth", + "payload": map[string]string{"token": token}, + }) + if err := conn.Write(ctx, websocket.MessageText, authMsg); err != nil { + t.Fatalf("write auth: %v", err) + } + // Drain auth_ok and ready (these are direct writes, not broadcasts). + for i := 0; i < 2; i++ { + if _, _, err := conn.Read(ctx); err != nil { + t.Fatalf("drain msg %d: %v", i, err) + } + } + + // Wait for registration. + time.Sleep(50 * time.Millisecond) + + // Trigger two broadcasts. + hub.BroadcastServerRestart("test-seq-1", 10) + hub.BroadcastServerRestart("test-seq-2", 20) + + // Collect broadcast messages — they must carry monotonically increasing seq. + var seqs []float64 + readCtx, readCancel := context.WithTimeout(ctx, 3*time.Second) + defer readCancel() + for i := 0; i < 10; i++ { + _, raw, readErr := conn.Read(readCtx) + if readErr != nil { + break + } + var env map[string]any + if json.Unmarshal(raw, &env) != nil { + continue + } + // Broadcasts go through deliverBroadcast which stamps seq. + if seq, ok := env["seq"].(float64); ok { + seqs = append(seqs, seq) + } + // Stop once we've collected at least 2 seq-bearing messages. + if len(seqs) >= 2 { + break + } + } + + if len(seqs) < 2 { + t.Fatalf("expected at least 2 messages with seq field, got %d", len(seqs)) + } + for i := 1; i < len(seqs); i++ { + if seqs[i] <= seqs[i-1] { + t.Errorf("seq not monotonically increasing: seq[%d]=%.0f seq[%d]=%.0f", i-1, seqs[i-1], i, seqs[i]) + } + } +} + // TestServeWS_BannedUser_ReceivesError verifies that a banned user cannot connect. func TestServeWS_BannedUser_ReceivesError(t *testing.T) { database := openServeTestDB(t) diff --git a/docs/CODEMAPS/architecture.md b/docs/CODEMAPS/architecture.md new file mode 100644 index 00000000..e683e080 --- /dev/null +++ b/docs/CODEMAPS/architecture.md @@ -0,0 +1,57 @@ + + +# OwnCord Architecture + +## System Overview + +``` ++-------------------+ +-------------------+ +| Tauri Client | WSS | Go Server | +| (Rust + TS) |--------->| (chatserver.exe) | +| | HTTPS | | +| livekit-client |---. | LiveKit SDK | ++-------------------+ | +-------------------+ + | | + v v + +-------------------+ + | LiveKit Server | + | (companion proc) | + +-------------------+ +``` + +## Data Flow + +``` +Client Server Storage +------ ------ ------- +ConnectPage api/auth_handler.go SQLite (WAL) + login/register ─HTTP──> POST /api/v1/auth/* ──> users, sessions + <─token─ + +MainPage ws/serve.go + ws.connect() ─WSS──> ServeWS() → Hub.register + dispatcher.ts <─ready─ handlers.go dispatcher + ├─ chat_send ──> messages, attachments + ├─ voice_join ──> voice_states + LiveKit token + └─ presence ──> users.status + +livekitSession.ts ws/livekit.go + Room.connect() ─WebRTC─> GenerateToken(JWT) + <─media─> LiveKit SFU (companion) +``` + +## Key Boundaries + +| Boundary | Protocol | Auth | +|----------|----------|------| +| Client ↔ Server REST | HTTPS | Bearer token | +| Client ↔ Server WS | WSS (via Rust proxy) | In-band `auth` message | +| Client ↔ LiveKit | WebRTC (via wss proxy) | JWT access token | +| Server ↔ LiveKit | gRPC/HTTP | API key + secret | +| Server ↔ SQLite | In-process | Single-writer WAL | + +## Entry Points + +- **Server:** `main.go` → config → TLS → DB → migrate → router → HTTP server +- **Client:** `main.ts` → router → ConnectPage (auth) → MainPage (app) +- **LiveKit:** Auto-started by `livekit_process.go` alongside chatserver diff --git a/docs/CODEMAPS/backend.md b/docs/CODEMAPS/backend.md new file mode 100644 index 00000000..32991d8c --- /dev/null +++ b/docs/CODEMAPS/backend.md @@ -0,0 +1,79 @@ + + +# Backend Codemap (Go Server) + +## HTTP Routes + +### Auth (rate-limited) +``` +POST /api/v1/auth/register → handleRegister [3/min] +POST /api/v1/auth/login → handleLogin [5/min] +POST /api/v1/auth/logout → handleLogout [AUTH] +GET /api/v1/auth/me → handleMe [AUTH] +``` + +### Channels & Messages +``` +GET /api/v1/channels/ → handleListChannels [AUTH] +GET /api/v1/channels/{id}/messages → handleGetMessages [AUTH, paginated] +GET /api/v1/search?q= → handleSearch [AUTH, FTS5] +``` + +### Invites, Uploads +``` +POST /api/v1/invites/ → handleCreateInvite [AUTH, MANAGE_INVITES] +GET /api/v1/invites/ → handleListInvites [AUTH, MANAGE_INVITES] +DELETE /api/v1/invites/{code} → handleRevokeInvite [AUTH, MANAGE_INVITES] +POST /api/v1/uploads → handleUpload [AUTH, max 100MB] +GET /api/v1/uploads/{id} → handleDownload [AUTH] +``` + +### WebSocket & LiveKit +``` +GET /api/v1/ws → ServeWS() [upgrade, in-band auth] +POST /api/v1/livekit/webhook → LiveKit webhook [JWT verify] +WS /livekit/* → reverse proxy → :7880 [mixed-content fix] +``` + +### Admin (/admin, IP-restricted) +``` +GET /admin/stats, /users, /channels, /audit-log, /settings, /backups +POST /admin/channels, /backup, /updates/apply +GET /admin/logs/stream [WebSocket log viewer] +``` + +## Middleware Chain +``` +RequestID → Recoverer → requestLogger → SecurityHeaders → MaxBodySize(1MB) + Per-route: AuthMiddleware, RequirePermission(bit), RateLimitMiddleware + Admin: AdminIPRestrict(allowedCIDRs) +``` + +## WS Message Handlers (ws/handlers.go) + +| Type | Handler | Rate | DB | Broadcast | +|------|---------|------|-----|-----------| +| chat_send | handleChatSend | 10/s | CreateMessage | channel | +| chat_edit | handleChatEdit | 10/s | EditMessage | channel | +| chat_delete | handleChatDelete | 10/s | DeleteMessage | channel | +| reaction_add/remove | handleReaction | 5/s | Add/RemoveReaction | channel | +| typing_start | handleTyping | 1/3s | — | channel (excl sender) | +| presence_update | handlePresence | 1/10s | UpdateUserStatus | all | +| voice_join | handleVoiceJoin | — | JoinVoice + GenToken | all | +| voice_leave | handleVoiceLeave | — | LeaveVoice | all | +| voice_mute/deafen | handleVoiceMute/Deafen | — | UpdateVoice* | all | +| voice_camera | handleVoiceCamera | 2/s | UpdateVoiceCamera | all | + +## Key Files + +| File | Lines | Purpose | +|------|-------|---------| +| main.go | 291 | Entry, init, graceful shutdown | +| api/router.go | 198 | Route mounting, Hub + LiveKit init | +| api/middleware.go | 325 | Auth, permissions, rate limit, security headers | +| ws/hub.go | 303 | Client registry, broadcast, settings cache | +| ws/handlers.go | 522 | WS message dispatcher | +| ws/voice_handlers.go | 332 | Voice join/leave/mute/camera | +| ws/livekit.go | 170 | Token generation, room management | +| ws/livekit_process.go | 189 | LiveKit binary lifecycle | +| ws/livekit_webhook.go | 178 | LiveKit event processing | diff --git a/docs/CODEMAPS/data.md b/docs/CODEMAPS/data.md new file mode 100644 index 00000000..3a9bc7bb --- /dev/null +++ b/docs/CODEMAPS/data.md @@ -0,0 +1,52 @@ + + +# Data Codemap (SQLite) + +## Tables + +| Table | PK | Key Columns | Indexes | +|-------|----|----|---------| +| roles | id | name, permissions (bitfield), position, is_default | — | +| users | id | username, password (bcrypt), role_id FK, status, banned, totp_secret | username UNIQUE | +| sessions | id | user_id FK, token, ip_address, expires_at | token UNIQUE | +| channels | id | name, type (text/voice), category, position, voice_max_users | — | +| channel_overrides | id | channel_id FK, role_id FK, allow/deny (bitfields) | (channel_id, role_id) | +| messages | id | channel_id FK, user_id FK, content, reply_to, deleted, pinned | (channel_id, id DESC) | +| messages_fts | rowid | FTS5 virtual table (content, channel_id) | — | +| attachments | id (UUID) | message_id FK, filename, stored_as, mime_type, size | — | +| reactions | id | message_id FK, user_id FK, emoji | (message_id, emoji) UNIQUE w/ user | +| voice_states | user_id | channel_id, muted, deafened, camera, screenshare, joined_at | — | +| invites | id | code UNIQUE, created_by FK, max_uses, use_count, expires_at | — | +| read_states | (user_id, channel_id) | last_message_id, mention_count | — | +| audit_log | id | actor_id, action, target_type, target_id, detail, created_at | (actor_id), (created_at DESC) | +| login_attempts | id | ip_address, username, success, timestamp | (ip_address, timestamp) | +| settings | key | value (JSON text) | — | +| emoji, sounds | id | Custom emoji/soundboard storage | — | + +## Migration History + +| # | File | Change | +|---|------|--------| +| 001 | initial_schema.sql | All base tables + FTS5 | +| 002 | voice_states.sql | voice_states table | +| 003a | audit_log.sql | Canonicalize audit columns | +| 003b | voice_optimization.sql | camera/screenshare fields, voice channel config | +| 004 | fix_member_permissions.sql | Member role perms = 0x663 | +| 005 | channel_overrides_index.sql | Composite index for permission lookups | +| 006 | member_video_permissions.sql | Add USE_VIDEO + SHARE_SCREEN bits | + +## Query Files (db/) + +| File | Tables | Methods | +|------|--------|---------| +| auth_queries.go | users, sessions, invites | CreateUser, GetUserBy*, BanUser, Session CRUD, Invite CRUD | +| channel_queries.go | channels, channel_overrides | List/Get/Create/Delete Channel, permissions | +| message_queries.go | messages, reactions, read_states | CRUD, Search (FTS5), pagination, reactions | +| voice_queries.go | voice_states | Join/Leave, GetState, Update mute/camera/etc | +| attachment_queries.go | attachments | Create, Link to message, Get by message IDs | +| admin_queries.go | audit_log, settings, users | Stats, audit, settings, backup | + +## DB Config +- Driver: `modernc.org/sqlite` (pure Go, no CGO) +- WAL mode, busy timeout 5s, single-writer +- Foreign keys enforced diff --git a/docs/CODEMAPS/dependencies.md b/docs/CODEMAPS/dependencies.md new file mode 100644 index 00000000..5fd18926 --- /dev/null +++ b/docs/CODEMAPS/dependencies.md @@ -0,0 +1,53 @@ + + +# Dependencies Codemap + +## Server (Go 1.25) + +| Dependency | Purpose | +|------------|---------| +| go-chi/chi v5 | HTTP router | +| nhooyr.io/websocket | WebSocket server | +| modernc.org/sqlite | SQLite driver (pure Go) | +| livekit/server-sdk-go v2 | Token gen, room management | +| livekit/protocol | LiveKit protobuf types | +| knadh/koanf v2 | Config (YAML + env) | +| golang.org/x/crypto | bcrypt password hashing | +| google/uuid | UUID generation | +| microcosm-cc/bluemonday | HTML sanitization | + +## Client TypeScript + +| Dependency | Purpose | +|------------|---------| +| livekit-client ^2.17 | LiveKit JS SDK (WebRTC) | +| @jitsi/rnnoise-wasm ^0.2 | Noise suppression (WASM) | +| @tauri-apps/api ^2.10 | Tauri v2 core IPC | +| @tauri-apps/plugin-* | store, dialog, fs, http, notification, global-shortcut, opener, process, updater | + +## Client Rust + +| Crate | Purpose | +|-------|---------| +| tauri 2 | App framework | +| tokio-tungstenite 0.28 | WS client (TLS) | +| rustls 0.23 | TLS engine | +| windows 0.58 | Win32 API (PTT, credentials) | +| serde/serde_json | Serialization | + +## External Services + +| Service | Protocol | Config | +|---------|----------|--------| +| LiveKit SFU | WebRTC + gRPC | config.voice (api_key, api_secret, url, binary_path) | +| Tenor API v2 | HTTPS | Public key in lib/tenor.ts (not a secret) | +| GitHub API | HTTPS | Optional token for update checks | + +## Service Topology +``` +Client ──WSS──> Server ──gRPC──> LiveKit (companion process) +Client ──WebRTC (wss proxy)───> LiveKit +Client ──HTTPS──> Tenor API (GIFs) +Server ──HTTPS──> GitHub API (update checks) +Server ──file──> SQLite (local .db) +``` diff --git a/docs/CODEMAPS/frontend.md b/docs/CODEMAPS/frontend.md new file mode 100644 index 00000000..e437fd80 --- /dev/null +++ b/docs/CODEMAPS/frontend.md @@ -0,0 +1,75 @@ + + +# Frontend Codemap (Tauri v2 Client) + +## Page Flow +``` +main.ts → router("connect") + ConnectPage → login/register → wirePostAuth() → ws.connect() + → dispatcher wires events → "ready" received + → router.navigate("main") + MainPage → compose sidebar + chat + voice + modals + → logout → router.navigate("connect") +``` + +## Component Tree & Store Subscriptions +``` +MainPage + ├─ ChannelSidebar ── channels.store, voice.store, auth.store, ui.store + ├─ ChatHeader ────── channels.store + ├─ MessageList ───── messages.store, members.store + ├─ TypingIndicator ─ members.store + ├─ MessageInput ──── messages.store, rate-limiter + ├─ VoiceWidget ───── voice.store, channels.store + ├─ VideoGrid ─────── voice.store (camera-filtered subscription) + ├─ MemberList ────── members.store + ├─ UserBar ───────── auth.store + └─ SettingsOverlay ─ auth.store, ui.store, voice.store +``` + +## WS Dispatch Flow (dispatcher.ts) +``` +ws.on("ready") → channels/members/voice bulk load +ws.on("chat_message") → messages.addMessage() + notifications.ts +ws.on("voice_state") → voice.updateVoiceState() +ws.on("voice_token") → livekitSession.handleVoiceToken() +ws.on("voice_leave") → voice.removeVoiceUser() +ws.on("presence") → members.updatePresence() +ws.on("channel_*") → channels.add/update/remove +ws.on("member_*") → members.add/update/remove +``` + +## LiveKit Voice Flow (livekitSession.ts) +``` +handleVoiceToken(token, url, channelId) + → Room.connect(wss://host/livekit, token) + → publishMic (optional RNNoise WASM) + → startSpeakingPoll (100ms, Web Audio AnalyserNode) + → onTrackSubscribed →