fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)

- BUG-039: switchOutputDevice continues loop on partial failure instead of early return
- BUG-040: clearOnError() prevents stale callback after MainPage destroy
- BUG-041: voice store tests cover localCamera, localScreenshare, setLocalSpeaking
- BUG-042: auth store updateUser tests and UserBar mute/deafen callback tests
- BUG-043: switchInputDevice guards against no active WebRTC session
- BUG-044: replace synchronous confirm() with double-click-to-delete via toast
- BUG-045: isSafeUrl() blocks javascript: URLs in image attachment src
This commit is contained in:
jevb
2026-03-18 07:03:53 +01:00
parent dcea5bc0ab
commit 5140505704
7 changed files with 335 additions and 10 deletions
@@ -151,8 +151,17 @@ function isImageMime(mime: string): boolean {
return mime.startsWith("image/");
}
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function renderAttachment(att: Attachment): HTMLDivElement {
if (isImageMime(att.mime)) {
if (isImageMime(att.mime) && isSafeUrl(att.url)) {
const wrap = createElement("div", { class: "msg-image" });
const img = createElement("img", {
src: att.url,
+15 -2
View File
@@ -164,6 +164,11 @@ export function setOnError(cb: (message: string) => void): void {
onErrorCallback = cb;
}
/** Clear the error callback (call on component destroy to avoid stale refs). */
export function clearOnError(): void {
onErrorCallback = null;
}
/**
* Fetch ICE servers (TURN/STUN credentials) for WebRTC.
* Falls back to empty array on failure so voice still works on LAN.
@@ -321,6 +326,11 @@ export function setDeafened(deafened: boolean): void {
/** Switch the input (microphone) device on an active session. */
export async function switchInputDevice(deviceId: string): Promise<void> {
// Don't acquire microphone if there's no active voice session
if (webrtcService === null) {
log.debug("Skipping input device switch — no active voice session");
return;
}
if (audioManager === null) {
audioManager = createAudioManager();
}
@@ -363,17 +373,20 @@ export async function switchInputDevice(deviceId: string): Promise<void> {
/** Switch the output (speaker) device on an active session. */
export async function switchOutputDevice(deviceId: string): Promise<void> {
let hadError = false;
for (const el of audioElements.values()) {
if (typeof el.setSinkId === "function") {
try {
await el.setSinkId(deviceId);
} catch (err) {
log.error("Failed to set output device on audio element", err);
onErrorCallback?.("Failed to switch speaker");
return;
hadError = true;
}
}
}
if (hadError) {
onErrorCallback?.("Failed to switch some audio to new speaker");
}
log.info("Switched output device", { deviceId });
}
+23 -6
View File
@@ -39,6 +39,7 @@ import {
setDeafened as voiceSessionSetDeafened,
setWsClient,
setOnError as setVoiceOnError,
clearOnError as clearVoiceOnError,
} from "@lib/voiceSession";
import {
setMessages,
@@ -98,6 +99,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Track currently mounted channel to avoid redundant rebuilds
let currentChannelId: number | null = null;
// Pending delete confirmations (double-click to delete pattern)
const pendingDeletes = new Map<number, number>();
// Abort controller for channel-scoped async operations (e.g. message fetch)
let channelAbort: AbortController | null = null;
@@ -208,12 +212,19 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}
},
onDeleteClick: (msgId: number) => {
if (!confirm("Delete this message?")) return;
ws.send({
type: "chat_delete",
payload: { message_id: msgId },
});
toast?.show("Message deleted", "success");
if (pendingDeletes.has(msgId)) {
window.clearTimeout(pendingDeletes.get(msgId));
pendingDeletes.delete(msgId);
ws.send({
type: "chat_delete",
payload: { message_id: msgId },
});
toast?.show("Message deleted", "success");
} else {
toast?.show("Click delete again to confirm", "info");
const tid = window.setTimeout(() => pendingDeletes.delete(msgId), 5000);
pendingDeletes.set(msgId, tid);
}
},
onReactionClick: (msgId: number, emoji: string) => {
if (emoji === "") return;
@@ -301,6 +312,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}
function destroyChannelComponents(): void {
for (const tid of pendingDeletes.values()) {
window.clearTimeout(tid);
}
pendingDeletes.clear();
if (channelAbort !== null) {
channelAbort.abort();
channelAbort = null;
@@ -596,6 +612,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
function destroy(): void {
log.info("MainPage destroying");
clearVoiceOnError();
destroyChannelComponents();
for (const child of children) {
@@ -5,6 +5,7 @@ import {
clearAuth,
getToken,
getCurrentUser,
updateUser,
} from "../../src/stores/auth.store";
import type { UserWithRole } from "../../src/lib/types";
@@ -128,7 +129,46 @@ describe("auth store", () => {
});
});
// 5. getCurrentUser returns current user
// 5. updateUser patches user fields
describe("updateUser", () => {
it("updates username on authenticated user", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
updateUser({ username: "newname" });
expect(authStore.getState().user?.username).toBe("newname");
});
it("preserves other user fields when patching", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
updateUser({ username: "newname" });
const user = authStore.getState().user;
expect(user?.id).toBe(42);
expect(user?.avatar).toBe("avatar.png");
expect(user?.role).toBe("member");
});
it("is a no-op when user is null", () => {
updateUser({ username: "newname" });
expect(authStore.getState().user).toBeNull();
});
it("produces a new state object", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const before = authStore.getState();
updateUser({ username: "changed" });
expect(authStore.getState()).not.toBe(before);
});
it("produces a new user object (immutable)", () => {
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
const userBefore = authStore.getState().user;
updateUser({ avatar: "new-avatar.png" });
const userAfter = authStore.getState().user;
expect(userBefore).not.toBe(userAfter);
expect(userAfter?.avatar).toBe("new-avatar.png");
});
});
// 6. getCurrentUser returns current user
describe("getCurrentUser", () => {
it("returns null when unauthenticated", () => {
expect(getCurrentUser()).toBeNull();
@@ -101,6 +101,51 @@ describe("UserBar", () => {
expect(openSettings).toHaveBeenCalledOnce();
});
it("calls onMuteToggle when mute button clicked", () => {
setAuthState({ username: "alice" }, true);
const onMuteToggle = vi.fn();
comp = createUserBar({ onMuteToggle });
comp.mount(container);
const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement;
muteBtn.click();
expect(onMuteToggle).toHaveBeenCalledOnce();
});
it("calls onDeafenToggle when deafen button clicked", () => {
setAuthState({ username: "alice" }, true);
const onDeafenToggle = vi.fn();
comp = createUserBar({ onDeafenToggle });
comp.mount(container);
const deafenBtn = container.querySelector('[title="Deafen"]') as HTMLButtonElement;
deafenBtn.click();
expect(onDeafenToggle).toHaveBeenCalledOnce();
});
it("does not throw when mute/deafen clicked without callbacks", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement;
const deafenBtn = container.querySelector('[title="Deafen"]') as HTMLButtonElement;
expect(() => muteBtn.click()).not.toThrow();
expect(() => deafenBtn.click()).not.toThrow();
});
it("stops responding to clicks after destroy", () => {
setAuthState({ username: "alice" }, true);
const onMuteToggle = vi.fn();
comp = createUserBar({ onMuteToggle });
comp.mount(container);
const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement;
comp.destroy?.();
muteBtn.click();
expect(onMuteToggle).not.toHaveBeenCalled();
});
it("destroy removes DOM and unsubscribes", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
@@ -8,6 +8,9 @@ import {
leaveVoiceChannel,
setLocalMuted,
setLocalDeafened,
setLocalCamera,
setLocalScreenshare,
setLocalSpeaking,
getChannelVoiceUsers,
} from "../../src/stores/voice.store";
import type {
@@ -15,6 +18,7 @@ import type {
VoiceStatePayload,
VoiceLeavePayload,
} from "../../src/lib/types";
import { authStore } from "../../src/stores/auth.store";
function resetStore(): void {
voiceStore.setState(() => ({
@@ -23,6 +27,8 @@ function resetStore(): void {
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
}));
}
@@ -79,6 +85,14 @@ describe("voice store", () => {
it("has localDeafened false", () => {
expect(voiceStore.getState().localDeafened).toBe(false);
});
it("has localCamera false", () => {
expect(voiceStore.getState().localCamera).toBe(false);
});
it("has localScreenshare false", () => {
expect(voiceStore.getState().localScreenshare).toBe(false);
});
});
describe("setVoiceStates", () => {
@@ -228,6 +242,68 @@ describe("voice store", () => {
});
});
describe("setLocalCamera / setLocalScreenshare", () => {
it("setLocalCamera sets camera to true", () => {
setLocalCamera(true);
expect(voiceStore.getState().localCamera).toBe(true);
});
it("setLocalCamera sets camera to false", () => {
setLocalCamera(true);
setLocalCamera(false);
expect(voiceStore.getState().localCamera).toBe(false);
});
it("setLocalScreenshare sets screenshare to true", () => {
setLocalScreenshare(true);
expect(voiceStore.getState().localScreenshare).toBe(true);
});
it("setLocalScreenshare sets screenshare to false", () => {
setLocalScreenshare(true);
setLocalScreenshare(false);
expect(voiceStore.getState().localScreenshare).toBe(false);
});
});
describe("setLocalSpeaking", () => {
it("updates speaking state for current user in active channel", () => {
// Set up: current user (id=1) in channel 10
authStore.setState(() => ({
token: "t",
user: { id: 1, username: "me", avatar: "", role: "member" },
serverName: "s",
motd: "",
isAuthenticated: true,
}));
setVoiceStates([VOICE_STATE_1]);
joinVoiceChannel(10);
setLocalSpeaking(true);
const user = voiceStore.getState().voiceUsers.get(10)?.get(1);
expect(user?.speaking).toBe(true);
setLocalSpeaking(false);
const userAfter = voiceStore.getState().voiceUsers.get(10)?.get(1);
expect(userAfter?.speaking).toBe(false);
// Cleanup
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
});
it("is a no-op when not in a voice channel", () => {
const before = voiceStore.getState();
setLocalSpeaking(true);
expect(voiceStore.getState()).toBe(before);
});
});
describe("getChannelVoiceUsers", () => {
it("returns all voice users for a channel", () => {
setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]);
+125
View File
@@ -0,0 +1,125 @@
# Open Bugs
Bug tracker for the OwnCord project.
## Active
### Critical
(none)
### High
(none)
### Medium
(none)
## Resolved
- **BUG-039**: `switchOutputDevice` early return on partial failure — fixed 2026-03-18
- Replaced `return` with error tracking; all elements attempted before reporting
- **BUG-040**: Stale `onErrorCallback` after MainPage destroy — fixed 2026-03-18
- Added `clearOnError()` export; MainPage calls it on destroy to prevent stale refs
- **BUG-041**: Voice store `resetStore` missing new fields in tests — fixed 2026-03-18
- Added `localCamera`/`localScreenshare` to resetStore; tests for setLocalCamera,
setLocalScreenshare, setLocalSpeaking
- **BUG-042**: `updateUser` and UserBar option callbacks untested — fixed 2026-03-18
- Added updateUser tests to auth.store.test.ts; added mute/deafen callback tests
to user-bar.test.ts
- **BUG-043**: `switchInputDevice` triggers `getUserMedia` with no session — fixed 2026-03-18
- Added `webrtcService === null` guard; skips mic acquisition when not in voice
- **BUG-044**: `confirm()` blocks Tauri WebView renderer — fixed 2026-03-18
- Replaced synchronous `confirm()` with double-click-to-delete pattern using toast
- **BUG-045**: Image `att.url` not scheme-validated — fixed 2026-03-18
- Added `isSafeUrl()` check; only http/https URLs render as images
- **BUG-031**: VoiceAudioTab device selection not applied to WebRTC — fixed 2026-03-18
- Added `switchInputDevice`/`switchOutputDevice` to voiceSession; VoiceAudioTab
calls on change
- **BUG-032**: No WS handlers for channel_create/update/delete — closed 2026-03-18
- Handlers wired in dispatcher.ts:173-200; `wireDispatcher` called in main.ts:141
- **BUG-033**: No WS handlers for member_update/member_ban — closed 2026-03-18
- Handlers wired in dispatcher.ts:219-229; `wireDispatcher` called in main.ts:141
- **BUG-034**: InviteManager mutates state before API resolves — closed 2026-03-18
- Filter is inside `.then()` — only runs after promise resolves
- **BUG-035**: DmSidebar active highlight never updates — fixed 2026-03-18
- Click handler now removes `.active` from siblings and adds to clicked item
- **BUG-036**: WebRTC failure silently disconnects user — fixed 2026-03-18
- Added `setOnError` callback pattern; MainPage wires it to toast
- **BUG-026**: Image attachments render placeholder, not actual images — fixed 2026-03-18
- Replaced placeholder `<div>` with `<img src=att.url>` + error fallback
- **BUG-030**: Orphaned MessageActionsBar + ReactionBar components — fixed 2026-03-18
- Deleted dead code: both components and their tests (never imported anywhere)
- **BUG-024**: Reactions cannot be removed — fixed 2026-03-18
- Toggles `reaction_add`/`reaction_remove` based on `me` field per PROTOCOL.md
- **BUG-028**: Message delete fires with no confirmation — fixed 2026-03-18
- Added `confirm()` guard before sending `chat_delete`; success toast added
- **BUG-029**: Message edit sends without validation — fixed 2026-03-18
- Added empty-check, no-op detection, and toast feedback
- **BUG-037**: Reaction rate limit silently swallows clicks — fixed 2026-03-18
- Shows error toast when rate limited
- **BUG-038**: No toasts for chat edit/delete/reaction operations — fixed 2026-03-18
- Added success toasts for delete and edit; error toast for rate-limited reactions
- **BUG-021**: Camera toggle hardcoded to `enabled: false` — fixed 2026-03-18
- Added `localCamera` state to voice store; toggle reads actual state
- **BUG-022**: Screenshare handler completely empty — fixed 2026-03-18
- Added `localScreenshare` state; sends `voice_screenshare` WS message
- **BUG-023**: UserBar mute/deafen buttons have no event listeners — fixed 2026-03-18
- Added `UserBarOptions` interface; MainPage passes mute/deafen handlers
- **BUG-027**: VAD speaking state never sent to server — fixed 2026-03-18
- Wired `vadDetector.onSpeakingChange``setLocalSpeaking` in voice store
- **BUG-020**: Account settings do nothing — fixed 2026-03-18
- Wired `api.changePassword()` and `api.updateProfile()` into MainPage callbacks
- Added `updateUser()` to auth store for username sync after profile edit
- Added toast feedback for success/error on both operations
- **BUG-025**: Theme changes don't sync to uiStore — fixed 2026-03-18
- Added `setTheme(name)` call in AppearanceTab click handler
- Store now stays in sync with localStorage and applied CSS
- **BUG-001**: NilHub tests pass mockHub not nil — fixed 2026-03-18 (#12)
- Added nil hub tests for PatchUser ban and role change paths
- **BUG-002**: window-state.ts untyped `any` — fixed (already resolved) (#10)
- Code already uses proper types (`Record<string, unknown>`, `typeof import(...)`)
- No `any` or `getInvoke()` pattern found — was fixed in a prior refactor
- **BUG-003**: Hub double-close panic — fixed 2026-03-17 (issue #3)
- Added `sync.Once` guard on quit channel close
- **BUG-004**: golangci-lint version incompatibility — fixed 2026-03-17 (issue #4)
- Pinned compatible linter version in CI
- **BUG-005**: SearchMessages missing validation — fixed 2026-03-17 (issue #5)
- Added input length and channel access checks
- **BUG-006**: InviteManager unhandled rejections — fixed 2026-03-17 (issue #6)
- Wrapped async calls with proper error handling
- **BUG-007**: Test schema missing columns — fixed 2026-03-17 (issue #7)
- Synced test fixtures with production schema
- **BUG-008**: Capacity over-allocation in
getReactionsBatch — fixed 2026-03-17 (#9)
- Corrected slice capacity to match actual batch size
- **BUG-009**: golangci-lint violations blocking CI — fixed 2026-03-17 (issue #13)
- Resolved all outstanding lint errors
- **BUG-010**: buildReady() silent hang — fixed 2026-03-17 (T-038)
- Server now sends INTERNAL error to client on buildReady failure
- **BUG-011**: Banned user keeps chatting — fixed 2026-03-17 (T-044)
- Added ban check to periodic session validation in WS handler
- **BUG-012**: Reaction error DB leak — fixed 2026-03-17 (T-039)
- Sanitized error messages, raw DB errors logged server-side only
- **BUG-013**: WS proxy no connect timeout — fixed 2026-03-17 (T-046)
- Added 10s connect timeout to Rust WS proxy
- **BUG-014**: Channel delete stale view — fixed 2026-03-17 (T-045)
- Client auto-redirects to first text channel on active channel deletion
- **BUG-015**: Missing rate limits on chat_edit/chat_delete — fixed 2026-03-17 (#18)
- Added rate limiting to edit and delete message endpoints
- **BUG-016**: Cert mismatch event not handled — fixed 2026-03-17 (#19)
- TOFU flow now properly handles certificate mismatch events
- **BUG-017**: SHA-256 fingerprint validation incorrect — fixed 2026-03-17 (#20)
- Fixed fingerprint comparison logic in cert pinning
- **BUG-018**: Session+ban query N+1 — fixed 2026-03-17 (#21)
- Optimized with JOIN query instead of separate lookups
- **BUG-019**: Channel position sorting broken — fixed 2026-03-17 (#22)
- Channels now sort correctly by position field