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:
jevb
2026-04-01 09:23:17 +02:00
parent 30fd7fd880
commit a40b42bbed
28 changed files with 590 additions and 186 deletions
+22 -17
View File
@@ -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)
}
+7 -1
View File
@@ -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();
}
}
+8 -1
View File
@@ -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);
});
+114 -11
View File
@@ -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 {
+9 -1
View File
@@ -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;
+25 -2
View File
@@ -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;
}
});
}
+12
View File
@@ -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 }));
}
+86 -65
View File
@@ -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
View File
@@ -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{
+32
View File
@@ -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
View File
@@ -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
}
}
}
+3 -3
View File
@@ -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)
}
+61
View File
@@ -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) {
+20
View File
@@ -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(
+10 -2
View File
@@ -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
}
+9 -5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+9 -8
View File
@@ -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
View File
@@ -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 {
+6 -7
View File
@@ -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)")
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
}
+2 -2
View File
@@ -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"