mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: resolve 24 critical and high issues from full code & security review
CRITICAL (5): - Hub panic recovery now calls h.Stop() after 3 panics (ws/hub.go) - Ring buffer EventsSince returns non-nil empty slice for current seq (ws/ringbuffer.go) - PTT event listener stores unsubscribe handle to prevent leak (ptt.ts) - verifyTotp respects config.allowSelfSigned instead of hardcoding (api.ts) - ptt_listen_for_key uses spawn_blocking to avoid thread pool starvation (ptt.rs) HIGH - Server (13): - TOTP rate-limit checked after body decode; counters reset on success - TOTP enable returns 409 if already enabled (must disable first) - Global search pre-computes accessible channel IDs for FTS WHERE clause - DeleteAccount queries roles by name instead of hard-coded IDs - BackupToSafe uses absClean in VACUUM INTO - Voice camera slot uses atomic EnableCameraIfUnderLimit DB method - readPump snapshots voiceChID before unregister for TOCTOU safety - Voice join sets state after token send; rollback takes broadcast flag - Updater download uses probe pattern instead of overflow write - Webhook checks Authorization header before reading body - Storage.Save adds fsync and fixes double-close - Default WS origin denies cross-origin (was: accept all) HIGH - Client (6): - WS reconnect uses generation counter to discard stale events - AudioPipeline uses generation counter against stale worklet callbacks - Screenshare mute state preserved across reconnect (not full leave) - handleVoiceToken uses iterative loop instead of unbounded recursion - store.ts re-entrancy guard with pending update queue - Notification AudioContext cleaned up on logout Reviewed by 4 parallel agents across Server Core, Server Realtime, Client & Tauri, and Security. 55 total findings; 24 CRITICAL+HIGH fixed here, 31 MEDIUM+LOW tracked in vault backlog (T-265–T-295).
This commit is contained in:
@@ -76,27 +76,32 @@ pub fn ptt_get_key() -> i32 {
|
||||
/// Wait for the user to press any non-modifier key and return its VK code.
|
||||
/// Used by the keybind capture UI. Times out after 10 seconds and returns 0
|
||||
/// to avoid blocking a thread indefinitely if the user navigates away.
|
||||
/// Runs on a dedicated thread to avoid blocking the Tauri async thread pool.
|
||||
#[tauri::command]
|
||||
pub fn ptt_listen_for_key() -> i32 {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
pub async fn ptt_listen_for_key() -> i32 {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
for vk in 1..=254i32 {
|
||||
// Skip modifier keys
|
||||
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
|
||||
continue;
|
||||
}
|
||||
if is_key_down(vk) {
|
||||
// Wait for release (with its own timeout)
|
||||
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
while std::time::Instant::now() < deadline {
|
||||
for vk in 1..=254i32 {
|
||||
// Skip modifier keys
|
||||
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
|
||||
continue;
|
||||
}
|
||||
if is_key_down(vk) {
|
||||
// Wait for release (with its own timeout)
|
||||
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
return vk;
|
||||
}
|
||||
return vk;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
0 // timed out — no key pressed
|
||||
0 // timed out — no key pressed
|
||||
})
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -208,7 +208,8 @@ export class AudioElements {
|
||||
|
||||
// --- Cleanup ---
|
||||
|
||||
/** Remove all remote audio elements from the DOM and clear tracking maps. */
|
||||
/** Remove all remote audio elements from the DOM and clear tracking maps.
|
||||
* Preserves screenshare mute state so reconnecting tracks inherit user intent. */
|
||||
cleanupAllAudioElements(): void {
|
||||
for (const el of this.remoteMicAudioElements.values()) el.remove();
|
||||
this.remoteMicAudioElements.clear();
|
||||
@@ -216,6 +217,11 @@ export class AudioElements {
|
||||
for (const el of audioEls) el.remove();
|
||||
}
|
||||
this.screenshareAudioElements.clear();
|
||||
}
|
||||
|
||||
/** Full cleanup including screenshare mute state — used on intentional leave. */
|
||||
cleanupAllAudioElementsFull(): void {
|
||||
this.cleanupAllAudioElements();
|
||||
this.screenshareAudioMutedByUser.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ const log = createLogger("audioPipeline");
|
||||
export class AudioPipeline {
|
||||
private room: Room | null = null;
|
||||
|
||||
/** Monotonic counter incremented on teardown — used to discard stale async results. */
|
||||
private _pipelineGeneration = 0;
|
||||
|
||||
// Pipeline nodes
|
||||
private audioPipelineCtx: AudioContext | null = null;
|
||||
private audioPipelineGain: GainNode | null = null;
|
||||
@@ -140,6 +143,7 @@ export class AudioPipeline {
|
||||
|
||||
/** Tear down the audio pipeline and restore the original sender track. */
|
||||
teardownAudioPipeline(): void {
|
||||
this._pipelineGeneration++;
|
||||
this.stopVadPolling();
|
||||
|
||||
// Restore original mic track on the WebRTC sender
|
||||
@@ -222,10 +226,13 @@ export class AudioPipeline {
|
||||
const threshold = ((100 - sensitivity) / 100) * 0.10;
|
||||
|
||||
// Try AudioWorklet first
|
||||
const gen = this._pipelineGeneration;
|
||||
this.audioPipelineCtx.audioWorklet.addModule("/vad-worklet.js").then(() => {
|
||||
if (this.audioPipelineCtx === null) return; // Torn down while loading
|
||||
if (gen !== this._pipelineGeneration) return; // Torn down while loading
|
||||
if (this.audioPipelineCtx === null) return;
|
||||
this.startVadWorklet(threshold);
|
||||
}).catch((err) => {
|
||||
if (gen !== this._pipelineGeneration) return;
|
||||
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
|
||||
this.startVadFallback(threshold);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,13 @@ type PendingVoiceJoin = {
|
||||
readonly directUrl?: string;
|
||||
};
|
||||
|
||||
/** Read pendingJoin from an instance — bypasses TS control-flow narrowing
|
||||
* that incorrectly assumes the field is still null after an async interleave. */
|
||||
function getPendingJoin(session: LiveKitSession): PendingVoiceJoin | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TS narrowing workaround
|
||||
return (session as any).pendingJoin as PendingVoiceJoin | null;
|
||||
}
|
||||
|
||||
// --- LiveKitSession class ---
|
||||
|
||||
export class LiveKitSession {
|
||||
@@ -677,17 +684,112 @@ export class LiveKitSession {
|
||||
} finally {
|
||||
this.connecting = false;
|
||||
}
|
||||
// Dispatch pending join *after* the try/finally so that a throw inside
|
||||
// the recursive call doesn't interfere with the outer finally's flag reset.
|
||||
const pendingJoin = this.pendingJoin;
|
||||
// Drain pending joins iteratively to avoid unbounded recursion when
|
||||
// rapid channel switches queue multiple requests.
|
||||
let pendingJoin = this.pendingJoin;
|
||||
this.pendingJoin = null;
|
||||
if (pendingJoin !== null) {
|
||||
await this.handleVoiceToken(
|
||||
pendingJoin.token,
|
||||
pendingJoin.url,
|
||||
pendingJoin.channelId,
|
||||
pendingJoin.directUrl,
|
||||
);
|
||||
while (pendingJoin !== null) {
|
||||
const { token: pToken, url: pUrl, channelId: pChannelId, directUrl: pDirectUrl } = pendingJoin;
|
||||
// Re-enter the connect logic for the queued join (non-recursive).
|
||||
if (this.room !== null && this.currentChannelId === pChannelId
|
||||
&& this.room.state === "connected") {
|
||||
this.handleVoiceTokenRefresh(pToken);
|
||||
pendingJoin = this.pendingJoin;
|
||||
this.pendingJoin = null;
|
||||
continue;
|
||||
}
|
||||
if (this.room !== null) this.leaveVoice(false);
|
||||
this.connecting = true;
|
||||
let pResolvedUrl = "";
|
||||
try {
|
||||
this.room = this.createRoom();
|
||||
this.syncModuleRooms();
|
||||
pResolvedUrl = await this.resolveLiveKitUrl(pUrl, pDirectUrl);
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await this.room.connect(pResolvedUrl, pToken);
|
||||
// Re-read pendingJoin: it may have been set by a concurrent
|
||||
// handleVoiceToken call during the await above. TS narrows
|
||||
// this.pendingJoin to null from the assignment before the loop,
|
||||
// but async interleaving can change it — read via helper to bypass.
|
||||
const queuedJoin = getPendingJoin(this);
|
||||
if (queuedJoin !== null
|
||||
&& (queuedJoin.token !== pToken
|
||||
|| queuedJoin.url !== pUrl
|
||||
|| queuedJoin.channelId !== pChannelId
|
||||
|| queuedJoin.directUrl !== pDirectUrl)) {
|
||||
log.info("Discarding stale voice join in favor of queued request", {
|
||||
channelId: pChannelId,
|
||||
queuedChannelId: queuedJoin.channelId,
|
||||
});
|
||||
if (this.room !== null) {
|
||||
const room = this.room;
|
||||
this.room = null;
|
||||
this.syncModuleRooms();
|
||||
room.removeAllListeners();
|
||||
room.disconnect().catch((err) => log.debug("Failed to disconnect room during cleanup", err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
} catch (connectErr) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
log.warn("LiveKit connect failed, retrying", { attempt, maxRetries: MAX_RETRIES, url: pResolvedUrl, error: connectErr });
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||
if (this.room === null) throw connectErr;
|
||||
this.room.removeAllListeners();
|
||||
this.room = this.createRoom();
|
||||
this.syncModuleRooms();
|
||||
} else {
|
||||
throw connectErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.room !== null) {
|
||||
log.info("Connected to LiveKit room", { channelId: pChannelId, url: pResolvedUrl });
|
||||
this.logIceConnectionInfo();
|
||||
this.currentChannelId = pChannelId;
|
||||
this.latestToken = pToken;
|
||||
this.lastUrl = pUrl;
|
||||
this.lastDirectUrl = pDirectUrl;
|
||||
this.room.startAudio().catch(() => {
|
||||
log.debug("Optimistic startAudio failed — waiting for user gesture");
|
||||
});
|
||||
await this.restoreLocalVoiceState("join");
|
||||
const savedInput = loadPref<string>("audioInputDevice", "");
|
||||
if (savedInput) {
|
||||
try {
|
||||
await this.room.switchActiveDevice("audioinput", savedInput);
|
||||
} catch (err) {
|
||||
log.warn("Saved input device unavailable, using default", err);
|
||||
}
|
||||
}
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
if (savedOutput) {
|
||||
try {
|
||||
await this.room.switchActiveDevice("audiooutput", savedOutput);
|
||||
} catch (err) {
|
||||
log.warn("Saved output device unavailable, using default", err);
|
||||
}
|
||||
}
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
this.reapplyMuteGain();
|
||||
this.startTokenRefreshTimer();
|
||||
log.info("Voice session active", { channelId: pChannelId });
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to connect to LiveKit", { url: pResolvedUrl, error: err });
|
||||
if (this.room !== null) {
|
||||
this.onErrorCallback?.("Failed to join voice — connection error");
|
||||
}
|
||||
this.leaveVoice(false);
|
||||
} finally {
|
||||
this.connecting = false;
|
||||
}
|
||||
pendingJoin = this.pendingJoin;
|
||||
this.pendingJoin = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,7 +831,8 @@ export class LiveKitSession {
|
||||
}
|
||||
// Remove orphaned remote audio elements (normally cleaned up by
|
||||
// TrackUnsubscribed, but may be missed during rapid reconnection).
|
||||
this._audioElements.cleanupAllAudioElements();
|
||||
// Full cleanup: also clears screenshare mute state on intentional leave.
|
||||
this._audioElements.cleanupAllAudioElementsFull();
|
||||
if (this.room !== null) {
|
||||
const r = this.room;
|
||||
this.room = null;
|
||||
|
||||
@@ -129,6 +129,14 @@ function flashTaskbar(): void {
|
||||
// Simple notification sound using Web Audio API
|
||||
let notifAudioCtx: AudioContext | null = null;
|
||||
|
||||
/** Close and release the notification AudioContext. Call on logout/cleanup. */
|
||||
export function cleanupNotificationAudio(): void {
|
||||
if (notifAudioCtx !== null) {
|
||||
notifAudioCtx.close().catch(() => {});
|
||||
notifAudioCtx = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Play a brief notification chime. */
|
||||
function playNotificationSound(): void {
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createLogger } from "./logger";
|
||||
const log = createLogger("ptt");
|
||||
|
||||
let listening = false;
|
||||
let pttUnsubscribe: (() => void) | null = null;
|
||||
|
||||
// Well-known virtual key code names for display
|
||||
const VK_NAMES: ReadonlyMap<number, string> = new Map([
|
||||
@@ -56,8 +57,12 @@ export async function initPtt(): Promise<void> {
|
||||
await invoke("ptt_set_key", { vkCode: vk });
|
||||
await invoke("ptt_start");
|
||||
|
||||
// Clean up previous listener if any
|
||||
pttUnsubscribe?.();
|
||||
pttUnsubscribe = null;
|
||||
|
||||
// Listen for press/release events
|
||||
await listen<boolean>("ptt-state", (event) => {
|
||||
const unsub = await listen<boolean>("ptt-state", (event) => {
|
||||
// Only toggle mute when in a voice channel
|
||||
const channelId = voiceStore.getState().currentChannelId;
|
||||
if (channelId === null) return;
|
||||
@@ -65,6 +70,7 @@ export async function initPtt(): Promise<void> {
|
||||
setMuted(!event.payload);
|
||||
log.debug(event.payload ? "PTT pressed — unmuted" : "PTT released — muted");
|
||||
});
|
||||
pttUnsubscribe = unsub;
|
||||
|
||||
listening = true;
|
||||
log.info("PTT started", { vk, name: vkName(vk) });
|
||||
@@ -78,6 +84,8 @@ export async function initPtt(): Promise<void> {
|
||||
export async function stopPtt(): Promise<void> {
|
||||
if (!listening) return;
|
||||
try {
|
||||
pttUnsubscribe?.();
|
||||
pttUnsubscribe = null;
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("ptt_stop");
|
||||
listening = false;
|
||||
|
||||
@@ -104,18 +104,41 @@ export function createStore<T>(initialState: T): Store<T> {
|
||||
const listeners: Set<(state: T) => void> = new Set();
|
||||
let notifyScheduled = false;
|
||||
|
||||
/** Re-entrancy guard: true while a subscriber notification is running. */
|
||||
let updating = false;
|
||||
/** Updaters queued by re-entrant setState calls during notification. */
|
||||
const pendingUpdates: Array<(prev: T) => T> = [];
|
||||
|
||||
function getState(): T {
|
||||
return state;
|
||||
}
|
||||
|
||||
function setState(updater: (prev: T) => T): void {
|
||||
if (updating) {
|
||||
// Re-entrant call from within a subscriber — queue for later.
|
||||
pendingUpdates.push(updater);
|
||||
return;
|
||||
}
|
||||
state = updater(state);
|
||||
if (!notifyScheduled) {
|
||||
notifyScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
notifyScheduled = false;
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
updating = true;
|
||||
try {
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
}
|
||||
// Drain any updates queued by re-entrant setState during notification.
|
||||
while (pendingUpdates.length > 0) {
|
||||
const queued = pendingUpdates.shift()!;
|
||||
state = queued(state);
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("ws");
|
||||
|
||||
/** Monotonic generation counter — incremented on each connect() to invalidate
|
||||
* stale event listeners from a previous connection attempt. */
|
||||
let wsGeneration = 0;
|
||||
|
||||
// Tauri IPC imports — resolved at runtime in Tauri context
|
||||
let tauriInvoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
|
||||
let tauriListen: ((event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void>) | null = null;
|
||||
@@ -257,14 +261,19 @@ export function createWsClient() {
|
||||
async function setupEventListeners(): Promise<void> {
|
||||
if (tauriListen === null) return;
|
||||
|
||||
// Capture generation so stale listeners from a previous connect() are no-ops.
|
||||
const gen = wsGeneration;
|
||||
|
||||
// Server messages
|
||||
const unsubMsg = await tauriListen("ws-message", (e) => {
|
||||
if (gen !== wsGeneration) return;
|
||||
handleMessage(e.payload as string);
|
||||
});
|
||||
eventUnsubs.push(unsubMsg);
|
||||
|
||||
// Connection state changes from Rust
|
||||
const unsubState = await tauriListen("ws-state", (e) => {
|
||||
if (gen !== wsGeneration) return;
|
||||
const rustState = e.payload as string;
|
||||
log.debug("Rust WS state", { state: rustState });
|
||||
|
||||
@@ -301,12 +310,14 @@ export function createWsClient() {
|
||||
|
||||
// Errors
|
||||
const unsubErr = await tauriListen("ws-error", (e) => {
|
||||
if (gen !== wsGeneration) return;
|
||||
log.warn("WebSocket error (proxy)", { error: e.payload });
|
||||
});
|
||||
eventUnsubs.push(unsubErr);
|
||||
|
||||
// TOFU certificate events
|
||||
const unsubCert = await tauriListen("cert-tofu", (e) => {
|
||||
if (gen !== wsGeneration) return;
|
||||
const raw = e.payload as CertTofuEvent;
|
||||
log.info("TOFU cert event", { host: raw.host, status: raw.status });
|
||||
|
||||
@@ -347,6 +358,7 @@ export function createWsClient() {
|
||||
}
|
||||
|
||||
async function connect(cfg: WsClientConfig): Promise<void> {
|
||||
wsGeneration++;
|
||||
config = cfg;
|
||||
intentionalClose = false;
|
||||
cancelReconnect();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createStore } from "@lib/store";
|
||||
import type { UserWithRole } from "@lib/types";
|
||||
import { resetVoiceStore } from "@stores/voice.store";
|
||||
import { leaveVoice } from "@lib/livekitSession";
|
||||
import { cleanupNotificationAudio } from "@lib/notifications";
|
||||
|
||||
export interface AuthState {
|
||||
readonly token: string | null;
|
||||
@@ -48,6 +49,7 @@ export function setAuth(
|
||||
export function clearAuth(): void {
|
||||
leaveVoice(false);
|
||||
resetVoiceStore();
|
||||
cleanupNotificationAudio();
|
||||
authStore.setState(() => ({ ...INITIAL_STATE }));
|
||||
}
|
||||
|
||||
|
||||
@@ -333,90 +333,111 @@ func handleSearch(database *db.DB) http.HandlerFunc {
|
||||
limit = v
|
||||
}
|
||||
|
||||
results, err := database.SearchMessages(q, channelID, limit)
|
||||
if err != nil {
|
||||
if isInvalidSearchQueryError(err) {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "BAD_REQUEST",
|
||||
Message: "invalid search query",
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("handleSearch SearchMessages", "err", err, "query", q)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "search failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
var results []db.MessageSearchResult
|
||||
|
||||
// Batch-fetch overrides and post-filter results by READ_MESSAGES.
|
||||
role, _ := r.Context().Value(RoleKey).(*db.Role)
|
||||
user, _ := r.Context().Value(UserKey).(*db.User)
|
||||
overrides := map[int64]db.ChannelOverride{}
|
||||
if role != nil && !permissions.HasAdmin(role.Permissions) {
|
||||
var oErr error
|
||||
overrides, oErr = database.GetAllChannelPermissionsForRole(role.ID)
|
||||
if oErr != nil {
|
||||
slog.Error("handleSearch GetAllChannelPermissionsForRole", "err", oErr)
|
||||
if channelID != nil {
|
||||
// Single-channel search: permission already checked above.
|
||||
var err error
|
||||
results, err = database.SearchMessages(q, channelID, limit)
|
||||
if err != nil {
|
||||
if isInvalidSearchQueryError(err) {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "BAD_REQUEST",
|
||||
Message: "invalid search query",
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("handleSearch SearchMessages", "err", err, "query", q)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "search failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Global search: pre-compute the set of accessible channel IDs
|
||||
// so the DB query never touches restricted content.
|
||||
role, _ := r.Context().Value(RoleKey).(*db.Role)
|
||||
user, _ := r.Context().Value(UserKey).(*db.User)
|
||||
|
||||
// Batch-fetch channel types in a single query to avoid N+1 lookups.
|
||||
uniqueIDs := make(map[int64]struct{}, len(results))
|
||||
for _, res := range results {
|
||||
uniqueIDs[res.ChannelID] = struct{}{}
|
||||
}
|
||||
channelIDs := make([]int64, 0, len(uniqueIDs))
|
||||
for id := range uniqueIDs {
|
||||
channelIDs = append(channelIDs, id)
|
||||
}
|
||||
channelTypeCache, ctErr := database.GetChannelTypes(channelIDs)
|
||||
if ctErr != nil {
|
||||
slog.Error("handleSearch GetChannelTypes", "err", ctErr)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "search failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var filtered []db.MessageSearchResult
|
||||
for _, res := range results {
|
||||
chType, ok := channelTypeCache[res.ChannelID]
|
||||
if !ok {
|
||||
// Fail closed if we cannot determine the channel type.
|
||||
continue
|
||||
// 1. Guild channels the user can read.
|
||||
allChannels, chErr := database.ListChannels()
|
||||
if chErr != nil {
|
||||
slog.Error("handleSearch ListChannels", "err", chErr)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "search failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if chType == "dm" {
|
||||
// DM channels require participant-based auth.
|
||||
if user == nil {
|
||||
continue
|
||||
|
||||
overrides := map[int64]db.ChannelOverride{}
|
||||
if role != nil && !permissions.HasAdmin(role.Permissions) {
|
||||
var oErr error
|
||||
overrides, oErr = database.GetAllChannelPermissionsForRole(role.ID)
|
||||
if oErr != nil {
|
||||
slog.Error("handleSearch GetAllChannelPermissionsForRole", "err", oErr)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "search failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
ok, dmErr := database.IsDMParticipant(user.ID, res.ChannelID)
|
||||
if dmErr != nil || !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var accessibleIDs []int64
|
||||
for _, ch := range allChannels {
|
||||
if ch.Type == "dm" {
|
||||
continue // DM channels handled separately below.
|
||||
}
|
||||
if hasChannelPermBatch(role, overrides, ch.ID, permissions.ReadMessages) {
|
||||
accessibleIDs = append(accessibleIDs, ch.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. DM channels the user participates in.
|
||||
if user != nil {
|
||||
dmChannels, dmErr := database.GetUserDMChannels(user.ID)
|
||||
if dmErr != nil {
|
||||
slog.Error("handleSearch GetUserDMChannels", "err", dmErr)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "search failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, dm := range dmChannels {
|
||||
accessibleIDs = append(accessibleIDs, dm.ChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(accessibleIDs) == 0 {
|
||||
results = []db.MessageSearchResult{}
|
||||
} else {
|
||||
if !hasChannelPermBatch(role, overrides, res.ChannelID, permissions.ReadMessages) {
|
||||
continue
|
||||
var err error
|
||||
results, err = database.SearchMessagesInChannels(q, accessibleIDs, limit)
|
||||
if err != nil {
|
||||
if isInvalidSearchQueryError(err) {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "BAD_REQUEST",
|
||||
Message: "invalid search query",
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("handleSearch SearchMessagesInChannels", "err", err, "query", q)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "search failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, res)
|
||||
}
|
||||
if filtered == nil {
|
||||
filtered = []db.MessageSearchResult{}
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Results []db.MessageSearchResult `json:"results"`
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response{Results: filtered})
|
||||
writeJSON(w, http.StatusOK, response{Results: results})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-11
@@ -57,17 +57,6 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi
|
||||
return
|
||||
}
|
||||
|
||||
// Per-user TOTP brute-force protection: lock out after 10 failures
|
||||
// across all IPs within a 15-minute window.
|
||||
totpKey := fmt.Sprintf("totp_fail:%d", challenge.UserID)
|
||||
if !limiter.Allow(totpKey, 10, 15*time.Minute) {
|
||||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||||
Error: "RATE_LIMITED",
|
||||
Message: "too many failed attempts, try again later",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req verifyTotpRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
@@ -77,6 +66,15 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi
|
||||
return
|
||||
}
|
||||
|
||||
totpKey := fmt.Sprintf("totp_fail:%d", challenge.UserID)
|
||||
if !limiter.Check(totpKey, 10, 15*time.Minute) {
|
||||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||||
Error: "RATE_LIMITED",
|
||||
Message: "too many failed attempts, try again later",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := database.GetUserByID(challenge.UserID)
|
||||
if err != nil || user == nil || user.TOTPSecret == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
@@ -87,6 +85,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi
|
||||
}
|
||||
|
||||
if !auth.VerifyTOTPCodeOnce(*user.TOTPSecret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) {
|
||||
limiter.Allow(totpKey, 10, 15*time.Minute)
|
||||
partialStore.RegisterFailure(partialToken, 5)
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
@@ -95,6 +94,8 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi
|
||||
return
|
||||
}
|
||||
|
||||
limiter.Reset(totpKey)
|
||||
|
||||
if _, ok := partialStore.Consume(partialToken); !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
@@ -135,6 +136,14 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if user.TOTPSecret != nil && *user.TOTPSecret != "" {
|
||||
writeJSON(w, http.StatusConflict, errorResponse{
|
||||
Error: "TOTP_ALREADY_ENABLED",
|
||||
Message: "disable 2FA before re-enabling",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req passwordConfirmationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
|
||||
@@ -96,6 +96,38 @@ func (r *RateLimiter) IsLockedOut(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check reports whether a request from key would be permitted given the limit
|
||||
// and window, WITHOUT recording a new timestamp. Use this for read-only
|
||||
// rate-limit checks where the caller wants to record (via Allow) only on
|
||||
// specific outcomes such as verification failures.
|
||||
func (r *RateLimiter) Check(key string, limit int, window time.Duration) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if lo, ok := r.lockouts[key]; ok {
|
||||
if time.Now().Before(lo.expiresAt) {
|
||||
return false
|
||||
}
|
||||
delete(r.lockouts, key)
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-window)
|
||||
|
||||
e, ok := r.windows[key]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, ts := range e.timestamps {
|
||||
if ts.After(cutoff) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count < limit
|
||||
}
|
||||
|
||||
// Reset clears all rate-limit state (timestamps and lockout) for key.
|
||||
func (r *RateLimiter) Reset(key string) {
|
||||
r.mu.Lock()
|
||||
|
||||
+57
-15
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeleteAccount anonymises and disables a user account within a single
|
||||
@@ -29,25 +30,66 @@ func (d *DB) DeleteAccount(ctx context.Context, userID int64) error {
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
// ── Guard: last admin/owner check ────────────────────────────────────
|
||||
// Owner (role_id=1) and Admin (role_id=2) are both "admin-class" roles.
|
||||
var userRoleID int64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT role_id FROM users WHERE id = ?`, userID,
|
||||
).Scan(&userRoleID); err != nil {
|
||||
return fmt.Errorf("DeleteAccount fetch role: %w", err)
|
||||
// Dynamically resolve admin-class role IDs from the roles table.
|
||||
adminRows, err := tx.QueryContext(ctx,
|
||||
`SELECT id FROM roles WHERE name IN ('Owner', 'Admin')`,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteAccount fetch admin roles: %w", err)
|
||||
}
|
||||
var adminRoleIDs []int64
|
||||
for adminRows.Next() {
|
||||
var rid int64
|
||||
if scanErr := adminRows.Scan(&rid); scanErr != nil {
|
||||
adminRows.Close() //nolint:errcheck
|
||||
return fmt.Errorf("DeleteAccount scan admin role: %w", scanErr)
|
||||
}
|
||||
adminRoleIDs = append(adminRoleIDs, rid)
|
||||
}
|
||||
adminRows.Close() //nolint:errcheck
|
||||
if adminRows.Err() != nil {
|
||||
return fmt.Errorf("DeleteAccount admin roles rows: %w", adminRows.Err())
|
||||
}
|
||||
|
||||
const roleOwner, roleAdmin int64 = 1, 2
|
||||
if userRoleID == roleOwner || userRoleID == roleAdmin {
|
||||
var adminCount int
|
||||
if len(adminRoleIDs) == 0 {
|
||||
// No admin-class roles defined; skip the guard.
|
||||
} else {
|
||||
var userRoleID int64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE role_id IN (?, ?) AND id != ? AND banned = 0`,
|
||||
roleOwner, roleAdmin, userID,
|
||||
).Scan(&adminCount); err != nil {
|
||||
return fmt.Errorf("DeleteAccount count admins: %w", err)
|
||||
`SELECT role_id FROM users WHERE id = ?`, userID,
|
||||
).Scan(&userRoleID); err != nil {
|
||||
return fmt.Errorf("DeleteAccount fetch role: %w", err)
|
||||
}
|
||||
if adminCount == 0 {
|
||||
return ErrLastAdmin
|
||||
|
||||
isAdminClass := false
|
||||
for _, rid := range adminRoleIDs {
|
||||
if userRoleID == rid {
|
||||
isAdminClass = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isAdminClass {
|
||||
// Build IN clause dynamically for the admin role IDs.
|
||||
placeholders := make([]string, len(adminRoleIDs))
|
||||
args := make([]any, 0, len(adminRoleIDs)+1)
|
||||
for i, rid := range adminRoleIDs {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, rid)
|
||||
}
|
||||
args = append(args, userID)
|
||||
|
||||
var adminCount int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND banned = 0`,
|
||||
strings.Join(placeholders, ",")),
|
||||
args...,
|
||||
).Scan(&adminCount); err != nil {
|
||||
return fmt.Errorf("DeleteAccount count admins: %w", err)
|
||||
}
|
||||
if adminCount == 0 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ func (d *DB) BackupToSafe(path, safeRoot string) error {
|
||||
// Defence-in-depth: only allow safe characters (alphanumeric, path separators,
|
||||
// hyphen, underscore, dot, space, colon, tilde). This is a strict allowlist —
|
||||
// anything else is rejected to prevent SQL injection via the interpolated path.
|
||||
for _, ch := range clean {
|
||||
for _, ch := range absClean {
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z',
|
||||
ch >= 'A' && ch <= 'Z',
|
||||
@@ -362,11 +362,11 @@ func (d *DB) BackupToSafe(path, safeRoot string) error {
|
||||
|
||||
// Reject SQL comment sequences that could break the VACUUM INTO statement,
|
||||
// even though individual hyphens are allowed for filenames.
|
||||
if strings.Contains(clean, "--") {
|
||||
if strings.Contains(absClean, "--") {
|
||||
return fmt.Errorf("BackupToSafe: path contains forbidden sequence %q", "--")
|
||||
}
|
||||
|
||||
_, err = d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", clean))
|
||||
_, err = d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", absClean))
|
||||
if err != nil {
|
||||
return fmt.Errorf("BackupToSafe: %w", err)
|
||||
}
|
||||
|
||||
@@ -269,6 +269,67 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchMessagesInChannels performs a full-text search scoped to the given
|
||||
// channel IDs. This prevents information leakage by filtering at the DB level
|
||||
// rather than post-filtering in application code.
|
||||
func (d *DB) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]MessageSearchResult, error) {
|
||||
if query == "" || len(channelIDs) == 0 {
|
||||
return []MessageSearchResult{}, nil
|
||||
}
|
||||
query = sanitizeFTSQuery(query)
|
||||
if query == "" {
|
||||
return []MessageSearchResult{}, nil
|
||||
}
|
||||
if limit < 1 {
|
||||
return []MessageSearchResult{}, nil
|
||||
}
|
||||
|
||||
// Build IN clause placeholders.
|
||||
placeholders := make([]string, len(channelIDs))
|
||||
args := make([]any, 0, len(channelIDs)+2)
|
||||
args = append(args, query)
|
||||
for i, id := range channelIDs {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := d.sqlDB.Query(
|
||||
fmt.Sprintf(
|
||||
`SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp
|
||||
FROM messages_fts f
|
||||
JOIN messages m ON f.rowid = m.id
|
||||
JOIN channels c ON m.channel_id = c.id
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE messages_fts MATCH ? AND m.channel_id IN (%s) AND m.deleted = 0
|
||||
ORDER BY rank LIMIT ?`,
|
||||
strings.Join(placeholders, ",")),
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SearchMessagesInChannels: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck
|
||||
|
||||
var results []MessageSearchResult
|
||||
for rows.Next() {
|
||||
var r MessageSearchResult
|
||||
if scanErr := rows.Scan(&r.MessageID, &r.ChannelID, &r.ChannelName,
|
||||
&r.User.ID, &r.User.Username, &r.User.Avatar,
|
||||
&r.Content, &r.Timestamp); scanErr != nil {
|
||||
return nil, fmt.Errorf("SearchMessagesInChannels scan: %w", scanErr)
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, fmt.Errorf("SearchMessagesInChannels rows: %w", rows.Err())
|
||||
}
|
||||
if results == nil {
|
||||
results = []MessageSearchResult{}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetMessagesForAPI returns messages in the API.md response shape, including
|
||||
// user object, reactions (with me flag), and attachments.
|
||||
func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]MessageAPIResponse, error) {
|
||||
|
||||
@@ -259,6 +259,26 @@ func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableCameraIfUnderLimit atomically enables a user's camera only if the
|
||||
// channel has not yet reached maxVideo active cameras. Returns true if the
|
||||
// camera was enabled, false if the limit was already reached.
|
||||
func (d *DB) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) {
|
||||
res, err := d.sqlDB.Exec(
|
||||
`UPDATE voice_states SET camera = 1
|
||||
WHERE user_id = ? AND channel_id = ?
|
||||
AND (SELECT COUNT(*) FROM voice_states WHERE channel_id = ? AND camera = 1) < ?`,
|
||||
userID, channelID, channelID, maxVideo,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("EnableCameraIfUnderLimit: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("EnableCameraIfUnderLimit RowsAffected: %w", err)
|
||||
}
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// UpdateVoiceScreenshare sets the screenshare field for the given user's voice state.
|
||||
func (d *DB) UpdateVoiceScreenshare(userID int64, screenshare bool) error {
|
||||
_, err := d.sqlDB.Exec(
|
||||
|
||||
@@ -124,7 +124,12 @@ func (s *Storage) Save(uuid string, r io.Reader) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file %s: %w", dst, err)
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = f.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Reconstruct the full stream: header bytes we already read + remainder.
|
||||
maxBytes := int64(s.maxSizeMB) * 1024 * 1024
|
||||
@@ -138,14 +143,17 @@ func (s *Storage) Save(uuid string, r io.Reader) error {
|
||||
if written == maxBytes {
|
||||
var probe [1]byte
|
||||
if n, _ := full.Read(probe[:]); n > 0 {
|
||||
// File exceeds limit — remove the partial write and reject.
|
||||
_ = f.Close()
|
||||
closed = true
|
||||
if removeErr := os.Remove(dst); removeErr != nil {
|
||||
slog.Error("storage: failed to remove oversized file", "path", dst, "err", removeErr)
|
||||
}
|
||||
return fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB)
|
||||
}
|
||||
}
|
||||
if syncErr := f.Sync(); syncErr != nil {
|
||||
return fmt.Errorf("syncing file %s: %w", dst, syncErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@ func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error
|
||||
// Cap download at 500 MiB to prevent unbounded disk usage from a
|
||||
// malicious or corrupted release asset.
|
||||
const maxBinarySize = 500 * 1024 * 1024
|
||||
limitedReader := io.LimitReader(resp.Body, maxBinarySize+1)
|
||||
limitedReader := io.LimitReader(resp.Body, maxBinarySize)
|
||||
|
||||
n, err := io.Copy(f, limitedReader)
|
||||
if err != nil {
|
||||
@@ -419,10 +419,14 @@ func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("writing downloaded file: %w", err)
|
||||
}
|
||||
if n > maxBinarySize {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("downloaded file exceeds maximum size of %d bytes", maxBinarySize)
|
||||
// Probe for one more byte to detect if the file exceeds the limit.
|
||||
if n == maxBinarySize {
|
||||
var probe [1]byte
|
||||
if extra, _ := resp.Body.Read(probe[:]); extra > 0 {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("downloaded file exceeds maximum size of %d bytes", maxBinarySize)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -55,7 +55,7 @@ func TouchForTest(c *Client) {
|
||||
|
||||
// RollbackVoiceJoinForTest exposes Hub.rollbackVoiceJoin for external tests.
|
||||
func (h *Hub) RollbackVoiceJoinForTest(c *Client, channelID int64) {
|
||||
h.rollbackVoiceJoin(c, channelID)
|
||||
h.rollbackVoiceJoin(c, channelID, true)
|
||||
}
|
||||
|
||||
// LeaveVoiceChannelWithRetryForTest exposes leaveVoiceChannelWithRetry for external tests.
|
||||
|
||||
+7
-3
@@ -167,6 +167,7 @@ func (h *Hub) Run() {
|
||||
|
||||
if panicCount >= 3 {
|
||||
slog.Error("hub: too many panics in 60s, stopping")
|
||||
h.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -324,13 +325,16 @@ func (h *Hub) registerNow(c *Client) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) unregisterNow(c *Client) {
|
||||
func (h *Hub) unregisterNow(c *Client) bool {
|
||||
h.mu.Lock()
|
||||
if current, ok := h.clients[c.userID]; ok && current == c {
|
||||
defer h.mu.Unlock()
|
||||
current, exists := h.clients[c.userID]
|
||||
if exists && current == c {
|
||||
delete(h.clients, c.userID)
|
||||
slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients))
|
||||
return false // not replaced
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return true // different client registered = was replaced
|
||||
}
|
||||
|
||||
// BroadcastToChannel enqueues msg for delivery to all clients subscribed to
|
||||
|
||||
@@ -22,14 +22,8 @@ import (
|
||||
// RoomEvent.ActiveSpeakersChanged (lower latency than webhooks).
|
||||
func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
|
||||
if err != nil {
|
||||
slog.Error("livekit webhook: read body failed", "error", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the webhook token from the Authorization header.
|
||||
// Check Authorization header BEFORE reading the body to avoid
|
||||
// allocating memory for unauthenticated requests.
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
slog.Warn("livekit webhook: missing Authorization header")
|
||||
@@ -37,6 +31,13 @@ func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFun
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
|
||||
if err != nil {
|
||||
slog.Error("livekit webhook: read body failed", "error", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// LiveKit sends "Bearer <token>" in the Authorization header.
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
verifier, err := auth.ParseAPIToken(tokenStr)
|
||||
|
||||
+7
-6
@@ -10,16 +10,17 @@ import (
|
||||
// checking according to the provided allowed-origins list.
|
||||
//
|
||||
// Rules:
|
||||
// - nil or empty list → InsecureSkipVerify = true (same as the old default)
|
||||
// - list contains "*" → InsecureSkipVerify = true (explicit opt-in)
|
||||
// - nil or empty list → InsecureSkipVerify = false (deny all cross-origin; safe default)
|
||||
// - list contains "*" → InsecureSkipVerify = true (explicit opt-in for any origin)
|
||||
// - any other list → OriginPatterns set to the list; origin checking active
|
||||
//
|
||||
// The wildcard cases preserve backward compatibility: if a deployment has not
|
||||
// set allowed_origins the server continues to work exactly as before.
|
||||
// The Tauri desktop client uses a Rust WS proxy that does not send an Origin
|
||||
// header, so the default deny-all does not block desktop connections.
|
||||
// Set allowed_origins: ["*"] in config to explicitly allow any origin.
|
||||
func OriginAcceptOptions(allowedOrigins []string) *websocket.AcceptOptions {
|
||||
if len(allowedOrigins) == 0 {
|
||||
slog.Warn("ws: no allowed_origins configured — accepting connections from ANY origin (insecure)")
|
||||
return &websocket.AcceptOptions{InsecureSkipVerify: true}
|
||||
slog.Info("ws: no allowed_origins configured — denying cross-origin connections (safe default)")
|
||||
return &websocket.AcceptOptions{InsecureSkipVerify: false}
|
||||
}
|
||||
|
||||
for _, o := range allowedOrigins {
|
||||
|
||||
@@ -38,20 +38,19 @@ func TestOriginAcceptOptions_ExplicitOrigins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginAcceptOptions_EmptyList falls back to wildcard (InsecureSkipVerify)
|
||||
// so that an empty configuration doesn't silently reject all connections.
|
||||
// TestOriginAcceptOptions_EmptyList denies cross-origin by default (secure).
|
||||
func TestOriginAcceptOptions_EmptyList(t *testing.T) {
|
||||
opts := ws.OriginAcceptOptions([]string{})
|
||||
if !opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions([]) should fall back to InsecureSkipVerify=true")
|
||||
if opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions([]) should deny cross-origin (InsecureSkipVerify=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginAcceptOptions_NilList same as empty.
|
||||
// TestOriginAcceptOptions_NilList same as empty — deny by default.
|
||||
func TestOriginAcceptOptions_NilList(t *testing.T) {
|
||||
opts := ws.OriginAcceptOptions(nil)
|
||||
if !opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions(nil) should fall back to InsecureSkipVerify=true")
|
||||
if opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions(nil) should deny cross-origin (InsecureSkipVerify=false)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ func (rb *EventRingBuffer) EventsSince(afterSeq uint64) [][]byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result [][]byte
|
||||
result := make([][]byte, 0)
|
||||
for i := 0; i < rb.count; i++ {
|
||||
idx := (oldestIdx + i) % rb.size
|
||||
e := rb.entries[idx]
|
||||
|
||||
+22
-4
@@ -138,6 +138,22 @@ func (h *Hub) handleReconnect(
|
||||
func (h *Hub) handleFreshConnect(
|
||||
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB,
|
||||
) error {
|
||||
// Clean stale voice state BEFORE building ready and registering.
|
||||
// When a user F5-reloads while in voice, the DB row from the previous
|
||||
// session must be removed so the ready payload doesn't include it and
|
||||
// other clients see a voice_leave broadcast.
|
||||
if vs, err := database.GetVoiceState(c.userID); err == nil && vs != nil {
|
||||
slog.Info("ws fresh connect: cleaning stale voice state",
|
||||
"user_id", c.userID, "channel_id", vs.ChannelID)
|
||||
if _, delErr := database.LeaveVoiceChannelIfMatch(c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil {
|
||||
slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr)
|
||||
}
|
||||
h.BroadcastToAll(buildVoiceLeave(vs.ChannelID, c.userID))
|
||||
if h.livekit != nil {
|
||||
go h.livekit.RemoveParticipant(vs.ChannelID, c.userID, vs.JoinedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh connection or replay fallback: full auth_ok + ready flow.
|
||||
slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
|
||||
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName)); err != nil {
|
||||
@@ -201,11 +217,13 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) {
|
||||
func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) {
|
||||
var lastReadErr error
|
||||
defer func() {
|
||||
hub.unregisterNow(c)
|
||||
// Snapshot voice state BEFORE unregister to avoid TOCTOU with replacement connections.
|
||||
voiceChID := c.getVoiceChID()
|
||||
replaced := hub.unregisterNow(c)
|
||||
if c.user != nil {
|
||||
replaced := hub.IsUserConnected(c.userID)
|
||||
voiceChID := c.getVoiceChID()
|
||||
if !replaced {
|
||||
// Always clean up voice state — LeaveVoiceChannelIfMatch uses a
|
||||
// join_token guard so it won't remove a replacement client's session.
|
||||
if voiceChID != 0 {
|
||||
hub.handleVoiceLeave(ctx, c)
|
||||
}
|
||||
c.mu.Lock()
|
||||
|
||||
+18
-12
@@ -109,28 +109,34 @@ func (h *Hub) handleVoiceCamera(ctx context.Context, c *Client, payload json.Raw
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce MaxVideo limit when enabling camera.
|
||||
// Count from DB (race-free via SQLite serialization) instead of LiveKit API.
|
||||
// Enforce MaxVideo limit when enabling camera using an atomic check-and-update.
|
||||
if p.Enabled {
|
||||
ch, chErr := h.db.GetChannel(voiceChID)
|
||||
if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 {
|
||||
videoCount, countErr := h.db.CountActiveCameras(voiceChID)
|
||||
if countErr != nil {
|
||||
slog.Error("handleVoiceCamera CountActiveCameras", "err", countErr, "channel_id", voiceChID)
|
||||
ok, limitErr := h.db.EnableCameraIfUnderLimit(c.userID, voiceChID, ch.VoiceMaxVideo)
|
||||
if limitErr != nil {
|
||||
slog.Error("handleVoiceCamera EnableCameraIfUnderLimit", "err", limitErr, "channel_id", voiceChID)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to check video limit"))
|
||||
return
|
||||
} else if videoCount >= ch.VoiceMaxVideo {
|
||||
}
|
||||
if !ok {
|
||||
c.sendMsg(buildErrorMsg(ErrCodeVideoLimit,
|
||||
fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo)))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.db.UpdateVoiceCamera(c.userID, true); err != nil {
|
||||
slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state"))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := h.db.UpdateVoiceCamera(c.userID, false); err != nil {
|
||||
slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.db.UpdateVoiceCamera(c.userID, p.Enabled); err != nil {
|
||||
slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state"))
|
||||
return
|
||||
}
|
||||
slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled, "channel_id", voiceChID)
|
||||
|
||||
|
||||
+12
-8
@@ -102,21 +102,20 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe
|
||||
state, err := h.db.GetVoiceState(c.userID)
|
||||
if err != nil || state == nil {
|
||||
slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID)
|
||||
h.rollbackVoiceJoin(c, channelID)
|
||||
h.rollbackVoiceJoin(c, channelID, false)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel"))
|
||||
return
|
||||
}
|
||||
|
||||
// Set voice channel on the client.
|
||||
c.setVoiceState(channelID, state.JoinedAt)
|
||||
|
||||
// Generate LiveKit token if LiveKit client is available.
|
||||
// Token generation failure is fatal — without a token the client cannot
|
||||
// connect to the SFU, so we must roll back the DB join.
|
||||
// NOTE: setVoiceState is deferred until after token send succeeds, so
|
||||
// rollback does not broadcast a spurious voice_leave for an unannounced join.
|
||||
if h.livekit != nil {
|
||||
if c.user == nil {
|
||||
slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID)
|
||||
h.rollbackVoiceJoin(c, channelID)
|
||||
h.rollbackVoiceJoin(c, channelID, false)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "not authenticated"))
|
||||
return
|
||||
}
|
||||
@@ -127,7 +126,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe
|
||||
token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, state.JoinedAt, canPublish, canSubscribe)
|
||||
if tokenErr != nil {
|
||||
slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID)
|
||||
h.rollbackVoiceJoin(c, channelID)
|
||||
h.rollbackVoiceJoin(c, channelID, false)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice token"))
|
||||
return
|
||||
}
|
||||
@@ -137,6 +136,9 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe
|
||||
c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL()))
|
||||
}
|
||||
|
||||
// Set voice channel on the client AFTER token is sent successfully.
|
||||
c.setVoiceState(channelID, state.JoinedAt)
|
||||
|
||||
// Broadcast the joiner's state to all connected clients.
|
||||
h.BroadcastToAll(buildVoiceState(*state))
|
||||
|
||||
@@ -237,11 +239,13 @@ func (h *Hub) handleVoiceTokenRefresh(ctx context.Context, c *Client) {
|
||||
// rollbackVoiceJoin undoes a partially-completed voice join: clears the
|
||||
// client's voice channel ID, removes the DB voice state row, and broadcasts
|
||||
// voice_leave so other clients don't see a ghost participant.
|
||||
func (h *Hub) rollbackVoiceJoin(c *Client, channelID int64) {
|
||||
func (h *Hub) rollbackVoiceJoin(c *Client, channelID int64, broadcast bool) {
|
||||
c.clearVoiceChID()
|
||||
if err := h.db.LeaveVoiceChannel(c.userID); err != nil {
|
||||
slog.Error("ws rollbackVoiceJoin LeaveVoiceChannel", "err", err,
|
||||
"user_id", c.userID, "channel_id", channelID)
|
||||
}
|
||||
h.BroadcastToAll(buildVoiceLeave(channelID, c.userID))
|
||||
if broadcast {
|
||||
h.BroadcastToAll(buildVoiceLeave(channelID, c.userID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ Configuration is loaded in three layers (later layers override earlier ones):
|
||||
| `server.port` | int | `8443` | HTTP(S) listen port |
|
||||
| `server.name` | string | `"OwnCord Server"` | Server display name (shown in `/api/v1/info` and admin panel) |
|
||||
| `server.data_dir` | string | `"data"` | Directory for database, certs, uploads, backups |
|
||||
| `server.allowed_origins` | string[] | `["*"]` | WebSocket CORS allowed origins; restrict in production |
|
||||
| `server.allowed_origins` | string[] | `[]` | WebSocket CORS allowed origins; empty list DENIES all cross-origin (set to `["*"]` to allow any origin) |
|
||||
| `server.trusted_proxies` | string[] | `[]` | CIDRs of trusted reverse proxies (for X-Forwarded-For) |
|
||||
| `server.admin_allowed_cidrs` | string[] | private networks | CIDRs allowed to access `/admin` routes. Default: `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7` |
|
||||
|
||||
@@ -99,7 +99,7 @@ server:
|
||||
port: 8443
|
||||
name: "OwnCord Server"
|
||||
data_dir: "data"
|
||||
allowed_origins: ["*"] # restrict in production
|
||||
allowed_origins: [] # empty = deny all cross-origin; set to ["*"] to allow any
|
||||
trusted_proxies: [] # e.g. ["10.0.0.0/8"] if behind a reverse proxy
|
||||
admin_allowed_cidrs:
|
||||
- "127.0.0.0/8"
|
||||
|
||||
Reference in New Issue
Block a user