d131ba461c
Lint & Test / test (push) Successful in 20s
Security - Default scripts_management, callbacks_management, links_management, and media_folders_management to False so a leaked token cannot escalate to RCE through admin CRUD endpoints. - TokenSpec + scope hierarchy (read | control | admin); legacy bare-string api_tokens entries promote to admin for back-compat. Management endpoints now require admin scope. - WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>) preferred over ?token= query so the token no longer lands in URL/history/ Referer; query fallback retained for HA integration back-compat. - Origin allow-list check on the WS endpoint (CSWSH defence). - In-process token-bucket rate limiter: 5/min for failed auths, 10/min for /api/scripts/execute and /api/callbacks/execute. - shell=False subprocess path (shlex.split) + per-parameter regex `pattern` in ScriptParameterConfig to harden shell=true scripts against parameter injection (Windows cmd.exe env-var expansion). - CSP gains form-action, worker-src, manifest-src directives. - Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access logs; validate Gitea release tag against strict SemVer regex. - noopener noreferrer + no-referrer referrerpolicy on every outbound link. - icacls hardening of config.yaml on Windows (current user + SYSTEM + Administrators only); 0600 still enforced on POSIX. - WS volume handler clamps input and never drops the socket on bad messages. Performance - Album-art read in windows_media gated by track key — was decoding the WinRT thumbnail twice per second regardless of track changes. - /api/media/artwork returns content-derived ETag + Cache-Control so the browser sends If-None-Match and gets 304s on track repeats. - Foreground-service ctypes argtypes hoisted to one-time module init (was re-declaring ~14 prototypes per probe). - display_service _static_cache keyed by (edid_hash, ...) tuple with eviction of disappeared monitors — fixes stale capabilities on hot-plug swaps where the new topology has the same monitor count. - Visualizer rAF loop paused on document.hidden, resumed on visible. Reliability / bug fixes - Lifespan rewritten as try/yield/finally so a partial-startup failure cannot orphan background tasks or executors. - _run_callback in routes/media.py keeps a strong task ref (GC-safe) and uses the dedicated callback executor instead of the default pool. - macos_media.set_volume() no longer always returns True. - TrayManager._restart_requested initialised in __init__; set before signalling exit so the main thread observes it correctly. - Missing static_dir now logs a WARNING instead of silent UI disable. UX / accessibility / PWA - manifest.json theme_color and background_color match the Studio Reference base (#0E0D0B); added id and scope for PWA installability. - ARIA on mini-player icon buttons; inner SVGs marked aria-hidden. - OS mediaSession API wired so headset / lockscreen / Bluetooth buttons drive play/pause/next/prev/seek and show track metadata + artwork. Observability - X-Request-ID middleware (accept upstream id if it matches a safe regex, otherwise UUID4); request_id_var added to ContextVars and included in every log line alongside the token label. - Audit log (append-only JSONL) for every script + callback execution, including the on_play/on_pause/etc. event callbacks. Background-thread writer; queue capped; flushed in lifespan teardown. Deployment - proxy_headers + forwarded_allow_ips plumbed through Settings → uvicorn.Config for reverse-proxy installs. - HTTPS support via ssl_certfile + ssl_keyfile (+ optional password); startup refuses to launch with only one of the pair set. - Thumbnail cache moved from project-root .cache to %LOCALAPPDATA%/media-server/cache (Windows) and $XDG_CACHE_HOME/media-server/thumbnails (POSIX). Tests - 35 new tests across auth scopes, rate limiter, browser path traversal (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
261 lines
8.8 KiB
JavaScript
261 lines
8.8 KiB
JavaScript
// ============================================================
|
|
// WebSocket: Connection, reconnection, authentication
|
|
// ============================================================
|
|
|
|
import {
|
|
dom, t, setWs, getWs,
|
|
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
|
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
|
authRequired, showUpdateBanner,
|
|
} from './core.js';
|
|
import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
|
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
|
import { loadCallbacksTable } from './callbacks.js';
|
|
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
|
import { updateForegroundUI } from './foreground.js';
|
|
|
|
let reconnectTimeout = null;
|
|
let wsReconnectAttempts = 0;
|
|
// Track the ping interval against the socket that owns it so we never leak
|
|
// a timer if connectWebSocket() is called while a previous socket is still
|
|
// alive. The pair is wiped on close to avoid double-clear races.
|
|
let activeSocket = null;
|
|
let activePingInterval = null;
|
|
|
|
function clearReconnect() {
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
}
|
|
|
|
function clearPing() {
|
|
if (activePingInterval) {
|
|
clearInterval(activePingInterval);
|
|
activePingInterval = null;
|
|
}
|
|
}
|
|
|
|
export function showAuthForm(errorMessage = '') {
|
|
const overlay = document.getElementById('auth-overlay');
|
|
overlay.classList.remove('hidden');
|
|
|
|
const errorEl = document.getElementById('auth-error');
|
|
if (errorMessage) {
|
|
errorEl.textContent = errorMessage;
|
|
errorEl.classList.add('visible');
|
|
} else {
|
|
errorEl.classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
function hideAuthForm() {
|
|
document.getElementById('auth-overlay').classList.add('hidden');
|
|
}
|
|
|
|
export function authenticate() {
|
|
const token = document.getElementById('token-input').value.trim();
|
|
if (!token) {
|
|
showAuthForm(t('auth.required'));
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem('media_server_token', token);
|
|
connectWebSocket(token);
|
|
}
|
|
|
|
export function clearToken() {
|
|
localStorage.removeItem('media_server_token');
|
|
const current = getWs();
|
|
if (current) {
|
|
try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
|
|
}
|
|
showAuthForm(t('auth.cleared'));
|
|
}
|
|
|
|
export function connectWebSocket(token) {
|
|
// Always cancel a pending reconnect first — otherwise a user-triggered
|
|
// reconnect can race a scheduled one and create two live sockets.
|
|
clearReconnect();
|
|
clearPing();
|
|
|
|
// Close any previous socket cleanly before opening a new one.
|
|
const previous = activeSocket;
|
|
activeSocket = null;
|
|
if (previous && previous.readyState <= WebSocket.OPEN) {
|
|
try { previous.close(1000, 'reconnecting'); } catch { /* ignore */ }
|
|
}
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
|
|
|
|
// Prefer Sec-WebSocket-Protocol-based auth so the token never appears in
|
|
// the URL (which would otherwise land in browser history, server access
|
|
// logs, and Referer headers). Keep the ?token=... fallback for clients
|
|
// that pre-date this change and don't speak the subprotocol.
|
|
let newWs;
|
|
if (token) {
|
|
try {
|
|
newWs = new WebSocket(wsBase, [`media-server.token.${token}`]);
|
|
} catch (e) {
|
|
console.warn('Subprotocol WS handshake failed, falling back to ?token=', e);
|
|
newWs = new WebSocket(`${wsBase}?token=${encodeURIComponent(token)}`);
|
|
}
|
|
} else {
|
|
newWs = new WebSocket(wsBase);
|
|
}
|
|
activeSocket = newWs;
|
|
setWs(newWs);
|
|
|
|
newWs.onopen = () => {
|
|
console.log('WebSocket connected');
|
|
wsReconnectAttempts = 0;
|
|
updateConnectionStatus(true);
|
|
hideConnectionBanner();
|
|
hideAuthForm();
|
|
loadScripts();
|
|
loadScriptsTable();
|
|
loadCallbacksTable();
|
|
loadLinksTable();
|
|
loadHeaderLinks();
|
|
loadAudioDevices();
|
|
};
|
|
|
|
newWs.onmessage = (event) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(event.data);
|
|
} catch (err) {
|
|
console.warn('Ignoring malformed WebSocket frame:', err);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'status' || msg.type === 'status_update') {
|
|
updateUI(msg.data);
|
|
} else if (msg.type === 'foreground' || msg.type === 'foreground_update') {
|
|
updateForegroundUI(msg.data);
|
|
} else if (msg.type === 'scripts_changed') {
|
|
console.log('Scripts changed, reloading...');
|
|
loadScripts();
|
|
loadScriptsTable();
|
|
} else if (msg.type === 'links_changed') {
|
|
console.log('Links changed, reloading...');
|
|
loadHeaderLinks();
|
|
loadLinksTable();
|
|
displayQuickAccess();
|
|
} else if (msg.type === 'update_available') {
|
|
showUpdateBanner(msg.data);
|
|
} else if (msg.type === 'audio_data') {
|
|
setFrequencyData(msg.data);
|
|
} else if (msg.type === 'error') {
|
|
console.error('WebSocket error:', msg.message);
|
|
}
|
|
};
|
|
|
|
newWs.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
updateConnectionStatus(false);
|
|
};
|
|
|
|
newWs.onclose = (event) => {
|
|
console.log('WebSocket closed:', event.code);
|
|
updateConnectionStatus(false);
|
|
stopPositionInterpolation();
|
|
|
|
// Drop this socket's ping interval. Guard so we don't kill a newer
|
|
// socket's interval if reconnect already started.
|
|
if (activeSocket === newWs) {
|
|
clearPing();
|
|
activeSocket = null;
|
|
}
|
|
|
|
if (event.code === 4001) {
|
|
localStorage.removeItem('media_server_token');
|
|
showAuthForm(t('auth.invalid'));
|
|
} else if (event.code !== 1000) {
|
|
wsReconnectAttempts++;
|
|
|
|
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
|
|
const delay = Math.min(
|
|
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
|
|
WS_BACKOFF_MAX_MS
|
|
);
|
|
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
|
|
|
|
if (wsReconnectAttempts >= 3) {
|
|
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
|
}
|
|
|
|
clearReconnect();
|
|
reconnectTimeout = setTimeout(() => {
|
|
reconnectTimeout = null;
|
|
const savedToken = localStorage.getItem('media_server_token');
|
|
if (savedToken || !authRequired) {
|
|
connectWebSocket(savedToken || '');
|
|
}
|
|
}, delay);
|
|
} else {
|
|
showConnectionBanner(t('connection.lost'), true);
|
|
}
|
|
}
|
|
};
|
|
|
|
clearPing();
|
|
activePingInterval = setInterval(() => {
|
|
if (newWs.readyState === WebSocket.OPEN) {
|
|
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
|
|
}
|
|
}, WS_PING_INTERVAL_MS);
|
|
}
|
|
|
|
export function updateConnectionStatus(connected) {
|
|
if (connected) {
|
|
dom.statusDot.classList.add('connected');
|
|
} else {
|
|
dom.statusDot.classList.remove('connected');
|
|
}
|
|
}
|
|
|
|
function showConnectionBanner(message, showButton) {
|
|
const banner = document.getElementById('connectionBanner');
|
|
const text = document.getElementById('connectionBannerText');
|
|
const btn = document.getElementById('connectionBannerBtn');
|
|
text.textContent = message;
|
|
btn.style.display = showButton ? '' : 'none';
|
|
banner.classList.remove('hidden');
|
|
}
|
|
|
|
function hideConnectionBanner() {
|
|
const banner = document.getElementById('connectionBanner');
|
|
banner.classList.add('hidden');
|
|
}
|
|
|
|
export function manualReconnect() {
|
|
const savedToken = localStorage.getItem('media_server_token');
|
|
if (savedToken || !authRequired) {
|
|
wsReconnectAttempts = 0;
|
|
hideConnectionBanner();
|
|
connectWebSocket(savedToken || '');
|
|
}
|
|
}
|
|
|
|
// When the browser regains connectivity or the tab becomes visible again,
|
|
// drop the backoff and reconnect immediately rather than waiting out the
|
|
// current timer.
|
|
function reconnectIfNeeded() {
|
|
const current = activeSocket;
|
|
if (current && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
const savedToken = localStorage.getItem('media_server_token');
|
|
if (savedToken || !authRequired) {
|
|
wsReconnectAttempts = 0;
|
|
connectWebSocket(savedToken || '');
|
|
}
|
|
}
|
|
|
|
window.addEventListener('online', reconnectIfNeeded);
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) reconnectIfNeeded();
|
|
});
|