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
This commit is contained in:
jevb
2026-03-21 10:08:44 +01:00
parent 9a853fd078
commit 3236918012
59 changed files with 3455 additions and 917 deletions
@@ -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": {
@@ -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<void>;
readonly onBan: (userId: number, username: string) => Promise<void>;
readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise<void>;
}
/** 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;
@@ -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 ----------------------------------------------------------------
@@ -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;
+8 -15
View File
@@ -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<string, never>;
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;
@@ -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);
@@ -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";
@@ -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
+30
View File
@@ -518,6 +518,36 @@ export function createApiClient(
): Promise<void> {
return adminRequest<void>("DELETE", `/channels/${id}`, undefined, signal);
},
// ── Admin: Members ──────────────────────────────────────
adminKickMember(
userId: number,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("DELETE", `/users/${userId}/sessions`, undefined, signal);
},
adminBanMember(
userId: number,
reason?: string,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("PATCH", `/users/${userId}`, {
banned: true,
ban_reason: reason ?? "",
}, signal);
},
adminChangeRole(
userId: number,
roleId: number,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("PATCH", `/users/${userId}`, {
role_id: roleId,
}, signal);
},
};
}
+67
View File
@@ -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<S, R>(
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<K extends keyof HTMLElementEventMap>(
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;
}
}
+19 -6
View File
@@ -534,12 +534,25 @@ export async function handleVoiceToken(
// Enable microphone: use RNNoise if Enhanced Noise Suppression is on
const enhancedNS = loadPref<boolean>("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
+1 -1
View File
@@ -185,7 +185,7 @@ export function createProfileManager(
const store = createStore<ProfilesState>(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 ────────────────────────────────────────────────
+44 -1
View File
@@ -56,6 +56,49 @@ export interface Store<T> {
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<string, unknown>)[key] !== (b as Record<string, unknown>)[key]) return false;
}
return true;
}
export { shallowEqual };
export function createStore<T>(initialState: T): Store<T> {
let state: T = initialState;
const listeners: Set<(state: T) => void> = new Set();
@@ -88,7 +131,7 @@ export function createStore<T>(initialState: T): Store<T> {
function subscribeSelector<S>(
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) => {
+1
View File
@@ -312,6 +312,7 @@ export interface ErrorPayload {
export interface AuthPayload {
readonly token: string;
readonly last_seq?: number;
}
export interface ChatSendPayload {
+11 -3
View File
@@ -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();
+31 -264
View File
@@ -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<typeof createPinnedPanelController> | null = null;
let inviteCtrl: ReturnType<typeof createInviteManagerController> | 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?.();
@@ -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);
@@ -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<string, number> = { 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,
};
}
@@ -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,
};
}
@@ -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,
@@ -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?.();
@@ -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<typeof createMemberList>;
@@ -45,7 +55,7 @@ describe("MemberList", () => {
resetStore();
container = document.createElement("div");
document.body.appendChild(container);
memberList = createMemberList();
memberList = createMemberList(defaultOpts());
});
afterEach(() => {
@@ -36,6 +36,7 @@ function makeMessage(overrides: Partial<Message> & { 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);
});
@@ -29,6 +29,7 @@ function makeMessage(overrides: Partial<Message> = {}): 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<MessageListOptions> = {}): MessageListOptio
onEditClick: vi.fn(),
onDeleteClick: vi.fn(),
onReactionClick: vi.fn(),
onPinClick: vi.fn(),
...overrides,
};
}
+24 -4
View File
@@ -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);
});
});
+40
View File
@@ -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)
}
}
+4
View File
@@ -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")
+1 -1
View File
@@ -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)
+38
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+20
View File
@@ -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()
}
+18
View File
@@ -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")
)
+5 -5
View File
@@ -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
}
+24 -1
View File
@@ -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
}
+79
View File
@@ -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 ==="
+4 -1
View File
@@ -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
+29 -11
View File
@@ -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()
+19
View File
@@ -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"
)
+63 -39
View File
@@ -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+0000U+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)
+146 -20
View File
@@ -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)
}
}
+23 -2
View File
@@ -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)
+262 -148
View File
@@ -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,
},
})
}
-10
View File
@@ -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,
+78
View File
@@ -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
}
+50 -16
View File
@@ -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.
+41
View File
@@ -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))
}
+153
View File
@@ -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)
}
-335
View File
@@ -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))
}
+128
View File
@@ -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)
}
+32
View File
@@ -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)
}
}
}
+234
View File
@@ -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)
+57
View File
@@ -0,0 +1,57 @@
<!-- Generated: 2026-03-20 | Files scanned: ~120 | Token estimate: ~800 -->
# 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
+79
View File
@@ -0,0 +1,79 @@
<!-- Generated: 2026-03-20 | Files scanned: 35 | Token estimate: ~900 -->
# 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 |
+52
View File
@@ -0,0 +1,52 @@
<!-- Generated: 2026-03-20 | Tables: 16 | Migrations: 7 | Token estimate: ~700 -->
# 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
+53
View File
@@ -0,0 +1,53 @@
<!-- Generated: 2026-03-20 | Token estimate: ~600 -->
# 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)
```
+75
View File
@@ -0,0 +1,75 @@
<!-- Generated: 2026-03-20 | Files scanned: 75 TS + 9 Rust | Token estimate: ~900 -->
# 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 → <audio> elements (remote audio)
→ onTrackSubscribed → VideoGrid callback (remote video)
enableCamera() → setCameraEnabled(true) [optimistic UI]
disableCamera() → setCameraEnabled(false)
leaveVoice() → room.disconnect() + cleanup
```
## State Stores (lib/store.ts pattern)
| Store | Key Fields |
|-------|------------|
| auth | token, user, serverName, isAuthenticated |
| channels | channels: Map, activeChannelId |
| messages | messagesByChannel: Map, pendingSends, hasMore |
| members | members: Map, typingBy: Set |
| voice | currentChannelId, voiceUsers: Map<ch, Map<uid, VoiceUser>>, localMuted/Deafened/Camera |
| ui | theme, connectionStatus, collapsedCategories, activeModal |
## Rust Backend (src-tauri/src/)
| File | Tauri Commands |
|------|----------------|
| commands.rs | get_settings, save_settings (key allowlist), store/get_cert_fingerprint, open_devtools |
| credentials.rs | save/load/delete_credential (Windows Credential Manager) |
| ws_proxy.rs | ws_connect, ws_send, ws_disconnect, accept_cert_fingerprint |
| ptt.rs | ptt_start/stop/set_key/get_key, ppt_listen_for_key (GetAsyncKeyState) |
| update_commands.rs | check_client_update, download_and_install_update |
+20 -1
View File
@@ -17,6 +17,9 @@ Messages are JSON with a `type` and `payload`.
- `type` — string, required. Determines how payload is interpreted.
- `id` — string, optional. Client-generated UUID for request/response correlation.
- `payload` — object, required. Contents vary by type.
- `seq` — uint64, server→client broadcast messages only. Monotonically
increasing sequence number. Direct responses to a specific client
(e.g. `error`, `chat_send_ok`, `auth_ok`) do NOT include `seq`.
Server responses to client requests include the same `id` for correlation.
@@ -27,9 +30,16 @@ Server responses to client requests include the same `id` for correlation.
### Client → Server
```json
{ "type": "auth", "payload": { "token": "session-token-here" } }
{ "type": "auth", "payload": { "token": "session-token-here", "last_seq": 0 } }
```
- `token` (string, required) — session token from login.
- `last_seq` (uint64, optional) — last `seq` received by the client.
If present and > 0, the server replays missed broadcast events from
a 1000-event ring buffer. If the requested seq is too old (no longer
in the buffer), the server falls back to the normal `auth_ok` + `ready`
flow as if `last_seq` were absent.
### Server → Client (success)
```json
@@ -54,6 +64,15 @@ Server responses to client requests include the same `id` for correlation.
Connection is closed by server after auth_error.
### Heartbeat Monitoring
The server tracks `lastActivity` per client connection. Any
incoming WebSocket message (including pings) resets the timer.
Clients inactive for >90 seconds are disconnected by the server.
The client sends a WebSocket ping every 30 seconds, which is
sufficient to keep the connection alive under normal conditions.
---
## Chat Messages
+349
View File
@@ -0,0 +1,349 @@
{
"$schema": "protocol-schema",
"version": "1.0",
"description": "OwnCord WebSocket protocol schema. Single source of truth for Server (Go) and Client (TypeScript) message types.",
"envelope": {
"type": "string",
"id": "string|undefined",
"payload": "object"
},
"messages": {
"auth_ok": {
"direction": "server_to_client",
"fields": {
"user": {
"id": "number",
"username": "string",
"avatar": "string|null",
"role": "string"
},
"server_name": "string",
"motd": "string"
}
},
"auth_error": {
"direction": "server_to_client",
"fields": {
"message": "string"
}
},
"ready": {
"direction": "server_to_client",
"fields": {
"channels": "array<ReadyChannel>",
"members": "array<ReadyMember>",
"voice_states": "array<ReadyVoiceState>",
"roles": "array<ReadyRole>"
},
"nested_types": {
"ReadyChannel": {
"id": "number",
"name": "string",
"type": "string",
"category": "string|null",
"position": "number",
"unread_count": "number|undefined",
"last_message_id": "number|undefined"
},
"ReadyMember": {
"id": "number",
"username": "string",
"avatar": "string|null",
"role": "string",
"status": "string"
},
"ReadyVoiceState": {
"channel_id": "number",
"user_id": "number",
"muted": "boolean",
"deafened": "boolean"
},
"ReadyRole": {
"id": "number",
"name": "string",
"color": "string|null",
"permissions": "number"
}
}
},
"chat_message": {
"direction": "server_to_client",
"fields": {
"id": "number",
"channel_id": "number",
"user": {
"id": "number",
"username": "string",
"avatar": "string|null",
"role": "string"
},
"content": "string",
"reply_to": "number|null",
"timestamp": "string",
"attachments": "array",
"reactions": "array",
"pinned": "boolean"
}
},
"chat_send_ok": {
"direction": "server_to_client",
"fields": {
"message_id": "number",
"timestamp": "string"
}
},
"chat_edited": {
"direction": "server_to_client",
"fields": {
"message_id": "number",
"channel_id": "number",
"content": "string",
"edited_at": "string"
}
},
"chat_deleted": {
"direction": "server_to_client",
"fields": {
"message_id": "number",
"channel_id": "number"
}
},
"reaction_update": {
"direction": "server_to_client",
"fields": {
"message_id": "number",
"channel_id": "number",
"emoji": "string",
"user_id": "number",
"action": "string"
}
},
"typing": {
"direction": "server_to_client",
"fields": {
"channel_id": "number",
"user_id": "number",
"username": "string"
}
},
"presence": {
"direction": "server_to_client",
"fields": {
"user_id": "number",
"status": "string"
}
},
"channel_create": {
"direction": "server_to_client",
"fields": {
"id": "number",
"name": "string",
"type": "string",
"category": "string",
"topic": "string",
"position": "number"
}
},
"channel_update": {
"direction": "server_to_client",
"fields": {
"id": "number",
"name": "string",
"type": "string",
"category": "string",
"topic": "string",
"position": "number"
}
},
"channel_delete": {
"direction": "server_to_client",
"fields": {
"id": "number"
}
},
"voice_state": {
"direction": "server_to_client",
"fields": {
"channel_id": "number",
"user_id": "number",
"username": "string",
"muted": "boolean",
"deafened": "boolean",
"speaking": "boolean",
"camera": "boolean",
"screenshare": "boolean"
}
},
"voice_leave": {
"direction": "server_to_client",
"fields": {
"channel_id": "number",
"user_id": "number"
}
},
"voice_config": {
"direction": "server_to_client",
"fields": {
"channel_id": "number",
"quality": "string",
"bitrate": "number",
"max_users": "number"
}
},
"voice_token": {
"direction": "server_to_client",
"fields": {
"channel_id": "number",
"token": "string",
"url": "string",
"direct_url": "string"
}
},
"voice_speakers": {
"direction": "server_to_client",
"fields": {
"channel_id": "number",
"speakers": "array<number>",
"threshold_mode": "string"
}
},
"member_join": {
"direction": "server_to_client",
"fields": {
"user": {
"id": "number",
"username": "string",
"avatar": "string|null",
"role": "string"
}
}
},
"member_leave": {
"direction": "server_to_client",
"fields": {
"user_id": "number"
}
},
"member_update": {
"direction": "server_to_client",
"fields": {
"user_id": "number",
"role": "string"
}
},
"member_ban": {
"direction": "server_to_client",
"fields": {
"user_id": "number"
}
},
"server_restart": {
"direction": "server_to_client",
"fields": {
"reason": "string",
"delay_seconds": "number"
}
},
"error": {
"direction": "server_to_client",
"fields": {
"code": "string",
"message": "string"
}
}
},
"client_messages": {
"auth": {
"fields": {
"token": "string",
"last_seq": "number|undefined"
}
},
"chat_send": {
"fields": {
"channel_id": "number",
"content": "string",
"reply_to": "number|null",
"attachments": "array"
}
},
"chat_edit": {
"fields": {
"message_id": "number",
"content": "string"
}
},
"chat_delete": {
"fields": {
"message_id": "number"
}
},
"reaction_add": {
"fields": {
"message_id": "number",
"emoji": "string"
}
},
"reaction_remove": {
"fields": {
"message_id": "number",
"emoji": "string"
}
},
"typing_start": {
"fields": {
"channel_id": "number"
}
},
"channel_focus": {
"fields": {
"channel_id": "number"
}
},
"presence_update": {
"fields": {
"status": "string"
}
},
"voice_join": {
"fields": {
"channel_id": "number"
}
},
"voice_leave": {
"fields": {}
},
"voice_mute": {
"fields": {
"muted": "boolean"
}
},
"voice_deafen": {
"fields": {
"deafened": "boolean"
}
},
"voice_camera": {
"fields": {
"enabled": "boolean"
}
},
"voice_screenshare": {
"fields": {
"enabled": "boolean"
}
},
"ping": {
"fields": {}
}
},
"drift_notes": [
"chat_message: Go server sends user.role but TypeScript ChatMessagePayload uses MessageUser (no role). Client should add role to MessageUser or use UserWithRole.",
"chat_message: Go server sends reactions[] and pinned fields. TypeScript ChatMessagePayload is missing both. These fields exist on MessageResponse (REST) but not the WS payload.",
"channel_create: Go sends topic (string) but TypeScript ChannelCreatePayload omits topic. TypeScript also types category as string|null while Go sends empty string.",
"channel_update: Go sends full channel object (all 6 fields). TypeScript ChannelUpdatePayload only has id, optional name, optional position. Major drift.",
"voice_config: TypeScript VoiceConfigPayload has extra fields (threshold_mode, mixing_threshold, top_speakers) not present in Go voiceConfigPayload struct.",
"voice_token: TypeScript marks direct_url as optional. Go always sends it.",
"soundboard_play: TypeScript defines client message type but Go handler does not process it."
]
}
@@ -0,0 +1,412 @@
# OwnCord Engineering Improvements: Research Report
*Generated: 2026-03-20 | Sources: 25+ | Scope: Coding standards, patterns, and methods only (no new features)*
## Executive Summary
OwnCord's architecture is fundamentally sound — the Go hub pattern with channel-based message routing, immutable reactive stores, layered client architecture, and protocol-first design all align with industry best practices from Discord, Matrix/Element, and other production chat platforms. This report identifies **14 improvement areas** where existing code can be strengthened through better patterns, stricter standards, and proven techniques from mature platforms.
---
## 1. Go Server: Error Handling & Sentinel Errors
### Current State
OwnCord uses `fmt.Errorf` and inline error strings throughout handlers. Error checks like `if err != nil` return generic messages.
### What Production Platforms Do
Discord's Go services and Matrix's Dendrite server use **sentinel errors** with `errors.Is`/`errors.As` for well-defined failure conditions, and **error wrapping** with `%w` to preserve context chains.
### Recommendation
- Define sentinel errors in the `db` package: `var ErrNotFound = errors.New("not found")`, `var ErrForbidden = errors.New("forbidden")`, etc.
- Wrap errors with context: `return fmt.Errorf("CreateMessage channel=%d: %w", channelID, err)` instead of bare `return err`
- In handlers, use `errors.Is(err, db.ErrNotFound)` to map to protocol error codes cleanly
- This eliminates string-matching for error classification and makes error flows testable
**Sources:**
- [Robust Go: Best Practices for Error Handling](https://leapcell.io/blog/robust-go-best-practices-for-error-handling)
- [Go slog structured logging guide](https://go.dev/blog/slog)
---
## 2. Go Server: Structured Logging Levels
### Current State
OwnCord uses `slog.Info` for most log lines, including routine operations like `"message sent"` and `"channel_focus"`. This creates noise in production.
### What Production Platforms Do
Matrix's Synapse and Dendrite use tiered logging: `Debug` for per-message flow, `Info` for connection lifecycle events, `Warn` for recoverable issues, `Error` for things that need attention.
### Recommendation
- **Debug:** Per-message dispatch, typing events, presence updates, broadcast delivery counts
- **Info:** Connection/disconnection, auth success, voice join/leave
- **Warn:** Rate limit hits, malformed messages, non-fatal DB errors
- **Error:** DB write failures, LiveKit communication failures, unrecoverable states
Specific lines to change:
- `handlers.go:230` "message sent" → `slog.Debug`
- `handlers.go:513` "channel_focus" → `slog.Debug`
- `serve.go:65-66` "websocket connected" + audit log → keep `slog.Info`
**Sources:**
- [Logging in Go with Slog: The Ultimate Guide](https://betterstack.com/community/guides/logging/logging-in-go/)
---
## 3. Go Server: Message Builder Type Safety
### Current State
All WebSocket messages are built using `map[string]any` (e.g., `buildChatMessage`, `buildAuthOK`). This has zero compile-time safety — a typo in a key name or wrong type silently produces broken protocol messages.
### What Production Platforms Do
Matrix's Dendrite and Revolt's server define **typed structs** for every protocol message and use `json.Marshal` on those structs.
### Recommendation
Define typed structs matching PROTOCOL.md:
```go
type ChatMessagePayload struct {
ID int64 `json:"id"`
ChannelID int64 `json:"channel_id"`
User UserSummary `json:"user"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
ReplyTo *int64 `json:"reply_to"`
Attachments []AttachmentInfo `json:"attachments,omitempty"`
}
type ServerMessage struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Payload interface{} `json:"payload"`
}
```
Benefits: compile-time field validation, IDE autocomplete, easier protocol evolution, automatic documentation via godoc.
---
## 4. Go Server: Graceful Shutdown & Connection Draining
### Current State
`Hub.GracefulStop()` stops LiveKit and closes the hub channel, but doesn't drain existing connections or wait for in-flight messages.
### What Production Platforms Do
Discord's gateway servers use **connection draining**: on shutdown signal, stop accepting new connections, send `server_restart` to all clients, wait for a grace period (5-10s), then close remaining connections.
### Recommendation
```
1. Signal received → h.BroadcastServerRestart("shutdown", 5)
2. Stop accepting new WS upgrades (close HTTP listener)
3. time.Sleep(5 * time.Second) or wait for all clients to disconnect
4. h.Stop() → close remaining connections
```
This pairs with the existing `server_restart` protocol message — just needs the server-side orchestration.
**Sources:**
- [Go WebSocket Server Guide: production best practices](https://websocket.org/guides/languages/go/)
- [Discord engineering: gateway resilience](https://medium.com/@neerupujari5/why-discord-rarely-goes-down-8-engineering-principles-you-should-copy-today-704ee44b42a9)
---
## 5. Client: Component Lifecycle & Memory Leak Prevention
### Current State
Components use `mount()`/`destroy()` with manual `unsub()` calls. Some components may not clean up all event listeners, timers, or DOM references.
### What Production Platforms Do
Element/Matrix uses a disposable pattern where every subscription, timer, and event listener is tracked in a cleanup array and flushed on unmount.
### Recommendation
Add a `Disposable` mixin/base pattern:
```typescript
class Disposable {
private cleanups: Array<() => void> = [];
protected addCleanup(fn: () => void): void {
this.cleanups.push(fn);
}
protected onStoreChange<T>(store: Store<T>, listener: (s: T) => void): void {
this.addCleanup(store.subscribe(listener));
}
protected onEvent(el: EventTarget, event: string, handler: EventListener): void {
el.addEventListener(event, handler);
this.addCleanup(() => el.removeEventListener(event, handler));
}
protected onInterval(fn: () => void, ms: number): void {
const id = setInterval(fn, ms);
this.addCleanup(() => clearInterval(id));
}
destroy(): void {
for (const fn of this.cleanups) fn();
this.cleanups.length = 0;
}
}
```
Every component extends `Disposable` instead of manually tracking `unsub` arrays. This is how Element Web, Rocket.Chat, and most production chat clients prevent leaks.
**Sources:**
- [Fixing Memory Leaks: Best Practices](https://suggestron.com/2025/05/18/fixing-memory-leaks-in-react-angular-and-vue-js-best-practices-and-tools/)
- [JavaScript Memory Leaks in 2025](https://medium.com/@deval93/javascript-memory-leaks-in-2025-how-to-detect-prevent-and-fix-them-ade013bd8b46)
---
## 6. Client: Virtual Scrolling for Message List
### Current State
`MessageList.ts` renders all loaded messages as DOM elements. As conversation history grows, DOM size increases linearly, causing:
- Increasing memory usage
- Slower re-renders
- Scroll jank
### What Production Platforms Do
Discord uses virtual scrolling — only messages visible in the viewport (plus a small buffer) exist as DOM elements. Stream Chat, Rocket.Chat, and Element all use this pattern. Kreya reports rendering millions of messages without lag using this approach.
### Recommendation
Implement a windowed rendering approach:
1. Maintain the full message array in the store (current behavior — keep this)
2. Only render messages in `[scrollTop - buffer, scrollTop + viewportHeight + buffer]`
3. Use a sentinel element at top/bottom to trigger pagination
4. Recycle DOM nodes instead of creating/destroying on scroll
Key consideration: chat messages have variable heights, so use a height-estimation cache (measure once, cache, re-measure on resize).
**Sources:**
- [Virtual Scrolling: Rendering millions of messages without lag](https://kreya.app/blog/using-virtual-scrolling/)
- [Rocket.Chat issue #5111: Infinite scroll without DOM manipulation](https://github.com/RocketChat/Rocket.Chat/issues/5111)
---
## 7. Protocol: Message Delivery Acknowledgment
### Current State
`chat_send` gets a `chat_send_ok` ack — good. But broadcasts (`chat_message`, `chat_edited`, `chat_deleted`, etc.) have no delivery guarantee. If a client misses a broadcast due to a brief disconnect, the message is lost from their view until they reload.
### What Production Platforms Do
- Discord uses **sequence numbers** on gateway events. On reconnect, the client sends the last sequence number and gets missed events replayed.
- Matrix uses a **sync token** — each sync response includes a `next_batch` token. On reconnect, the client resumes from its last token.
- Slack uses a similar **event ID** approach.
### Recommendation
Add a monotonic `seq` field to all server→client broadcasts:
```json
{ "type": "chat_message", "seq": 4821, "payload": { ... } }
```
On reconnect, the client sends `{ "type": "auth", "payload": { "token": "...", "last_seq": 4820 } }`. The server replays events from `last_seq + 1` to current. This requires:
1. A bounded event buffer on the server (ring buffer of last N events per channel)
2. A `seq` counter on the Hub
3. Client-side gap detection: if received `seq` skips a number, request a resync
This is the single highest-impact improvement for reliability — every major chat platform implements this pattern.
**Sources:**
- [WebSocket Reconnection: State Sync and Recovery Guide](https://websocket.org/guides/reconnection/)
- [WebSocket reliability in realtime](https://ably.com/topic/websocket-reliability-in-realtime-infrastructure)
- [Discord: why it rarely fails](https://medium.com/@neerupujari5/why-discord-rarely-goes-down-8-engineering-principles-you-should-copy-today-704ee44b42a9)
---
## 8. Protocol: Heartbeat Improvements
### Current State
Client sends `ping` every 30s. Server responds with `pong`. No server-initiated keepalive. If the server detects a dead connection, it only notices when a `conn.Read` or `conn.Write` fails.
### What Production Platforms Do
Discord's gateway sends server-initiated heartbeats at a specified interval (sent in the `HELLO` event). If the client misses sending a heartbeat response, the server closes the connection. This is bidirectional: both sides monitor liveness.
### Recommendation
- Server should also track last-received-message time per client
- If no message received from a client in 60s (2x heartbeat interval), close the connection as stale
- This prevents "ghost connections" where the client process crashed but TCP hasn't timed out yet
- The `readPump` can check `time.Since(c.lastActivity)` periodically
---
## 9. SQLite: Performance Pragmas
### Current State
OwnCord uses SQLite in WAL mode (good). But additional pragmas can significantly improve performance.
### What Production Deployments Do
The most-cited SQLite performance tuning guide recommends these pragmas for production chat workloads:
### Recommendation
Ensure these pragmas are set at connection init:
```sql
PRAGMA journal_mode = WAL; -- already done
PRAGMA synchronous = NORMAL; -- safe with WAL, 2x faster than FULL
PRAGMA temp_store = MEMORY; -- temp tables in RAM
PRAGMA mmap_size = 268435456; -- 256MB memory-mapped I/O
PRAGMA cache_size = -64000; -- 64MB page cache
PRAGMA wal_autocheckpoint = 1000; -- optimal checkpoint interval
PRAGMA busy_timeout = 5000; -- wait 5s on lock instead of immediate SQLITE_BUSY
PRAGMA foreign_keys = ON; -- enforce referential integrity
```
Also: periodic `PRAGMA optimize` (once per connection close) lets SQLite auto-tune its query planner.
**Sources:**
- [SQLite performance tuning (phiresky)](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
- [SQLite Performance Optimization Guide 2026](https://forwardemail.net/en/blog/docs/sqlite-performance-optimization-pragma-chacha20-production-guide)
- [SQLite Optimizations For Ultra High-Performance](https://www.powersync.com/blog/sqlite-optimizations-for-ultra-high-performance)
---
## 10. Client: Store Subscription Efficiency
### Current State
`store.ts` uses `queueMicrotask` for batched notifications — excellent. But `subscribe()` fires on EVERY state change, and components must use `subscribeSelector` manually to avoid unnecessary re-renders.
### What Production Platforms Do
Element uses a Flux dispatcher with fine-grained event types. Zustand (used by many production apps) defaults to selector-based subscriptions with shallow equality.
### Recommendation
- Make `subscribeSelector` the primary API. Rename it to just `subscribe` and make the old `subscribe` into `subscribeAll` (rare use case)
- Add a built-in `shallowEqual` comparator for array/object selectors
- Consider adding a `batch()` utility for coordinated multi-store updates (e.g., when the `ready` payload updates channels, members, voice states, and roles simultaneously)
This reduces wasted re-renders and is the pattern used by Zustand, Jotai, and Redux Toolkit.
---
## 11. Client: WebSocket Reconnection with State Recovery
### Current State
`ws.ts` has exponential backoff reconnection — good. But on reconnect, the client re-authenticates and gets a fresh `ready` payload. Any messages received between disconnect and reconnect are lost.
### What Production Platforms Do
- Discord replays missed events using sequence numbers (see #7)
- Slack has a "catch up" mechanism that fetches missed events on reconnect
- Matrix resumes from the last sync token
### Recommendation (client side of #7)
1. Track last received `seq` number
2. On reconnect, send `last_seq` in the auth message
3. If the server can replay, process the replayed events normally
4. If too far behind (server returns `"resync_required"`), do a full state refresh (current behavior)
5. During reconnect, queue outbound messages locally and flush after reconnection
---
## 12. Go Server: Request-Scoped Structured Logging
### Current State
Log lines include `user_id` and sometimes `channel_id`, but each log call adds these manually. There's no correlation ID across a single message's lifecycle.
### What Production Platforms Do
Matrix's Dendrite uses request-scoped loggers with `slog.With()` to carry context through an entire handler chain.
### Recommendation
In `handleMessage`, create a request-scoped logger:
```go
reqLog := slog.With(
"user_id", c.userID,
"msg_type", env.Type,
"req_id", env.ID,
)
```
Pass `reqLog` to sub-handlers instead of using the global `slog`. This:
- Eliminates repeated `"user_id", c.userID` in every log call
- Enables tracing a single message through its entire lifecycle
- Makes log grep/filter much easier in production
**Sources:**
- [Structured Logging with slog (Go blog)](https://go.dev/blog/slog)
---
## 13. Testing: WebSocket Integration Test Patterns
### Current State
Tests use `NewTestClient` with bare send channels — functional but doesn't test the actual WebSocket upgrade, serialization, or connection lifecycle.
### What Production Platforms Do
Matrix's Dendrite has a `test.Server` that starts a real HTTP server, upgrades to WebSocket, and runs scenarios end-to-end. Mumble has protocol-level integration tests.
### Recommendation
Add a thin integration test layer:
1. Start a test HTTP server with `httptest.NewServer`
2. Connect via real WebSocket (`nhooyr.io/websocket.Dial`)
3. Send auth message, receive `auth_ok` + `ready`
4. Run message send/receive scenarios
5. Test reconnection and error paths
This catches serialization bugs, protocol violations, and concurrency issues that unit tests with mock channels miss. Keep existing unit tests as-is — add this as a separate `_integration_test.go` file.
---
## 14. Client: TypeScript Strict Mode Enforcement
### Current State
The client uses TypeScript but some patterns (like `as unknown as` casts in ws.ts listener registry) bypass type safety.
### What Production Platforms Do
Element Web uses strict TypeScript with `"strict": true` and avoids `any` types. Revolt's client also enforces strict mode.
### Recommendation
- Audit `tsconfig.json` for `"strict": true`, `"noUncheckedIndexedAccess": true`
- Replace `as unknown as` casts with proper generics or discriminated union narrowing
- The ws.ts listener registry can use a generic `Map<T, Set<WsListener<T>>>` pattern that avoids casts entirely
- Replace `map[string]any` equivalent patterns (`Record<string, unknown>`) with typed interfaces
---
## Key Takeaways (Priority Order)
1. **Message sequence numbers + replay on reconnect** (#7, #11) — highest-impact reliability improvement; every major platform does this
2. **Typed message structs in Go** (#3) — eliminates an entire class of silent protocol bugs
3. **Virtual scrolling for messages** (#6) — prevents performance degradation as conversations grow
4. **Disposable component pattern** (#5) — systematic prevention of memory leaks
5. **SQLite pragma tuning** (#9) — free performance gains with no code changes
6. **Sentinel errors** (#1) — cleaner error handling, better testability
7. **Structured logging levels** (#2) — reduces noise, improves debuggability
8. **Request-scoped logging** (#12) — makes production debugging tractable
9. **Graceful shutdown** (#4) — prevents data loss during server restarts
10. **Server-side heartbeat monitoring** (#8) — detects ghost connections faster
11. **Store subscription efficiency** (#10) — reduces wasted re-renders
12. **WebSocket integration tests** (#13) — catches serialization/protocol bugs
13. **TypeScript strict mode** (#14) — catches type errors at compile time
14. **Heartbeat improvements** (#8) — bidirectional liveness detection
---
## Sources
1. [Go WebSocket Server Guide](https://websocket.org/guides/languages/go/)
2. [A Million WebSockets and Go](https://www.freecodecamp.org/news/million-websockets-and-go-cc58418460bb/)
3. [WebSocket Reconnection: State Sync Guide](https://websocket.org/guides/reconnection/)
4. [WebSocket Best Practices for Production](https://websocket.org/guides/best-practices/)
5. [WebSocket reliability in realtime](https://ably.com/topic/websocket-reliability-in-realtime-infrastructure)
6. [WebSocket architecture best practices](https://ably.com/topic/websocket-architecture-best-practices)
7. [Discord: handling 2.5M concurrent voice users](https://discord.com/blog/how-discord-handles-two-and-half-million-concurrent-voice-users-using-webrtc)
8. [Why Discord rarely fails: 8 engineering principles](https://medium.com/@neerupujari5/why-discord-rarely-goes-down-8-engineering-principles-you-should-copy-today-704ee44b42a9)
9. [Element Web architecture (DeepWiki)](https://deepwiki.com/element-hq/element-web)
10. [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk)
11. [Matrix Specification](https://spec.matrix.org/latest/)
12. [SQLite performance tuning (phiresky)](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
13. [SQLite Performance Optimization Guide 2026](https://forwardemail.net/en/blog/docs/sqlite-performance-optimization-pragma-chacha20-production-guide)
14. [SQLite Optimizations For Ultra High-Performance](https://www.powersync.com/blog/sqlite-optimizations-for-ultra-high-performance)
15. [Virtual Scrolling: millions of messages without lag](https://kreya.app/blog/using-virtual-scrolling/)
16. [Rocket.Chat: infinite scroll DOM issues](https://github.com/RocketChat/Rocket.Chat/issues/5111)
17. [LiveKit Documentation](https://docs.livekit.io/)
18. [LiveKit Client Protocol](https://docs.livekit.io/reference/internals/client-protocol/)
19. [Robust Go: Error Handling Best Practices](https://leapcell.io/blog/robust-go-best-practices-for-error-handling)
20. [Structured Logging with slog (Go blog)](https://go.dev/blog/slog)
21. [Logging in Go with Slog (Better Stack)](https://betterstack.com/community/guides/logging/logging-in-go/)
22. [Fixing Memory Leaks: Best Practices](https://suggestron.com/2025/05/18/fixing-memory-leaks-in-react-angular-and-vue-js-best-practices-and-tools/)
23. [JavaScript Memory Leaks in 2025](https://medium.com/@deval93/javascript-memory-leaks-in-2025-how-to-detect-prevent-and-fix-them-ade013bd8b46)
24. [Revolt Chat (GitHub)](https://github.com/revoltchat)
25. [Building Scalable Real-Time Applications with LiveKit](https://azumo.com/artificial-intelligence/ai-insights/livekit-building-production-ready-real-time-voice-and-video-applications)
---
## Methodology
Searched 20+ queries across web, analyzed 25+ sources, and cross-referenced against the current OwnCord codebase (Go server: `ws/`, `api/`, `db/`, `auth/`; Client: `lib/`, `stores/`, `components/`). Sub-questions investigated: self-hosted platform architectures, voice/video patterns, testing strategies, resilience patterns, security practices, performance optimization.