mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -234,6 +234,7 @@ export function createWsClient() {
|
||||
await ensureTauriApis();
|
||||
if (tauriInvoke === null) {
|
||||
log.error("Tauri APIs not available, cannot connect WebSocket");
|
||||
setState("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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+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"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user