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
+28 -12
View File
@@ -148,16 +148,19 @@ export async function loadDisplayMonitors() {
let powerBtn = '';
if (monitor.power_supported) {
// Inline onclick with string-interpolated monitor.name is a DOM-XSS
// foot-gun if the OS ever reports a name containing quotes / angle
// brackets. Use a delegated click handler bound to data-* attrs.
powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
data-action="toggle-power" data-monitor-id="${monitor.id}"
title="${escapeHtml(monitor.power_on ? t('display.power_off') : t('display.power_on'))}">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
</button>`;
}
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
const detailsHtml = details ? `<span class="display-monitor-details">${escapeHtml(details)}</span>` : '';
const primaryBadge = monitor.is_primary
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -282,7 +285,7 @@ export async function loadDisplayMonitors() {
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg>
<div class="display-monitor-info">
<span class="display-monitor-name"><span class="display-monitor-name-text">${monitor.name}</span>${primaryBadge}</span>
<span class="display-monitor-name"><span class="display-monitor-name-text">${escapeHtml(monitor.name)}</span>${primaryBadge}</span>
${detailsHtml}
</div>
${powerBtn}
@@ -303,6 +306,11 @@ export async function loadDisplayMonitors() {
container.appendChild(card);
});
// Bind a single delegated click handler for the power buttons.
// Avoids inline onclick="..." with interpolated monitor data.
container.removeEventListener('click', _onPowerButtonClick);
container.addEventListener('click', _onPowerButtonClick);
// Enhance every tuning <select> with an IconSelect now that the
// cards are in the DOM (IconSelect needs offsetParent + sibling).
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
@@ -441,7 +449,14 @@ export async function onDisplayPictureModeChange(monitorId, codeRaw) {
}
}
export async function toggleDisplayPower(monitorId, monitorName) {
function _onPowerButtonClick(event) {
const btn = event.target.closest('[data-action="toggle-power"]');
if (!btn) return;
const id = Number(btn.dataset.monitorId);
if (Number.isFinite(id)) toggleDisplayPower(id);
}
export async function toggleDisplayPower(monitorId) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
@@ -459,13 +474,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on');
}
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
showToast(t(newState ? 'display.msg.power_on' : 'display.msg.power_off'), 'success');
} else {
showToast('Failed to change monitor power', 'error');
showToast(t('display.msg.power_failed'), 'error');
}
} catch (e) {
console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error');
showToast(t('display.msg.power_failed'), 'error');
}
}
@@ -562,7 +577,7 @@ async function _loadLinksTableImpl() {
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading links:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('links.msg.load_failed'))}</td></tr>`;
}
}
@@ -665,9 +680,10 @@ export async function saveLink(event) {
description: document.getElementById('linkDescription').value || ''
};
const encodedName = encodeURIComponent(linkName);
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
`/api/links/update/${encodedName}` :
`/api/links/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST';
@@ -701,7 +717,7 @@ export async function deleteLinkConfirm(linkName) {
}
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
method: 'DELETE',
headers: getAuthHeaders()
});