feat: make authentication optional — no tokens = no auth
Lint & Test / test (push) Successful in 10s

When no api_tokens are configured (the new default), all endpoints
are accessible without authentication. The frontend detects this
via /api/health's auth_required field and skips the login form.

- Backend: auth.py skips verification when api_tokens is empty
- Frontend: shared getAuthHeaders()/hasCredentials() helpers replace
  scattered token logic across all JS modules
- Health endpoint exposes auth_required for frontend discovery
- config.example.yaml ships with tokens commented out
- CLI --show-token and startup log reflect disabled state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 13:59:55 +03:00
parent f80f6e9299
commit 4d1bb78c83
14 changed files with 175 additions and 190 deletions
+19 -1
View File
@@ -13,6 +13,7 @@ import {
togglePlayPause, nextTrack, previousTrack, toggleMute,
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
changeLocale, t,
setAuthRequired,
} from './core.js';
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
@@ -160,8 +161,25 @@ window.addEventListener('DOMContentLoaded', async () => {
// Load version from health endpoint
fetchVersion();
// Check if authentication is required
let authReq = true;
try {
const healthResp = await fetch('/api/health');
const healthData = await healthResp.json();
authReq = healthData.auth_required !== false;
} catch { /* assume auth required on error */ }
setAuthRequired(authReq);
const token = localStorage.getItem('media_server_token');
if (token) {
if (!authReq) {
// No auth required — connect directly without token
connectWebSocket('');
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadAudioDevices();
} else if (token) {
connectWebSocket(token);
loadScripts();
loadScriptsTable();
+13 -36
View File
@@ -5,6 +5,7 @@
import {
t, showToast, escapeHtml, closeDialog,
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
getAuthHeaders, hasCredentials,
} from './core.js';
// Browser state
@@ -24,14 +25,10 @@ const THUMBNAIL_CACHE_MAX = 200;
// Load media folders on page load
export async function loadMediaFolders() {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
if (!hasCredentials()) return;
const response = await fetch('/api/browser/folders', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) throw new Error('Failed to load folders');
@@ -119,11 +116,7 @@ async function browsePath(folderId, path, offset = 0, nocache = false) {
showBrowserSearch(false);
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
if (!hasCredentials()) return;
// Show loading spinner
const container = document.getElementById('browserGrid');
@@ -135,7 +128,7 @@ async function browsePath(folderId, path, offset = 0, nocache = false) {
if (nocache) url += '&nocache=true';
const response = await fetch(
url,
{ headers: { 'Authorization': `Bearer ${token}` } }
{ headers: getAuthHeaders() }
);
if (!response.ok) {
@@ -487,11 +480,7 @@ function formatBitrate(bps) {
async function loadThumbnail(imgElement, fileName) {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
@@ -510,7 +499,7 @@ async function loadThumbnail(imgElement, fileName) {
const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
{ headers: { 'Authorization': `Bearer ${token}` } }
{ headers: getAuthHeaders() }
);
if (response.status === 200) {
@@ -576,20 +565,13 @@ async function playMediaFile(fileName) {
if (playInProgress) return;
playInProgress = true;
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
const response = await fetch('/api/browser/play', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ path: absolutePath })
});
@@ -610,15 +592,11 @@ export async function playAllFolder() {
const btn = document.getElementById('playAllBtn');
if (btn) btn.disabled = true;
try {
const token = localStorage.getItem('media_server_token');
if (!token || !currentFolderId) return;
if (!hasCredentials() || !currentFolderId) return;
const response = await fetch('/api/browser/play-folder', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ folder_id: currentFolderId, path: currentPath })
});
@@ -640,8 +618,7 @@ export async function playAllFolder() {
export async function downloadFile(fileName, event) {
if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token');
if (!token) return;
if (!hasCredentials()) return;
const fullPath = currentPath === '/'
? '/' + fileName
@@ -651,7 +628,7 @@ export async function downloadFile(fileName, event) {
try {
const response = await fetch(
`/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
{ headers: getAuthHeaders() }
);
if (!response.ok) throw new Error('Download failed');
+5 -15
View File
@@ -2,7 +2,7 @@
// Callbacks: CRUD management
// ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm } from './core.js';
import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
export let callbackFormDirty = false;
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
@@ -16,12 +16,11 @@ export async function loadCallbacksTable() {
}
async function _loadCallbacksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('callbacksTableBody');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) {
@@ -79,13 +78,12 @@ export function showAddCallbackDialog() {
}
export async function showEditCallbackDialog(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('callbackDialog');
const title = document.getElementById('callbackDialogTitle');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) {
@@ -137,7 +135,6 @@ export async function saveCallback(event) {
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
const callbackName = document.getElementById('callbackName').value;
@@ -157,10 +154,7 @@ export async function saveCallback(event) {
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(data)
});
@@ -187,14 +181,10 @@ export async function deleteCallbackConfirm(callbackName) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
headers: getAuthHeaders()
});
const result = await response.json();
+27 -8
View File
@@ -300,8 +300,7 @@ function updateAllText() {
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
}
const token = localStorage.getItem('media_server_token');
if (token) {
if (hasCredentials()) {
if (_loadScriptsTable) _loadScriptsTable();
if (_loadCallbacksTable) _loadCallbacksTable();
if (_loadLinksTable) _loadLinksTable();
@@ -396,19 +395,39 @@ export function showConfirm(message) {
});
}
// ============================================================
// Auth Helpers
// ============================================================
// Set to false when server reports auth_required: false
export let authRequired = true;
export function setAuthRequired(value) { authRequired = value; }
/**
* Build Authorization headers for API requests.
* Returns empty object when auth is disabled or no token is stored.
*/
export function getAuthHeaders() {
const token = localStorage.getItem('media_server_token');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
/**
* Check if we have sufficient credentials to call the API.
* True when auth is disabled OR a token is stored.
*/
export function hasCredentials() {
return !authRequired || !!localStorage.getItem('media_server_token');
}
// ============================================================
// API Commands
// ============================================================
export async function sendCommand(endpoint, body = null) {
const token = localStorage.getItem('media_server_token');
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
};
if (body) {
+11 -31
View File
@@ -2,21 +2,20 @@
// Display Brightness & Power Control + Links Management
// ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon } from './core.js';
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
let displayBrightnessTimers = {};
const DISPLAY_THROTTLE_MS = 50;
export async function loadDisplayMonitors() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
if (!hasCredentials()) return;
const container = document.getElementById('displayMonitors');
if (!container) return;
try {
const response = await fetch('/api/display/monitors?refresh=true', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) {
@@ -108,14 +107,10 @@ export function onDisplayBrightnessChange(monitorId, value) {
}
async function sendDisplayBrightness(monitorId, brightness) {
const token = localStorage.getItem('media_server_token');
try {
await fetch(`/api/display/brightness/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ brightness })
});
} catch (e) {
@@ -128,14 +123,10 @@ export async function toggleDisplayPower(monitorId, monitorName) {
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/display/power/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ on: newState })
});
const data = await response.json();
@@ -160,15 +151,14 @@ export async function toggleDisplayPower(monitorId, monitorName) {
// ============================================================
export async function loadHeaderLinks() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
if (!hasCredentials()) return;
const container = document.getElementById('headerLinks');
if (!container) return;
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) return;
@@ -210,12 +200,11 @@ export async function loadLinksTable() {
}
async function _loadLinksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('linksTableBody');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) {
@@ -273,13 +262,12 @@ export function showAddLinkDialog() {
}
export async function showEditLinkDialog(linkName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) {
@@ -342,7 +330,6 @@ export async function saveLink(event) {
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('linkIsEdit').value === 'true';
const linkName = isEdit ?
document.getElementById('linkOriginalName').value :
@@ -364,10 +351,7 @@ export async function saveLink(event) {
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(data)
});
@@ -393,14 +377,10 @@ export async function deleteLinkConfirm(linkName) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
headers: getAuthHeaders()
});
const result = await response.json();
+6 -13
View File
@@ -9,6 +9,7 @@ import {
currentPosition, setCurrentPosition, isUserAdjustingVolume,
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek,
getAuthHeaders, hasCredentials,
} from './core.js';
import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js';
@@ -282,9 +283,8 @@ const VISUALIZER_SMOOTHING = 0.15;
export async function checkVisualizerAvailability() {
try {
const token = localStorage.getItem('media_server_token');
const resp = await fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (resp.ok) {
const data = await resp.json();
@@ -428,14 +428,12 @@ export async function loadAudioDevices() {
if (!section || !select) return;
try {
const token = localStorage.getItem('media_server_token');
const [devicesResp, statusResp] = await Promise.all([
fetch('/api/media/visualizer/devices', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
}),
fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
})
]);
@@ -495,15 +493,11 @@ export async function onAudioDeviceChanged() {
if (!select) return;
const deviceName = select.value || null;
const token = localStorage.getItem('media_server_token');
try {
const resp = await fetch('/api/media/visualizer/device', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ device_name: deviceName })
});
@@ -612,9 +606,8 @@ export function updateUI(status) {
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
if (artworkSource) {
const token = localStorage.getItem('media_server_token');
fetch(`/api/media/artwork?_=${Date.now()}`, {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
})
.then(r => r.ok ? r.blob() : null)
.then(blob => {
+11 -37
View File
@@ -6,19 +6,16 @@ import {
t, showToast, escapeHtml, closeDialog, showConfirm,
resolveMdiIcons, fetchMdiIcon,
scripts, setScripts,
getAuthHeaders, hasCredentials,
} from './core.js';
export let scriptFormDirty = false;
export function setScriptFormDirty(value) { scriptFormDirty = value; }
export async function loadScripts() {
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch('/api/scripts/list', {
headers: {
'Authorization': `Bearer ${token}`
}
headers: getAuthHeaders()
});
if (response.ok) {
@@ -67,10 +64,9 @@ export async function displayQuickAccess() {
});
try {
const token = localStorage.getItem('media_server_token');
if (token) {
if (hasCredentials()) {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (gen !== _quickAccessGen) return;
if (response.ok) {
@@ -124,16 +120,12 @@ export async function displayQuickAccess() {
}
async function executeScript(scriptName, buttonElement) {
const token = localStorage.getItem('media_server_token');
buttonElement.classList.add('executing');
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ args: [] })
});
@@ -165,12 +157,11 @@ export async function loadScriptsTable() {
}
async function _loadScriptsTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('scriptsTableBody');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) {
@@ -232,13 +223,12 @@ export function showAddScriptDialog() {
}
export async function showEditScriptDialog(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('scriptDialog');
const title = document.getElementById('dialogTitle');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
headers: getAuthHeaders()
});
if (!response.ok) {
@@ -300,7 +290,6 @@ export async function saveScript(event) {
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
const scriptName = isEdit ?
document.getElementById('scriptOriginalName').value :
@@ -324,10 +313,7 @@ export async function saveScript(event) {
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(data)
});
@@ -353,14 +339,10 @@ export async function deleteScriptConfirm(scriptName) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
headers: getAuthHeaders()
});
const result = await response.json();
@@ -443,7 +425,6 @@ function showExecutionResult(name, result, type = 'script') {
}
export async function executeScriptDebug(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
@@ -463,10 +444,7 @@ export async function executeScriptDebug(scriptName) {
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ args: [] })
});
@@ -494,7 +472,6 @@ export async function executeScriptDebug(scriptName) {
}
export async function executeCallbackDebug(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
@@ -514,10 +491,7 @@ export async function executeCallbackDebug(callbackName) {
try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
});
const result = await response.json();
+7 -5
View File
@@ -6,6 +6,7 @@ import {
dom, t, showToast, setWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired,
} from './core.js';
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
@@ -62,7 +63,8 @@ export function connectWebSocket(token) {
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl);
setWs(newWs);
@@ -134,8 +136,8 @@ export function connectWebSocket(token) {
reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
connectWebSocket(savedToken);
if (savedToken || !authRequired) {
connectWebSocket(savedToken || '');
}
}, delay);
} else {
@@ -175,9 +177,9 @@ function hideConnectionBanner() {
export function manualReconnect() {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
if (savedToken || !authRequired) {
wsReconnectAttempts = 0;
hideConnectionBanner();
connectWebSocket(savedToken);
connectWebSocket(savedToken || '');
}
}