fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s

Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
  and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
  paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
  thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
  X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
  kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
  start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
  delegated data-action handler; remote MDI SVGs parsed and sanitized
  (strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent

Bugs
- WebSocket reconnect: close previous socket before opening new, clear
  ping interval per-socket, clear reconnectTimeout up-front, retry on
  online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
  background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
  spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
  checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip

Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
  asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
  threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
  — cache grew unbounded)
- Progress drag listeners attached only while dragging

Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
  _upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
  clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
  pip install -e . users see the source-of-truth version, not the stale
  package-metadata version baked in at install time

UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
  copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
  chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
  callbacks.empty in both en + ru
This commit is contained in:
2026-05-16 13:22:46 +03:00
parent 770bba7e60
commit bcc6d40ed7
28 changed files with 1063 additions and 876 deletions
+49 -20
View File
@@ -66,12 +66,14 @@ function showRootFolders() {
// Hide search at root level
showBrowserSearch(false);
// Render breadcrumb with just "Home" (not clickable at root)
// Render breadcrumb with just "Home" (already at root — not interactive).
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '';
const root = document.createElement('span');
root.className = 'breadcrumb-item breadcrumb-home';
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
root.setAttribute('aria-current', 'page');
root.setAttribute('aria-label', t('browser.home') || 'Home');
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
breadcrumb.appendChild(root);
// Hide play all button and pagination
@@ -133,8 +135,10 @@ function showRootFolders() {
}
async function browsePath(folderId, path, offset = 0, nocache = false) {
// Clear search when navigating
// Clear search when navigating; bump browse generation so in-flight
// thumbnail fetches from the previous folder can be discarded.
showBrowserSearch(false);
bumpBrowseGen();
try {
if (!hasCredentials()) return;
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
const parts = (currentPathStr || '').split('/').filter(p => p);
let path = '/';
// Home link (back to folder list)
const home = document.createElement('span');
// Home link (back to folder list) — use a real <button> so it's
// keyboard-focusable and reachable by screen readers.
const home = document.createElement('button');
home.type = 'button';
home.className = 'breadcrumb-item breadcrumb-home';
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.setAttribute('aria-label', t('browser.home') || 'Home');
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.onclick = () => showRootFolders();
breadcrumb.appendChild(home);
@@ -512,16 +519,33 @@ function formatBitrate(bps) {
return Math.round(bps / 1000) + ' kbps';
}
// Bump this whenever the user changes folder/path so in-flight fetches from
// the previous view can be ignored when they finally resolve.
let _browseGen = 0;
function bumpBrowseGen() { return ++_browseGen; }
function currentBrowseGen() { return _browseGen; }
function buildRelativeFilePath(relativePath, fileName) {
const base = (relativePath === '/' || relativePath === '') ? '' : relativePath.replace(/\/$/, '');
return base + '/' + fileName;
}
async function loadThumbnail(imgElement, fileName) {
const myGen = currentBrowseGen();
const folderId = currentFolderId;
const relPath = buildRelativeFilePath(currentPath, fileName);
const cacheKey = `${folderId}|${relPath}`;
try {
if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
// If the user navigates away before this fetch resolves, the imgElement
// may already be detached. Bail in that case.
if (!imgElement.isConnected) return;
// Check cache first
if (thumbnailCache.has(absolutePath)) {
const cachedUrl = thumbnailCache.get(absolutePath);
if (thumbnailCache.has(cacheKey)) {
const cachedUrl = thumbnailCache.get(cacheKey);
imgElement.onload = () => {
if (!imgElement.isConnected) return;
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
@@ -529,17 +553,24 @@ async function loadThumbnail(imgElement, fileName) {
return;
}
const encodedPath = encodeURIComponent(absolutePath);
const params = new URLSearchParams({
folder_id: folderId,
path: relPath,
size: 'medium',
});
const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
`/api/browser/thumbnail?${params.toString()}`,
{ headers: getAuthHeaders() }
);
// Drop the response if the user has since navigated away.
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
if (response.status === 200) {
const blob = await response.blob();
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
const url = URL.createObjectURL(blob);
thumbnailCache.set(absolutePath, url);
thumbnailCache.set(cacheKey, url);
// Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
@@ -548,13 +579,11 @@ async function loadThumbnail(imgElement, fileName) {
thumbnailCache.delete(oldest);
}
// Wait for image to actually load before showing it
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
// Revoke previous blob URL if not managed by cache
if (imgElement.src && imgElement.src.startsWith('blob:')) {
let isCached = false;
for (const cachedUrl of thumbnailCache.values()) {
@@ -564,8 +593,8 @@ async function loadThumbnail(imgElement, fileName) {
}
imgElement.src = url;
} else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement;
if (!parent) return;
const isList = parent.classList.contains('browser-list-icon');
imgElement.remove();
if (isList) {
@@ -579,7 +608,7 @@ async function loadThumbnail(imgElement, fileName) {
}
} catch (error) {
console.error('Error loading thumbnail:', error);
imgElement.classList.remove('loading');
if (imgElement.isConnected) imgElement.classList.remove('loading');
}
}
@@ -601,12 +630,12 @@ async function playMediaFile(fileName) {
try {
if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
const relativePath = buildRelativeFilePath(currentPath, fileName);
const response = await fetch('/api/browser/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ path: absolutePath })
body: JSON.stringify({ folder_id: currentFolderId, path: relativePath })
});
if (!response.ok) throw new Error('Failed to play file');