fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s
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:
@@ -150,7 +150,7 @@ async function executeScript(scriptName, buttonElement) {
|
||||
|
||||
async function _doExecuteScript(scriptName, params) {
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ params })
|
||||
@@ -393,7 +393,7 @@ async function _loadScriptsTableImpl() {
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading scripts:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state" style="color: var(--error);">${escapeHtml(t('scripts.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
const script = scriptsList.find(s => s.name === scriptName);
|
||||
|
||||
if (!script) {
|
||||
showToast('Script not found', 'error');
|
||||
showToast(t('scripts.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading script for edit:', error);
|
||||
showToast('Failed to load script details', 'error');
|
||||
showToast(t('scripts.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,9 +508,10 @@ export async function saveScript(event) {
|
||||
parameters: _collectParameterDefinitions(),
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(scriptName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/scripts/update/${scriptName}` :
|
||||
`/api/scripts/create/${scriptName}`;
|
||||
`/api/scripts/update/${encodedName}` :
|
||||
`/api/scripts/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -524,15 +525,15 @@ export async function saveScript(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
|
||||
scriptFormDirty = false;
|
||||
closeScriptDialog();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
|
||||
showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving script:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
|
||||
showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
@@ -544,7 +545,7 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -552,13 +553,13 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Script deleted successfully', 'success');
|
||||
showToast(t('scripts.msg.deleted'), 'success');
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete script', 'error');
|
||||
showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
showToast('Error deleting script', 'error');
|
||||
showToast(t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,23 +582,23 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
const outputPre = document.getElementById('executionOutput');
|
||||
const errorPre = document.getElementById('executionError');
|
||||
|
||||
title.textContent = `Execution Result: ${name}`;
|
||||
title.textContent = `${t('execution.result')}: ${name}`;
|
||||
|
||||
const success = result.success && result.exit_code === 0;
|
||||
const statusClass = success ? 'success' : 'error';
|
||||
const statusText = success ? 'Success' : 'Failed';
|
||||
const statusText = t(success ? 'execution.success' : 'execution.failed');
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item ${statusClass}">
|
||||
<label>Status</label>
|
||||
<value>${statusText}</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value>${escapeHtml(statusText)}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Exit Code</label>
|
||||
<label>${escapeHtml(t('execution.exit_code'))}</label>
|
||||
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Duration</label>
|
||||
<label>${escapeHtml(t('execution.duration'))}</label>
|
||||
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||||
</div>
|
||||
`;
|
||||
@@ -606,7 +607,7 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
if (result.stdout && result.stdout.trim()) {
|
||||
outputPre.textContent = result.stdout;
|
||||
} else {
|
||||
outputPre.textContent = '(no output)';
|
||||
outputPre.textContent = t('execution.no_output');
|
||||
outputPre.style.fontStyle = 'italic';
|
||||
outputPre.style.color = 'var(--text-secondary)';
|
||||
}
|
||||
@@ -642,11 +643,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${scriptName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${scriptName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -813,11 +814,11 @@ export async function executeCallbackDebug(callbackName) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${callbackName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${callbackName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -826,7 +827,7 @@ export async function executeCallbackDebug(callbackName) {
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
||||
const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user