mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -312,6 +312,7 @@ export interface ErrorPayload {
|
||||
|
||||
export interface AuthPayload {
|
||||
readonly token: string;
|
||||
readonly last_seq?: number;
|
||||
}
|
||||
|
||||
export interface ChatSendPayload {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ==="
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -63,7 +63,7 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires}
|
||||
if auth.IsEffectivelyBanned(tempUser) {
|
||||
slog.Info("ws user banned, closing connection", "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("BANNED", "you are banned"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned"))
|
||||
h.kickClient(c)
|
||||
return
|
||||
}
|
||||
@@ -71,12 +71,34 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
slog.Warn("ws handleMessage invalid JSON", "user_id", c.userID, "err", err)
|
||||
c.sendMsg(buildErrorMsg("INVALID_JSON", "message must be valid JSON"))
|
||||
c.mu.Lock()
|
||||
c.invalidCount++
|
||||
count := c.invalidCount
|
||||
c.mu.Unlock()
|
||||
|
||||
slog.Warn("ws handleMessage invalid JSON", "user_id", c.userID, "err", err, "invalid_count", count)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInvalidJSON, "message must be valid JSON"))
|
||||
|
||||
if count >= 10 {
|
||||
slog.Warn("ws too many invalid messages, closing connection", "user_id", c.userID, "invalid_count", count)
|
||||
h.kickClient(c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("ws ← client message", "type", env.Type, "user_id", c.userID, "id", env.ID)
|
||||
// Valid parse — reset consecutive invalid counter.
|
||||
c.mu.Lock()
|
||||
c.invalidCount = 0
|
||||
c.mu.Unlock()
|
||||
|
||||
// Request-scoped logger with correlation context.
|
||||
reqLog := slog.With(
|
||||
"user_id", c.userID,
|
||||
"msg_type", env.Type,
|
||||
"req_id", env.ID,
|
||||
)
|
||||
|
||||
reqLog.Debug("ws ← client message")
|
||||
|
||||
switch env.Type {
|
||||
case "chat_send":
|
||||
@@ -110,8 +132,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
case "ping":
|
||||
c.sendMsg(buildJSON(map[string]any{"type": "pong"}))
|
||||
default:
|
||||
slog.Warn("ws handleMessage unknown type", "type", env.Type, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("UNKNOWN_TYPE", fmt.Sprintf("unknown message type: %s", env.Type)))
|
||||
reqLog.Warn("ws handleMessage unknown type")
|
||||
c.sendMsg(buildErrorMsg(ErrCodeUnknownType, fmt.Sprintf("unknown message type: %s", env.Type)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,19 +153,19 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
|
||||
Attachments []string `json:"attachments"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_send payload"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_send payload"))
|
||||
return
|
||||
}
|
||||
channelID, err := p.ChannelID.Int64()
|
||||
if err != nil || channelID <= 0 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be a positive integer"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check channel exists.
|
||||
ch, err := h.db.GetChannel(channelID)
|
||||
if err != nil || ch == nil {
|
||||
c.sendMsg(buildErrorMsg("NOT_FOUND", "channel not found"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,7 +178,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
|
||||
if ch.SlowMode > 0 && !h.hasChannelPerm(c, channelID, permissions.ManageMessages) {
|
||||
slowKey := fmt.Sprintf("slow:%d:%d", c.userID, channelID)
|
||||
if !h.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {
|
||||
c.sendMsg(buildErrorMsg("SLOW_MODE", fmt.Sprintf("channel has %ds slow mode", ch.SlowMode)))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeSlowMode, fmt.Sprintf("channel has %ds slow mode", ch.SlowMode)))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -164,11 +186,11 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
|
||||
// Sanitize and validate content length.
|
||||
content := sanitizer.Sanitize(p.Content)
|
||||
if content == "" && len(p.Attachments) == 0 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content cannot be empty"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message content cannot be empty"))
|
||||
return
|
||||
}
|
||||
if len([]rune(content)) > 4000 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content exceeds maximum length of 4000 characters"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message content exceeds maximum length of 4000 characters"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -183,7 +205,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
|
||||
msgID, err := h.db.CreateMessage(channelID, c.userID, content, p.ReplyTo)
|
||||
if err != nil {
|
||||
slog.Error("ws handleChatSend CreateMessage", "err", err)
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to save message"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to save message"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,7 +238,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
|
||||
msg, err := h.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
slog.Error("ws handleChatSend GetMessage after create", "err", err)
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to retrieve message"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to retrieve message"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,7 +249,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
|
||||
avatar = c.user.Avatar
|
||||
}
|
||||
|
||||
slog.Info("message sent", "user", username, "channel_id", channelID, "msg_id", msgID)
|
||||
slog.Debug("message sent", "user", username, "channel_id", channelID, "msg_id", msgID)
|
||||
|
||||
// Ack sender.
|
||||
c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp))
|
||||
@@ -250,31 +272,31 @@ func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_edit payload"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_edit payload"))
|
||||
return
|
||||
}
|
||||
msgID, err := p.MessageID.Int64()
|
||||
if err != nil || msgID <= 0 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer"))
|
||||
return
|
||||
}
|
||||
|
||||
content := sanitizer.Sanitize(p.Content)
|
||||
if content == "" {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "content cannot be empty"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "content cannot be empty"))
|
||||
return
|
||||
}
|
||||
|
||||
// EditMessage checks ownership internally.
|
||||
if err := h.db.EditMessage(msgID, c.userID, content); err != nil {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "cannot edit this message"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message"))
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
slog.Error("ws handleChatEdit GetMessage after edit", "err", err, "msg_id", msgID)
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "edit saved but broadcast failed"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "edit saved but broadcast failed"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -282,7 +304,7 @@ func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) {
|
||||
if msg.EditedAt != nil {
|
||||
editedAt = *msg.EditedAt
|
||||
}
|
||||
slog.Info("message edited", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID)
|
||||
slog.Debug("message edited", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID)
|
||||
h.BroadcastToChannel(msg.ChannelID, buildChatEdited(msgID, msg.ChannelID, content, editedAt))
|
||||
}
|
||||
|
||||
@@ -298,28 +320,28 @@ func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) {
|
||||
MessageID json.Number `json:"message_id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_delete payload"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_delete payload"))
|
||||
return
|
||||
}
|
||||
msgID, err := p.MessageID.Int64()
|
||||
if err != nil || msgID <= 0 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer"))
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
c.sendMsg(buildErrorMsg("NOT_FOUND", "message not found"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeNotFound, "message not found"))
|
||||
return
|
||||
}
|
||||
|
||||
isMod := h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages)
|
||||
if err := h.db.DeleteMessage(msgID, c.userID, isMod); err != nil {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "cannot delete this message"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot delete this message"))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("message deleted", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod)
|
||||
slog.Debug("message deleted", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod)
|
||||
_ = h.db.LogAudit(c.userID, "message_delete", "message", msgID,
|
||||
fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod))
|
||||
h.BroadcastToChannel(msg.ChannelID, buildChatDeleted(msgID, msg.ChannelID))
|
||||
@@ -338,26 +360,26 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid reaction payload"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid reaction payload"))
|
||||
return
|
||||
}
|
||||
msgID, err := p.MessageID.Int64()
|
||||
if err != nil || msgID <= 0 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "message_id must be positive integer"))
|
||||
return
|
||||
}
|
||||
if p.Emoji == "" {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji cannot be empty"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji cannot be empty"))
|
||||
return
|
||||
}
|
||||
if len(p.Emoji) > 32 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji too long"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji too long"))
|
||||
return
|
||||
}
|
||||
// Reject control characters (U+0000–U+001F, U+007F) to prevent injection.
|
||||
for _, r := range p.Emoji {
|
||||
if r < 0x20 || r == 0x7F {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji contains invalid characters"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "emoji contains invalid characters"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -366,7 +388,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
|
||||
if err != nil || msg == nil {
|
||||
// Normalize: return same error whether message doesn't exist or is in
|
||||
// a channel the user can't see (prevents IDOR information leak).
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "reaction failed"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "reaction failed"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -384,7 +406,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
|
||||
if err != nil {
|
||||
// Sanitize: never leak raw DB constraint errors to client.
|
||||
slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", c.userID, "err", err)
|
||||
c.sendMsg(buildErrorMsg("CONFLICT", "reaction failed"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeConflict, "reaction failed"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -395,7 +417,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
|
||||
func (h *Hub) handleTyping(c *Client, payload json.RawMessage) {
|
||||
channelID, err := parseChannelID(payload)
|
||||
if err != nil || channelID <= 0 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be positive integer"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be positive integer"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -425,17 +447,19 @@ func (h *Hub) handlePresence(c *Client, payload json.RawMessage) {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid presence_update payload"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid presence_update payload"))
|
||||
return
|
||||
}
|
||||
validStatuses := map[string]bool{"online": true, "idle": true, "dnd": true, "offline": true}
|
||||
if !validStatuses[p.Status] {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "status must be online|idle|dnd|offline"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "status must be online|idle|dnd|offline"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateUserStatus(c.userID, p.Status); err != nil {
|
||||
slog.Error("ws handlePresence UpdateUserStatus", "err", err)
|
||||
slog.Error("ws handlePresence UpdateUserStatus", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update status"))
|
||||
return
|
||||
}
|
||||
|
||||
h.BroadcastToAll(buildPresenceMsg(c.userID, p.Status))
|
||||
@@ -471,7 +495,7 @@ func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLab
|
||||
return true
|
||||
}
|
||||
slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel)
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing "+permLabel+" permission"))
|
||||
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing "+permLabel+" permission"))
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -510,7 +534,7 @@ func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) {
|
||||
c.channelID = chID
|
||||
c.mu.Unlock()
|
||||
|
||||
slog.Info("channel_focus", "user_id", c.userID, "channel_id", chID, "prev_channel_id", prevCh)
|
||||
slog.Debug("channel_focus", "user_id", c.userID, "channel_id", chID, "prev_channel_id", prevCh)
|
||||
|
||||
// Mark channel as read by updating read_states to the latest message.
|
||||
latestID, latestErr := h.db.GetLatestMessageID(chID)
|
||||
|
||||
+146
-20
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user