fix: resolve 15 post-review issues across server and client

Server fixes:
- Move ATTACH_FILES permission check before CreateMessage to prevent
  orphaned messages on permission denial
- Fix hardcoded /api/files/ URL to /api/v1/files/ per spec
- Add error logging for GetAttachmentsByMessageIDs failure
- Set 1MB WebSocket read limit to match client-side limit
- Extract requireChannelPerm helper, replacing 8 repeated patterns

Client fixes:
- Wire onUnauthorized callback to clear auth on 401 responses
- Store auth token in authStore before WS connect
- Reset WS state to disconnected when Tauri APIs unavailable
- Add connectivity guard and 200ms send debounce on message send
- Add toast container to MainPage with error feedback on 5 API failures
- Clear voice currentChannelId on server-driven voice_leave for current user
- Apply stored theme/font/compact preferences at app startup
- Fix infinite scroll throttle to use store subscription instead of fixed timer

Tests:
- Add TestChatSend_AttachmentsDeniedNoMessageCreated
- Add attachments table to handler test schema
This commit is contained in:
jevb
2026-03-17 01:59:34 +01:00
parent 95c5b2d3b9
commit 4bdc83a368
15 changed files with 690 additions and 123 deletions
-59
View File
@@ -1,59 +0,0 @@
# Code Review Findings (2026-03-16)
Scope: server (Go), Tauri client (TS/Rust), spec/docs.
No tests run.
> **Status: ALL RESOLVED** (2026-03-16).
> Commits: `0680a32`, `0bd9165`, `642cd54`.
## Critical
1. Auth bypass: any client can `channel_focus` any channel
and REST returns data without permission checks.
Impact: cross-channel data exposure.
Fix: enforce `READ_MESSAGES` on focus, GET channels,
messages, and search; track access in hub routing.
2. WS auth failure mismatch: server sends `type: "error"`
with `AUTH_ERROR`; client expects `auth_error` type.
Impact: infinite reconnect with bad token.
Fix: emit `auth_error` per spec; client stops on it.
3. Member/role protocol mismatch: server sends flat
`member_join` with `role_id`; client expects nested
`payload.user` with role string.
Impact: runtime crash on join, wrong role display.
Fix: align payloads to `UserWithRole` shape.
4. `chat_message` omits `attachments`; client iterates
it unconditionally. Impact: crash on render.
Fix: always include `attachments: []` in payloads.
## High
1. REST message/search responses diverge from API spec.
Missing `user` object, attachments, reactions, pinned,
deleted fields. Impact: clients mis-parse data.
Fix: update handlers/queries to match spec shapes.
2. Health endpoint mismatch: server `/health`, client
`/api/v1/health`, docs `/api/health`.
Impact: health checks always fail.
Fix: pick one canonical path, update all.
3. Attachments parsed on `chat_send` but never persisted.
Impact: attachments silently dropped.
Fix: validate IDs, persist, include in responses.
## Medium
1. WS heartbeat `ping` treated as unknown by server.
Impact: noisy error logs.
Fix: add `ping` handler or disable client heartbeat.
2. API base path inconsistent: docs say `/api`, code
uses `/api/v1`. Impact: wrong integration URLs.
Fix: pick one path, update code and docs.
**Low**
1. `auth_ok` does not include role, but UI expects role-based color coding. Even if `member_join` and `ready` are fixed, initial auth state will still lack role. Impact: inconsistent role display until ready arrives. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\serve.go:171-191`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\types.ts:163-167`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\dispatcher.ts:55-63`. Recommendation: include role in `auth_ok` or adjust client to tolerate missing role until ready.
**Test Gaps**
1. No automated coverage for authorization of channel read access (REST and WS channel focus). Given the permission system, this should have dedicated tests to prevent regressions. Suggested targets: `D:\Local-Lab\Coding\Repos\OwnCord\Server\api\channel_handler_test.go` and WS tests in `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\handlers_test.go`.
2. No contract tests asserting server responses match `API.md` and `PROTOCOL.md`. The current drift would have been caught by simple golden tests.
@@ -24,6 +24,7 @@ export type MessageInputComponent = MountableComponent & {
const TYPING_THROTTLE_MS = 3_000;
const MAX_TEXTAREA_HEIGHT = 200;
const SEND_DEBOUNCE_MS = 200;
export function createMessageInput(
options: MessageInputOptions,
@@ -34,6 +35,7 @@ export function createMessageInput(
let state = { replyTo: null as { messageId: number; username: string } | null,
editing: null as { messageId: number } | null };
let lastTypingTime = 0;
let lastSendTime = 0;
let textarea: HTMLTextAreaElement | null = null;
let replyBar: HTMLDivElement | null = null;
@@ -69,6 +71,11 @@ export function createMessageInput(
const content = textarea.value.trim();
if (content.length === 0) return;
// Debounce to prevent double-click duplicate sends
const now = Date.now();
if (now - lastSendTime < SEND_DEBOUNCE_MS) return;
lastSendTime = now;
if (state.editing !== null) {
options.onEditMessage(state.editing.messageId, content);
cancelEdit();
@@ -112,7 +119,7 @@ export function createMessageInput(
}
function mount(container: Element): void {
root = createElement("div", { class: "message-input-wrap" });
root = createElement("div", { class: "message-input-wrap", "data-testid": "message-input" });
replyBar = createElement("div", { class: "reply-bar" });
const replyInner = createElement("div", { class: "reply-bar-inner" });
@@ -137,11 +144,12 @@ export function createMessageInput(
{ class: "input-btn attach-btn", "aria-label": "Attach file" }, "+");
textarea = createElement("textarea", {
class: "msg-textarea", placeholder: `Message #${options.channelName}`, rows: "1",
"data-testid": "msg-textarea",
});
const emojiBtn = createElement("button",
{ class: "input-btn emoji-btn", "aria-label": "Emoji" }, "\uD83D\uDE00");
const sendBtn = createElement("button",
{ class: "input-btn send-btn", "aria-label": "Send message" }, "\u27A4");
{ class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" }, "\u27A4");
textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal });
textarea.addEventListener("keydown", (e: KeyboardEvent) => {
@@ -11,7 +11,7 @@ import {
} from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { Attachment } from "@lib/types";
import { messagesStore, getChannelMessages } from "@stores/messages.store";
import { messagesStore, getChannelMessages, hasMoreMessages } from "@stores/messages.store";
import type { Message } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
@@ -274,6 +274,7 @@ function renderMessage(
const el = createElement("div", {
class: isGrouped ? "message grouped" : "message",
"data-testid": `message-${msg.id}`,
});
// Avatar (hidden for grouped messages via CSS)
@@ -336,25 +337,25 @@ function renderMessage(
if (!msg.deleted) {
const actionsBar = createElement("div", { class: "msg-actions-bar" });
const reactBtn = createElement("button", {}, "\uD83D\uDE04");
const reactBtn = createElement("button", { "data-testid": `msg-react-${msg.id}` }, "\uD83D\uDE04");
reactBtn.title = "React";
reactBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal });
actionsBar.appendChild(reactBtn);
const replyBtn = createElement("button", {}, "\u21A9");
const replyBtn = createElement("button", { "data-testid": `msg-reply-${msg.id}` }, "\u21A9");
replyBtn.title = "Reply";
replyBtn.addEventListener("click", () => opts.onReplyClick(msg.id), { signal });
actionsBar.appendChild(replyBtn);
if (msg.user.id === opts.currentUserId) {
const editBtn = createElement("button", {}, "\u270E");
const editBtn = createElement("button", { "data-testid": `msg-edit-${msg.id}` }, "\u270E");
editBtn.title = "Edit";
editBtn.addEventListener("click", () => opts.onEditClick(msg.id), { signal });
actionsBar.appendChild(editBtn);
}
if (msg.user.id === opts.currentUserId) {
const deleteBtn = createElement("button", {}, "\uD83D\uDDD1");
const deleteBtn = createElement("button", { "data-testid": `msg-delete-${msg.id}` }, "\uD83D\uDDD1");
deleteBtn.title = "Delete";
deleteBtn.addEventListener("click", () => opts.onDeleteClick(msg.id), { signal });
actionsBar.appendChild(deleteBtn);
@@ -414,14 +415,26 @@ export function createMessageList(options: MessageListOptions): MountableCompone
}
let loadingOlder = false;
let prevMessageCount = 0;
// Reset loadingOlder when the store updates with new messages (fetch completed)
const unsubLoadingReset = messagesStore.subscribe(() => {
const msgs = getChannelMessages(options.channelId);
if (msgs.length !== prevMessageCount) {
prevMessageCount = msgs.length;
loadingOlder = false;
}
});
function handleScroll(): void {
if (messagesContainer === null) return;
if (messagesContainer.scrollTop < SCROLL_TOP_THRESHOLD && !loadingOlder) {
if (
messagesContainer.scrollTop < SCROLL_TOP_THRESHOLD
&& !loadingOlder
&& hasMoreMessages(options.channelId)
) {
loadingOlder = true;
options.onScrollTop();
// Reset after a short delay to allow the fetch to land
setTimeout(() => { loadingOlder = false; }, 500);
}
}
@@ -448,6 +461,7 @@ export function createMessageList(options: MessageListOptions): MountableCompone
function destroy(): void {
ac.abort();
unsubLoadingReset();
for (const unsub of unsubscribers) { unsub(); }
unsubscribers.length = 0;
if (root !== null) { root.remove(); root = null; }
@@ -72,6 +72,22 @@ function applyTheme(name: ThemeName): void {
}
}
/**
* Apply stored appearance preferences (theme, font size, compact mode).
* Call at app startup so the UI doesn't flash default styles.
*/
export function applyStoredAppearance(): void {
applyTheme(loadPref<ThemeName>("theme", "dark"));
document.documentElement.style.setProperty(
"--font-size",
`${loadPref<number>("fontSize", 16)}px`,
);
document.documentElement.classList.toggle(
"compact-mode",
loadPref<boolean>("compactMode", false),
);
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
@@ -590,7 +606,7 @@ export function createSettingsOverlay(
// ---- MountableComponent ---------------------------------------------------
function mount(container: Element): void {
root = createElement("div", { class: "settings-overlay" });
root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" });
// Sidebar
const sidebar = createElement("div", { class: "settings-sidebar" });
@@ -35,6 +35,7 @@ import {
setVoiceConfig,
setSpeakers,
joinVoiceChannel,
leaveVoiceChannel,
} from "@stores/voice.store";
import { createLogger } from "./logger";
@@ -217,6 +218,11 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
unsubs.push(
ws.on("voice_leave", (payload) => {
removeVoiceUser(payload);
// Clear local voice state if the current user was removed (kick/disconnect)
const currentUserId = authStore.getState().user?.id ?? 0;
if (payload.user_id === currentUserId) {
leaveVoiceChannel();
}
}),
);
+1
View File
@@ -234,6 +234,7 @@ export function createWsClient() {
await ensureTauriApis();
if (tauriInvoke === null) {
log.error("Tauri APIs not available, cannot connect WebSocket");
setState("disconnected");
return;
}
+15 -1
View File
@@ -1,5 +1,10 @@
// OwnCord Tauri v2 Client — Entry Point
import "@styles/tokens.css";
import "@styles/base.css";
import "@styles/login.css";
import "@styles/app.css";
import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render";
import { createRouter } from "@lib/router";
import { createApiClient } from "@lib/api";
@@ -8,6 +13,7 @@ import { wireDispatcher } from "@lib/dispatcher";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { createConnectPage } from "@pages/ConnectPage";
import { createMainPage } from "@pages/MainPage";
import { applyStoredAppearance } from "@components/SettingsOverlay";
import { createConnectedOverlay } from "@components/ConnectedOverlay";
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
import { createLogger } from "@lib/logger";
@@ -19,6 +25,9 @@ const log = createLogger("main");
// Install global error handlers first
installGlobalErrorHandlers();
// Apply stored theme/font/compact preferences before first render
applyStoredAppearance();
const appEl = document.getElementById("app");
if (!appEl) {
throw new Error("Missing #app element");
@@ -26,7 +35,10 @@ if (!appEl) {
// Create core services
const router = createRouter("connect");
const api = createApiClient({ host: "" });
const api = createApiClient({ host: "" }, () => {
log.warn("Session expired (401), clearing auth");
clearAuth();
});
const ws = createWsClient();
let dispatcherCleanup: (() => void) | null = null;
let connectedOverlay: ConnectedOverlayControl | null = null;
@@ -44,6 +56,8 @@ function renderPage(pageId: "connect" | "main"): void {
// Shared helper for post-auth WS connect + overlay flow
function wirePostAuth(host: string, token: string, username: string): void {
api.setConfig({ token });
// Store token in authStore so the dispatcher's auth_ok handler has it
authStore.setState((prev) => ({ ...prev, token }));
ws.connect({ host, token });
dispatcherCleanup = wireDispatcher(ws);
+195 -11
View File
@@ -20,8 +20,15 @@ import { createServerBanner } from "@components/ServerBanner";
import type { ServerBannerControl } from "@components/ServerBanner";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import { createQuickSwitcher } from "@components/QuickSwitcher";
import { createInviteManager } from "@components/InviteManager";
import type { InviteItem } from "@components/InviteManager";
import type { InviteResponse } from "@lib/types";
import { createToastContainer } from "@components/Toast";
import type { ToastContainer } from "@components/Toast";
import { createPinnedMessages } from "@components/PinnedMessages";
import type { PinnedMessage } from "@components/PinnedMessages";
import { authStore, clearAuth } from "@stores/auth.store";
import { closeSettings } from "@stores/ui.store";
import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store";
import { channelsStore, getActiveChannel, setActiveChannel } from "@stores/channels.store";
import {
voiceStore,
@@ -82,6 +89,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Abort controller for channel-scoped async operations (e.g. message fetch)
let channelAbort: AbortController | null = null;
// Toast container for user-facing error feedback
let toast: ToastContainer | null = null;
// Pinned panel toggle — assigned inside mount(), called from buildChatHeader()
let togglePinnedPanel: () => Promise<void> = async () => {};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -104,6 +117,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
} catch (err) {
if (!signal.aborted) {
log.error("Failed to load messages", { channelId, error: String(err) });
toast?.show("Failed to load messages", "error");
}
}
}
@@ -125,6 +139,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
} catch (err) {
if (!signal.aborted) {
log.error("Failed to load older messages", { channelId, error: String(err) });
toast?.show("Failed to load older messages", "error");
}
}
}
@@ -134,13 +149,21 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// ---------------------------------------------------------------------------
function buildChatHeader(): HTMLDivElement {
const header = createElement("div", { class: "chat-header" });
const header = createElement("div", { class: "chat-header", "data-testid": "chat-header" });
const hash = createElement("span", { class: "ch-hash" }, "#");
chatHeaderName = createElement("span", { class: "ch-name" }, "general");
chatHeaderName = createElement("span", { class: "ch-name", "data-testid": "chat-header-name" }, "general");
const divider = createElement("div", { class: "ch-divider" });
chatHeaderTopic = createElement("span", { class: "ch-topic" }, "");
const tools = createElement("div", { class: "ch-tools" });
const pinBtn = createElement("button", {
type: "button",
class: "pin-btn",
title: "Pins",
"aria-label": "Pins",
"data-testid": "pin-btn",
}, "\uD83D\uDCCC");
pinBtn.addEventListener("click", () => { void togglePinnedPanel(); });
const searchInput = createElement("input", {
class: "search-input",
type: "text",
@@ -149,8 +172,10 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
const membersToggle = createElement("button", {
type: "button",
"aria-label": "Toggle member list",
"data-testid": "members-toggle",
}, "\uD83D\uDC65");
appendChildren(tools, searchInput, membersToggle);
membersToggle.addEventListener("click", () => toggleMemberList());
appendChildren(tools, searchInput, pinBtn, membersToggle);
appendChildren(header, hash, chatHeaderName, divider, chatHeaderTopic, tools);
return header;
@@ -163,11 +188,13 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
function mountChannelComponents(channelId: number, channelName: string): void {
// Skip if already mounted for this channel
if (currentChannelId === channelId) return;
currentChannelId = channelId;
// Tear down previous instances
destroyChannelComponents();
// Set after destroy (which resets currentChannelId to null)
currentChannelId = channelId;
// New abort controller for this channel's async work
channelAbort = new AbortController();
const signal = channelAbort.signal;
@@ -234,6 +261,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
channelId,
channelName,
onSend: (content: string, replyTo: number | null) => {
if (ws.getState() !== "connected") {
log.warn("Cannot send message: not connected");
toast?.show("Not connected — message not sent", "error");
return;
}
ws.send({
type: "chat_send",
payload: {
@@ -342,7 +374,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
);
// --- Main .app row ---
const app = createElement("div", { class: "app" });
const app = createElement("div", { class: "app", "data-testid": "app-layout" });
// Server strip
const serverStripSlot = createElement("div", {});
@@ -351,7 +383,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
children.push(serverStrip);
// Channel sidebar (composed: sidebar + voice widget + user bar)
const sidebarWrapper = createElement("div", { class: "channel-sidebar" });
const sidebarWrapper = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" });
const channelSidebarSlot = createElement("div", {});
const channelSidebar = createChannelSidebar();
@@ -366,6 +398,19 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}
}
// Invite button in sidebar header
const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header");
if (sidebarHeader !== null) {
const inviteBtn = createElement("button", {
class: "invite-btn",
title: "Invite",
}, "Invite");
inviteBtn.addEventListener("click", () => {
void openInviteManager();
});
sidebarHeader.appendChild(inviteBtn);
}
// Voice widget (hidden when not in voice)
const voiceWidgetSlot = createElement("div", {});
const voiceWidget = createVoiceWidget({
@@ -404,12 +449,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
sidebarWrapper.appendChild(userBarSlot);
// Chat area
const chatArea = createElement("div", { class: "chat-area" });
const chatArea = createElement("div", { class: "chat-area", "data-testid": "chat-area" });
chatArea.appendChild(buildChatHeader());
messagesSlot = createElement("div", { class: "messages-slot" });
typingSlot = createElement("div", { class: "typing-slot" });
inputSlot = createElement("div", { class: "input-slot" });
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" });
appendChildren(chatArea, messagesSlot, typingSlot, inputSlot);
// Member list
@@ -418,6 +463,15 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
memberList.mount(memberListSlot);
children.push(memberList);
// Wire member list visibility to uiStore
const memberListEl = memberListSlot.querySelector(".member-list");
const unsubMemberList = uiStore.subscribe((state) => {
if (memberListEl !== null) {
memberListEl.classList.toggle("hidden", !state.memberListVisible);
}
});
unsubscribers.push(unsubMemberList);
appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot);
root.appendChild(app);
@@ -469,6 +523,136 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
closeQuickSwitcher();
});
// Invite manager overlay
let inviteManager: MountableComponent | null = null;
function closeInviteManager(): void {
if (inviteManager !== null) {
inviteManager.destroy?.();
inviteManager = null;
}
}
function mapInviteResponse(r: InviteResponse): InviteItem {
// Server may include extra fields (e.g. created_by) beyond the typed response
const extra = r as unknown as Record<string, unknown>;
const createdBy = typeof extra["created_by"] === "object"
&& extra["created_by"] !== null
? (extra["created_by"] as { username?: string }).username ?? "unknown"
: "unknown";
const uses = r.use_count
?? (typeof extra["uses"] === "number" ? (extra["uses"] as number) : 0);
return {
code: r.code,
createdBy,
createdAt: r.expires_at ?? "",
uses,
maxUses: r.max_uses,
expiresAt: r.expires_at,
};
}
async function openInviteManager(): Promise<void> {
if (inviteManager !== null || root === null) return;
try {
const raw = await api.getInvites();
const invites = raw.map(mapInviteResponse);
inviteManager = createInviteManager({
invites,
onCreateInvite: async () => {
const created = await api.createInvite({});
return mapInviteResponse(created);
},
onRevokeInvite: async (code: string) => {
const raw2 = await api.getInvites();
const match = raw2.find((i) => i.code === code);
if (match !== undefined) {
await api.revokeInvite(match.id);
}
},
onCopyLink: (code: string) => {
void navigator.clipboard.writeText(code);
},
onClose: closeInviteManager,
});
if (root !== null) {
inviteManager.mount(root);
}
} catch (err) {
log.error("Failed to open invite manager", { error: String(err) });
toast?.show("Failed to load invites", "error");
}
}
unsubscribers.push(() => {
closeInviteManager();
});
// Pinned messages panel
let pinnedPanel: MountableComponent | null = null;
function closePinnedPanel(): void {
if (pinnedPanel !== null) {
pinnedPanel.destroy?.();
pinnedPanel = null;
}
}
function mapToPinnedMessage(msg: {
readonly id: number;
readonly user: { readonly username: string };
readonly content: string;
readonly created_at?: string;
readonly timestamp?: string;
}): PinnedMessage {
return {
id: msg.id,
author: msg.user.username,
content: msg.content,
timestamp: msg.created_at ?? msg.timestamp ?? "",
};
}
togglePinnedPanel = async (): Promise<void> => {
if (pinnedPanel !== null) {
closePinnedPanel();
return;
}
if (root === null || currentChannelId === null) return;
const channelId = currentChannelId;
try {
const resp = await api.getPins(channelId);
const pins = resp.messages.map(mapToPinnedMessage);
pinnedPanel = createPinnedMessages({
channelId,
pinnedMessages: pins,
onJumpToMessage: (_msgId: number) => {
closePinnedPanel();
},
onUnpin: (msgId: number) => {
void api.unpinMessage(channelId, msgId);
closePinnedPanel();
},
onClose: closePinnedPanel,
});
if (root !== null) {
pinnedPanel.mount(root);
}
} catch (err) {
log.error("Failed to load pinned messages", { error: String(err) });
toast?.show("Failed to load pinned messages", "error");
}
};
unsubscribers.push(() => {
closePinnedPanel();
});
// Toast container for error feedback
toast = createToastContainer();
toast.mount(root);
children.push(toast);
container.appendChild(root);
// --- Subscribe to channel changes ---
@@ -303,3 +303,8 @@ export function getChannelMessages(channelId: number): readonly Message[] {
export function isChannelLoaded(channelId: number): boolean {
return messagesStore.select((s) => s.loadedChannels.has(channelId));
}
/** Check whether a channel has more older messages to fetch. */
export function hasMoreMessages(channelId: number): boolean {
return messagesStore.select((s) => s.hasMore.get(channelId) ?? false);
}
+1 -1
View File
@@ -90,7 +90,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI
return nil, fmt.Errorf("GetAttachmentsByMessageIDs scan: %w", scanErr)
}
ai.ID = id
ai.URL = "/api/files/" + id
ai.URL = "/api/v1/files/" + id
result[msgID] = append(result[msgID], ai)
}
return result, nil
+44 -15
View File
@@ -118,7 +118,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
// Rate limit.
ratKey := fmt.Sprintf("chat:%d", c.userID)
if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many messages"))
c.sendMsg(buildRateLimitError("too many messages", chatWindow.Seconds()))
return
}
@@ -146,8 +146,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
}
// Permission check.
if !h.hasChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing SEND_MESSAGES permission"))
if !h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES") {
return
}
@@ -171,6 +170,13 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
return
}
// Check attachment permission before persisting anything.
if len(p.Attachments) > 0 {
if !h.requireChannelPerm(c, channelID, permissions.AttachFiles, "ATTACH_FILES") {
return
}
}
// Persist message.
msgID, err := h.db.CreateMessage(channelID, c.userID, content, p.ReplyTo)
if err != nil {
@@ -182,17 +188,15 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
// Link attachments if provided.
var attachments []map[string]any
if len(p.Attachments) > 0 {
if !h.hasChannelPerm(c, channelID, permissions.AttachFiles) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing ATTACH_FILES permission"))
return
}
linked, linkErr := h.db.LinkAttachmentsToMessage(msgID, p.Attachments)
if linkErr != nil {
slog.Error("ws handleChatSend LinkAttachments", "err", linkErr)
}
if linked > 0 {
attMap, attErr := h.db.GetAttachmentsByMessageIDs([]int64{msgID})
if attErr == nil {
if attErr != nil {
slog.Error("ws handleChatSend GetAttachments", "err", attErr)
} else {
for _, ai := range attMap[msgID] {
attachments = append(attachments, map[string]any{
"id": ai.ID,
@@ -227,7 +231,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp))
// Broadcast to channel.
broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, content, msg.Timestamp, p.ReplyTo, attachments)
broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, c.roleName, content, msg.Timestamp, p.ReplyTo, attachments)
h.BroadcastToChannel(channelID, broadcast)
}
@@ -310,7 +314,7 @@ func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) {
func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
ratKey := fmt.Sprintf("reaction:%d", c.userID)
if !h.limiter.Allow(ratKey, reactionRateLimit, reactionWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many reactions"))
c.sendMsg(buildRateLimitError("too many reactions", reactionWindow.Seconds()))
return
}
@@ -335,6 +339,13 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji too long"))
return
}
// Reject control characters (U+0000U+001F, U+007F) to prevent injection.
for _, r := range p.Emoji {
if r < 0x20 || r == 0x7F {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji contains invalid characters"))
return
}
}
msg, err := h.db.GetMessage(msgID)
if err != nil || msg == nil {
@@ -342,8 +353,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
return
}
if !h.hasChannelPerm(c, msg.ChannelID, permissions.AddReactions) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing ADD_REACTIONS permission"))
if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") {
return
}
@@ -388,7 +398,7 @@ func (h *Hub) handleTyping(c *Client, payload json.RawMessage) {
func (h *Hub) handlePresence(c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("presence:%d", c.userID)
if !h.limiter.Allow(ratKey, presenceRateLimit, presenceWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many presence updates"))
c.sendMsg(buildRateLimitError("too many presence updates", presenceWindow.Seconds()))
return
}
@@ -434,6 +444,17 @@ func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool {
return effective&perm == perm
}
// requireChannelPerm checks whether the client has the given permission on the
// channel. If not, it sends a FORBIDDEN error to the client and returns false.
// The permLabel should be the human-readable permission name (e.g. "SEND_MESSAGES").
func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLabel string) bool {
if h.hasChannelPerm(c, channelID, perm) {
return true
}
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing "+permLabel+" permission"))
return false
}
// broadcastExclude sends msg to all channel members except excludeUserID.
func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) {
h.mu.RLock()
@@ -454,6 +475,7 @@ func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) {
// handleChannelFocus sets which channel the client is currently viewing,
// so channel-scoped broadcasts (chat messages, typing) reach them.
// Also updates read_states so unread counts decrease when the user views a channel.
func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) {
chID, err := parseChannelID(payload)
if err != nil || chID <= 0 {
@@ -461,12 +483,19 @@ func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) {
}
// Permission check: user must have READ_MESSAGES on the target channel.
if !h.hasChannelPerm(c, chID, permissions.ReadMessages) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "no permission to view this channel"))
if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") {
return
}
c.mu.Lock()
c.channelID = chID
c.mu.Unlock()
// Mark channel as read by updating read_states to the latest message.
latestID, latestErr := h.db.GetLatestMessageID(chID)
if latestErr == nil && latestID > 0 {
if rsErr := h.db.UpdateReadState(c.userID, chID, latestID); rsErr != nil {
slog.Warn("handleChannelFocus UpdateReadState", "err", rsErr, "user_id", c.userID, "channel_id", chID)
}
}
}
+73
View File
@@ -9,6 +9,7 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/ws"
)
@@ -36,6 +37,16 @@ CREATE TABLE IF NOT EXISTS audit_log (
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
stored_as TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)...)
func openHandlerDB(t *testing.T) *db.DB {
@@ -468,6 +479,68 @@ func TestSlowMode_DifferentChannels_IndependentWindows(t *testing.T) {
}
}
// ─── Attachment permission ordering ───────────────────────────────────────────
// chatSendMsgWithAttachments constructs a raw chat_send envelope with attachment IDs.
func chatSendMsgWithAttachments(channelID int64, content string, attachmentIDs []string) []byte {
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": map[string]any{
"channel_id": channelID,
"content": content,
"attachments": attachmentIDs,
},
})
return raw
}
// denyAttachOnChannel inserts a channel_override that denies ATTACH_FILES.
func denyAttachOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) {
t.Helper()
_, err := database.Exec(
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`,
channelID, roleID, permissions.AttachFiles,
)
if err != nil {
t.Fatalf("denyAttachOnChannel: %v", err)
}
}
// TestChatSend_AttachmentsDeniedNoMessageCreated verifies that when ATTACH_FILES
// is denied, the message is NOT persisted (permission check before CreateMessage).
func TestChatSend_AttachmentsDeniedNoMessageCreated(t *testing.T) {
hub, database := newHandlerHub(t)
user := seedMemberUser(t, database, "attach-denied")
chID := seedTestChannel(t, database, "attach-chan")
// Deny ATTACH_FILES for Member role on this channel.
denyAttachOnChannel(t, database, chID, permissions.MemberRoleID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Send a message with attachments — should be rejected before persisting.
hub.HandleMessageForTest(c, chatSendMsgWithAttachments(chID, "has attachment", []string{"fake-attach-id"}))
time.Sleep(50 * time.Millisecond)
code := receiveErrorCode(send, 300*time.Millisecond)
if code != "FORBIDDEN" {
t.Errorf("expected FORBIDDEN for denied ATTACH_FILES, got %q", code)
}
// Verify no message was persisted in the database.
var count int
err := database.QueryRow("SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count)
if err != nil {
t.Fatalf("count query: %v", err)
}
if count != 0 {
t.Errorf("expected 0 messages in DB (permission denied before persist), got %d", count)
}
}
// TestSlowMode_ErrorMessageContainsSlowModeDuration verifies the error payload
// describes the slow mode duration.
func TestSlowMode_ErrorMessageContainsSlowModeDuration(t *testing.T) {
+100 -12
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"net/http"
"sync"
"time"
"nhooyr.io/websocket"
@@ -16,6 +17,59 @@ import (
const authDeadline = 10 * time.Second
const writeTimeout = 10 * time.Second
const settingsCacheTTL = 30 * time.Second
// cachedSettings holds server_name and motd to avoid per-connection DB queries.
var (
settingsMu sync.RWMutex
settingsName = "OwnCord Server"
settingsMotd = "Welcome!"
settingsLastUpdate time.Time
settingsDB *db.DB
)
// InitSettingsCache sets the DB reference for the settings cache.
// Must be called once during server startup.
func InitSettingsCache(database *db.DB) {
settingsMu.Lock()
defer settingsMu.Unlock()
settingsDB = database
refreshSettingsLocked()
}
func refreshSettingsLocked() {
if settingsDB == nil {
return
}
var name, motd string
if err := settingsDB.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err == nil {
settingsName = name
}
if err := settingsDB.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd); err == nil {
settingsMotd = motd
}
settingsLastUpdate = time.Now()
}
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
func getCachedSettings() (string, string) {
settingsMu.RLock()
if time.Since(settingsLastUpdate) < settingsCacheTTL {
name, motd := settingsName, settingsMotd
settingsMu.RUnlock()
return name, motd
}
settingsMu.RUnlock()
settingsMu.Lock()
defer settingsMu.Unlock()
// Double-check after acquiring write lock.
if time.Since(settingsLastUpdate) < settingsCacheTTL {
return settingsName, settingsMotd
}
refreshSettingsLocked()
return settingsName, settingsMotd
}
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
// then drives the client's read/write loops.
@@ -32,6 +86,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
slog.Warn("ws upgrade failed", "err", err)
return
}
conn.SetReadLimit(1 << 20) // 1 MB — match client-side limit
user, tokenHash, err := authenticateConn(conn, database)
if err != nil {
@@ -43,11 +98,12 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
c := newClient(hub, conn, user, tokenHash)
hub.Register(c)
// Look up role name for protocol-compliant payloads.
// Look up role name for protocol-compliant payloads and cache on client.
roleName := "member"
if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil {
roleName = role.Name
}
c.roleName = roleName
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
_ = database.LogAudit(user.ID, "ws_connect", "user", user.ID,
@@ -59,8 +115,8 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
// Send auth_ok followed by the ready payload.
ctx := r.Context()
_ = conn.Write(ctx, websocket.MessageText, buildAuthOK(database, user, roleName))
if ready, readyErr := buildReady(database); readyErr == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthOK(user, roleName))
if ready, readyErr := buildReady(database, user.ID); readyErr == nil {
_ = conn.Write(ctx, websocket.MessageText, ready)
}
@@ -175,17 +231,15 @@ func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string,
}
// buildAuthOK constructs the auth_ok server→client message.
func buildAuthOK(database *db.DB, user *db.User, roleName string) []byte {
serverName := "OwnCord Server"
motd := "Welcome!"
_ = database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&serverName)
_ = database.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd)
// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status).
func buildAuthOK(user *db.User, roleName string) []byte {
var avatarVal any
if user.Avatar != nil {
avatarVal = *user.Avatar
}
serverName, motd := getCachedSettings()
return buildJSON(map[string]any{
"type": "auth_ok",
"payload": map[string]any{
@@ -193,7 +247,6 @@ func buildAuthOK(database *db.DB, user *db.User, roleName string) []byte {
"id": user.ID,
"username": user.Username,
"avatar": avatarVal,
"status": user.Status,
"role": roleName,
},
"server_name": serverName,
@@ -203,7 +256,9 @@ func buildAuthOK(database *db.DB, user *db.User, roleName string) []byte {
}
// buildReady constructs the ready server→client message.
func buildReady(database *db.DB) ([]byte, error) {
// Per PROTOCOL.md, channels include unread_count and last_message_id per user,
// and only protocol-specified fields (no slow_mode, archived, voice_* extras).
func buildReady(database *db.DB, userID int64) ([]byte, error) {
channels, err := database.ListChannels()
if err != nil {
return nil, fmt.Errorf("buildReady ListChannels: %w", err)
@@ -219,6 +274,35 @@ func buildReady(database *db.DB) ([]byte, error) {
members = []db.MemberSummary{}
}
// Per-user unread counts.
unreadMap, err := database.GetChannelUnreadCounts(userID)
if err != nil {
slog.Warn("buildReady GetChannelUnreadCounts", "err", err)
unreadMap = map[int64]db.ChannelUnread{}
}
// Build protocol-compliant channel objects (strip extra fields).
channelPayloads := make([]map[string]any, 0, len(channels))
for _, ch := range channels {
entry := map[string]any{
"id": ch.ID,
"name": ch.Name,
"type": ch.Type,
"category": ch.Category,
"position": ch.Position,
}
if ch.Type == "text" {
if u, ok := unreadMap[ch.ID]; ok {
entry["unread_count"] = u.UnreadCount
entry["last_message_id"] = u.LastMessageID
} else {
entry["unread_count"] = 0
entry["last_message_id"] = 0
}
}
channelPayloads = append(channelPayloads, entry)
}
// Collect all active voice states across every voice channel.
voiceStates, err := collectAllVoiceStates(database, channels)
if err != nil {
@@ -227,13 +311,17 @@ func buildReady(database *db.DB) ([]byte, error) {
voiceStates = []db.VoiceState{}
}
serverName, motd := getCachedSettings()
return buildJSON(map[string]any{
"type": "ready",
"payload": map[string]any{
"channels": channels,
"channels": channelPayloads,
"members": members,
"voice_states": voiceStates,
"roles": roles,
"server_name": serverName,
"motd": motd,
},
}), nil
}
+9 -13
View File
@@ -71,8 +71,7 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
return
}
if !h.hasChannelPerm(c, channelID, permissions.ConnectVoice) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing CONNECT_VOICE permission"))
if !h.requireChannelPerm(c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") {
return
}
@@ -279,7 +278,7 @@ func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) {
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(buildErrorMsg("RATE_LIMITED", "too many camera toggles"))
c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds()))
return
}
@@ -289,8 +288,7 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
return
}
if !h.hasChannelPerm(c, voiceChID, permissions.UseVideo) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_VIDEO permission"))
if !h.requireChannelPerm(c, voiceChID, permissions.UseVideo, "USE_VIDEO") {
return
}
@@ -320,7 +318,7 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
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(buildErrorMsg("RATE_LIMITED", "too many screenshare toggles"))
c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds()))
return
}
@@ -330,8 +328,7 @@ func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) {
return
}
if !h.hasChannelPerm(c, voiceChID, permissions.ShareScreen) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing SHARE_SCREEN permission"))
if !h.requireChannelPerm(c, voiceChID, permissions.ShareScreen, "SHARE_SCREEN") {
return
}
@@ -358,7 +355,7 @@ func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) {
func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_signal:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages"))
c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds()))
return
}
@@ -415,7 +412,7 @@ func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) {
func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_signal:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages"))
c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds()))
return
}
@@ -454,7 +451,7 @@ func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) {
func (h *Hub) handleVoiceICE(c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_signal:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages"))
c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds()))
return
}
@@ -491,8 +488,7 @@ func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) {
return
}
if !h.hasChannelPerm(c, 0, permissions.UseSoundboard) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_SOUNDBOARD permission"))
if !h.requireChannelPerm(c, 0, permissions.UseSoundboard, "USE_SOUNDBOARD") {
return
}
+192
View File
@@ -0,0 +1,192 @@
# TODOS
Items deferred from CEO plan review of `tauri-migration` branch
(2026-03-16). Ordered by priority.
## P1 — Must fix soon
### ~~1. Attachment permission ordering bug~~ DONE
Moved `ATTACH_FILES` permission check before `CreateMessage()`
in `Server/ws/handlers.go`. Added test
`TestChatSend_AttachmentsDeniedNoMessageCreated`.
---
### ~~2. Hardcoded `/api/files/` URL~~ DONE
Changed to `/api/v1/files/` in
`Server/db/attachment_queries.go:93`.
---
### ~~3. Missing `onUnauthorized` handler~~ DONE
Wired `api.onUnauthorized` callback at creation in `main.ts`
to call `clearAuth()`, which triggers navigation back to
connect page via existing authStore subscription.
---
## P2 — Should fix next
### ~~4. Silent API failure toasts~~ DONE
Added `ToastContainer` to `MainPage.ts`. Wired toast to 5
catch blocks: `loadMessages`, `loadOlderMessages`,
`openInviteManager`, `togglePinnedPanel`, and the connectivity
guard on message send.
---
### ~~5. Message send connectivity guard + debounce~~ DONE
Added `ws.getState() !== "connected"` guard in `MainPage.ts`
`onSend` callback with toast feedback. Added 200ms send
debounce in `MessageInput.ts` to prevent double-click
duplicates.
---
### ~~6. WebSocket frame size limit on server~~ DONE
Added `conn.SetReadLimit(1 << 20)` (1MB) in
`Server/ws/serve.go` after WebSocket accept.
---
### ~~7. Wrap dispatcher store operations in try/catch~~ N/A
Already handled: `ws.ts` dispatch function wraps every
listener call in try/catch with `log.error`. No additional
wrapping needed in `dispatcher.ts`.
---
### ~~8. `GetAttachmentsByMessageIDs` error silently swallowed~~ DONE
Added `slog.Error("ws handleChatSend GetAttachments", ...)` in
`Server/ws/handlers.go` inside the error check.
---
## P3 — Tech debt / polish
### 9. Split oversized files
**What:** Break these files into smaller modules:
- `Server/admin/api.go` (788 lines) into
`handlers_users.go`, `handlers_channels.go`,
`handlers_backup.go`
- `Client/tauri-client/src/pages/MainPage.ts` (683 lines)
into extracted helpers
- `Client/tauri-client/src/components/SettingsOverlay.ts`
(670 lines) into per-tab components
**Why:** All exceed the 400-line target. Larger files are
harder to navigate, review, and test. They will grow as
features are added.
**Context:** Pure refactor, no behavior change. Best done
when no other PRs touch these files.
**Effort:** M per file (3 files)
---
### ~~10. Extract permission check helper (server DRY)~~ DONE
Created `requireChannelPerm(c, channelID, perm, permLabel)`
helper in `handlers.go`. Replaced 8 instances across
`handlers.go` and `voice_handlers.go`.
---
### 11. Virtual scrolling for MessageList
**What:** Implement DOM windowing/recycling in
`MessageList.ts` so only visible messages (plus buffer) are
in the DOM.
**Why:** Channels with 10K+ messages will cause initial
render hang and high memory usage.
**Context:** Phase 5 of MIGRATION-PLAN.md mentions this.
Consider a lightweight virtual scroll library or custom
implementation using IntersectionObserver.
**Effort:** L
---
### 12. WS message render batching
**What:** Batch store subscription callbacks using
`requestAnimationFrame` or `queueMicrotask` so 100 rapid
WS messages don't trigger 100 full re-renders.
**Why:** Burst activity (e.g., reconnect with backlog)
causes jank from unbatched DOM updates.
**Effort:** M
---
### 13. E2E test improvement plan (Phases 4-6)
**What:** Complete the remaining phases of the E2E
improvement plan:
- Phase 4: Strengthen assertions, fix quality
- Phase 5: Toast coverage
- Phase 6: Migrate all selectors to data-testid
**Why:** Phases 1-3 are complete. Remaining phases improve
test reliability and coverage.
**Context:** See `project_e2e_improvement_plan.md` in
Claude memory for full plan.
**Effort:** M (per phase)
**Depends on:** TODO #4 (toast wiring) for Phase 5 — DONE
---
## CLIENT-REVIEW.md findings
### ~~Auth token never set in authStore~~ DONE
Fixed in `main.ts:wirePostAuth` — store token in authStore
before WS connect so dispatcher's `auth_ok` handler has it.
---
### ~~WS connect hangs in "connecting" state~~ DONE
Fixed in `ws.ts` — set state to "disconnected" when Tauri
APIs are unavailable.
---
### ~~Server-driven voice disconnect doesn't clear currentChannelId~~ DONE
Fixed in `dispatcher.ts``voice_leave` handler now calls
`leaveVoiceChannel()` when the current user is removed.
---
### ~~Theme/font not applied on app start~~ DONE
Extracted `applyStoredAppearance()` from `SettingsOverlay.ts`
and call it at app startup in `main.ts`.
---
### ~~Infinite scroll throttle~~ DONE
Fixed in `MessageList.ts` — replaced fixed 500ms timeout
with store subscription that resets `loadingOlder` when
message count changes. Also checks `hasMoreMessages` before
triggering scroll load.