diff --git a/custom_components/wled_screen_controller/config_flow.py b/custom_components/wled_screen_controller/config_flow.py index 7ec453d..b05e30e 100644 --- a/custom_components/wled_screen_controller/config_flow.py +++ b/custom_components/wled_screen_controller/config_flow.py @@ -21,7 +21,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str, vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str, - vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_API_KEY, default=""): str, } ) @@ -57,21 +57,25 @@ async def validate_server( except aiohttp.ClientError as err: raise ConnectionError(f"Cannot connect to server: {err}") from err - # Step 2: Validate API key via authenticated endpoint - headers = {"Authorization": f"Bearer {api_key}"} - try: - async with session.get( - f"{server_url}/api/v1/output-targets", - headers=headers, - timeout=timeout, - ) as resp: - if resp.status == 401: - raise PermissionError("Invalid API key") - resp.raise_for_status() - except PermissionError: - raise - except aiohttp.ClientError as err: - raise ConnectionError(f"API request failed: {err}") from err + # Step 2: Validate API key via authenticated endpoint (skip if no key and auth not required) + auth_required = data.get("auth_required", True) + if api_key: + headers = {"Authorization": f"Bearer {api_key}"} + try: + async with session.get( + f"{server_url}/api/v1/output-targets", + headers=headers, + timeout=timeout, + ) as resp: + if resp.status == 401: + raise PermissionError("Invalid API key") + resp.raise_for_status() + except PermissionError: + raise + except aiohttp.ClientError as err: + raise ConnectionError(f"API request failed: {err}") from err + elif auth_required: + raise PermissionError("Server requires an API key") return {"version": version} diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py index 5f25eb4..d9d0ea0 100644 --- a/custom_components/wled_screen_controller/coordinator.py +++ b/custom_components/wled_screen_controller/coordinator.py @@ -36,7 +36,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): self.session = session self.api_key = api_key self.server_version = "unknown" - self._auth_headers = {"Authorization": f"Bearer {api_key}"} + self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT) super().__init__( diff --git a/server/CLAUDE.md b/server/CLAUDE.md index ad85824..bb01357 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -23,7 +23,8 @@ Server uses API key authentication via Bearer token in `Authorization` header. - Config: `config/default_config.yaml` under `auth.api_keys` - Env var: `WLED_AUTH__API_KEYS` -- Dev key: `development-key-change-in-production` +- When `api_keys` is empty (default), auth is disabled — all endpoints are open +- To enable auth, add key entries (e.g. `dev: "your-secret-key"`) ## Common Tasks diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index f051ed7..d337e5d 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -8,11 +8,11 @@ server: - "http://localhost:8080" auth: - # API keys are REQUIRED - authentication is always enforced - # Format: label: "api-key" + # API keys — when empty, authentication is disabled (open access). + # To enable auth, add one or more label: "api-key" entries. + # Generate secure keys: openssl rand -hex 32 api_keys: - # Generate secure keys: openssl rand -hex 32 - dev: "development-key-change-in-production" # Development key - CHANGE THIS! + dev: "development-key-change-in-production" storage: devices_file: "data/devices.json" diff --git a/server/src/wled_controller/api/auth.py b/server/src/wled_controller/api/auth.py index c3a9e75..ad51998 100644 --- a/server/src/wled_controller/api/auth.py +++ b/server/src/wled_controller/api/auth.py @@ -15,11 +15,19 @@ logger = get_logger(__name__) security = HTTPBearer(auto_error=False) +def is_auth_enabled() -> bool: + """Return True when at least one API key is configured.""" + return bool(get_config().auth.api_keys) + + def verify_api_key( credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)] ) -> str: """Verify API key from Authorization header. + When no API keys are configured, authentication is disabled and all + requests are allowed through as "anonymous". + Args: credentials: HTTP authorization credentials @@ -31,6 +39,10 @@ def verify_api_key( """ config = get_config() + # No keys configured → auth disabled, allow all requests + if not config.auth.api_keys: + return "anonymous" + # Check if credentials are provided if not credentials: logger.warning("Request missing Authorization header") @@ -43,14 +55,6 @@ def verify_api_key( # Extract token token = credentials.credentials - # Verify against configured API keys - if not config.auth.api_keys: - logger.error("No API keys configured - server misconfiguration") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Server authentication not configured properly", - ) - # Find matching key and return its label using constant-time comparison authenticated_as = None for label, api_key in config.auth.api_keys.items(): @@ -80,10 +84,14 @@ AuthRequired = Annotated[str, Depends(verify_api_key)] def verify_ws_token(token: str) -> bool: """Check a WebSocket query-param token against configured API keys. - Use this for WebSocket endpoints where FastAPI's Depends() isn't available. + When no API keys are configured, authentication is disabled and all + WebSocket connections are allowed. """ config = get_config() - if token and config.auth.api_keys: + # No keys configured → auth disabled, allow all connections + if not config.auth.api_keys: + return True + if token: for _label, api_key in config.auth.api_keys.items(): if secrets.compare_digest(token, api_key): return True diff --git a/server/src/wled_controller/api/routes/backup.py b/server/src/wled_controller/api/routes/backup.py index 4b486ea..6b5eea7 100644 --- a/server/src/wled_controller/api/routes/backup.py +++ b/server/src/wled_controller/api/routes/backup.py @@ -27,6 +27,7 @@ from wled_controller.api.schemas.system import ( ) from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.config import get_config +from wled_controller.storage.base_store import freeze_saves from wled_controller.utils import atomic_write_json, get_logger logger = get_logger(__name__) @@ -175,6 +176,7 @@ async def import_store( return len(incoming) count = await asyncio.to_thread(_write) + freeze_saves() logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...") _schedule_restart() return { @@ -269,6 +271,27 @@ async def restore_config( if not isinstance(stores[key], dict): raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object") + # Guard: reject backups where every store is empty (version key only, no entities). + # This prevents accidental data wipes from restoring a backup taken when the + # server had no data loaded. + total_entities = 0 + for key in present_keys: + store_data = stores[key] + for field_key, field_val in store_data.items(): + if field_key != "version" and isinstance(field_val, dict): + total_entities += len(field_val) + if total_entities == 0: + raise HTTPException( + status_code=400, + detail="Backup contains no entity data (all stores are empty). Aborting to prevent data loss.", + ) + + # Log missing stores as warnings + missing = known_keys - present_keys + if missing: + for store_key in sorted(missing): + logger.warning(f"Restore: backup is missing store '{store_key}' — existing data will be kept") + # Write store files atomically (in thread to avoid blocking event loop) config = get_config() @@ -284,10 +307,13 @@ async def restore_config( written = await asyncio.to_thread(_write_stores) + # Freeze all store saves so the old process can't overwrite restored files + # with stale in-memory data before the restart completes. + freeze_saves() + logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...") _schedule_restart() - missing = known_keys - present_keys return RestoreResponse( status="restored", stores_written=written, diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index 198719c..d82dac3 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -14,7 +14,7 @@ import psutil from fastapi import APIRouter, Depends, HTTPException, Query from wled_controller import __version__ -from wled_controller.api.auth import AuthRequired +from wled_controller.api.auth import AuthRequired, is_auth_enabled from wled_controller.api.dependencies import ( get_audio_source_store, get_audio_template_store, @@ -107,6 +107,7 @@ async def health_check(): timestamp=datetime.now(timezone.utc), version=__version__, demo_mode=get_config().demo, + auth_required=is_auth_enabled(), ) diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py index 2a32e40..80cfa1f 100644 --- a/server/src/wled_controller/api/schemas/system.py +++ b/server/src/wled_controller/api/schemas/system.py @@ -13,6 +13,7 @@ class HealthResponse(BaseModel): timestamp: datetime = Field(description="Current server time") version: str = Field(description="Application version") demo_mode: bool = Field(default=False, description="Whether demo mode is active") + auth_required: bool = Field(default=True, description="Whether API key authentication is required") class VersionResponse(BaseModel): diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index 43a619a..3ba8448 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -21,7 +21,7 @@ class ServerConfig(BaseSettings): class AuthConfig(BaseSettings): """Authentication configuration.""" - api_keys: dict[str, str] = {} # label: key mapping (required for security) + api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled) class StorageConfig(BaseSettings): diff --git a/server/src/wled_controller/core/backup/auto_backup.py b/server/src/wled_controller/core/backup/auto_backup.py index 9f8e372..2a82912 100644 --- a/server/src/wled_controller/core/backup/auto_backup.py +++ b/server/src/wled_controller/core/backup/auto_backup.py @@ -3,7 +3,7 @@ import asyncio import json import os -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional @@ -18,6 +18,11 @@ DEFAULT_SETTINGS = { "max_backups": 10, } +# Skip the immediate-on-start backup if a recent backup exists within this window. +# Prevents rapid restarts from flooding the backup directory and rotating out +# good backups. +_STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5) + class AutoBackupEngine: """Creates periodic backups of all configuration stores.""" @@ -83,11 +88,28 @@ class AutoBackupEngine: self._task.cancel() self._task = None + def _most_recent_backup_age(self) -> timedelta | None: + """Return the age of the newest backup file, or None if no backups exist.""" + files = list(self._backup_dir.glob("*.json")) + if not files: + return None + newest = max(files, key=lambda p: p.stat().st_mtime) + mtime = datetime.fromtimestamp(newest.stat().st_mtime, tz=timezone.utc) + return datetime.now(timezone.utc) - mtime + async def _backup_loop(self) -> None: try: - # Perform first backup immediately on start - await self._perform_backup() - self._prune_old_backups() + # Skip immediate backup if a recent one already exists. + # Prevents rapid restarts (crashes, restores) from flooding the + # backup directory and rotating out good backups. + age = self._most_recent_backup_age() + if age is None or age > _STARTUP_BACKUP_COOLDOWN: + await self._perform_backup() + self._prune_old_backups() + else: + logger.info( + f"Skipping startup backup — most recent is only {age.total_seconds():.0f}s old" + ) interval_secs = self._settings["interval_hours"] * 3600 while True: @@ -133,8 +155,7 @@ class AutoBackupEngine: filename = f"ledgrab-autobackup-{timestamp}.json" file_path = self._backup_dir / filename - content = json.dumps(backup, indent=2, ensure_ascii=False) - file_path.write_text(content, encoding="utf-8") + atomic_write_json(file_path, backup) self._last_backup_time = now logger.info(f"Auto-backup created: {filename}") @@ -156,7 +177,6 @@ class AutoBackupEngine: def get_settings(self) -> dict: next_backup = None if self._settings["enabled"] and self._last_backup_time: - from datetime import timedelta next_backup = ( self._last_backup_time + timedelta(hours=self._settings["interval_hours"]) ).isoformat() diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index c1d8b91..48f0ead 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -103,23 +103,13 @@ async def lifespan(app: FastAPI): print(f" Open http://localhost:{config.server.port} in your browser") print(" =============================================\n") - # Validate authentication configuration + # Log authentication mode if not config.auth.api_keys: - logger.error("=" * 70) - logger.error("CRITICAL: No API keys configured!") - logger.error("Authentication is REQUIRED for all API requests.") - logger.error("Please add API keys to your configuration:") - logger.error(" 1. Generate keys: openssl rand -hex 32") - logger.error(" 2. Add to config/default_config.yaml under auth.api_keys") - logger.error(" 3. Format: label: \"your-generated-key\"") - logger.error("=" * 70) - raise RuntimeError("No API keys configured - server cannot start without authentication") - - # Log authentication status - logger.info(f"API Authentication: ENFORCED ({len(config.auth.api_keys)} clients configured)") - client_labels = ", ".join(config.auth.api_keys.keys()) - logger.info(f"Authorized clients: {client_labels}") - logger.info("All API requests require valid Bearer token authentication") + logger.info("Authentication disabled (no API keys configured)") + else: + logger.info(f"Authentication enabled ({len(config.auth.api_keys)} API key(s) configured)") + client_labels = ", ".join(config.auth.api_keys.keys()) + logger.info(f"Authorized clients: {client_labels}") # Create MQTT service (shared broker connection) mqtt_service = MQTTService(config.mqtt) @@ -144,6 +134,21 @@ async def lifespan(app: FastAPI): storage_config=config.storage, ) + # Verify STORE_MAP covers all StorageConfig file fields. + # Catches missed additions early (at startup) rather than silently + # excluding new stores from backups. + storage_attrs = { + attr for attr in config.storage.model_fields + if attr.endswith("_file") + } + mapped_attrs = set(STORE_MAP.values()) + unmapped = storage_attrs - mapped_attrs + if unmapped: + logger.warning( + f"StorageConfig fields not in STORE_MAP (missing from backups): " + f"{sorted(unmapped)}" + ) + # Initialize API dependencies init_dependencies( device_store, template_store, processor_manager, diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index 001d54d..dd5467d 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -3,7 +3,7 @@ */ // Layer 0: state -import { apiKey, setApiKey, refreshInterval } from './core/state.ts'; +import { apiKey, setApiKey, authRequired, refreshInterval } from './core/state.ts'; import { Modal } from './core/modal.ts'; import { queryEl } from './core/dom-utils.ts'; @@ -180,6 +180,9 @@ import { import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts'; import { navigateToCard } from './core/navigation.ts'; import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts'; +import { + applyStylePreset, applyBgEffect, renderAppearanceTab, initAppearance, +} from './features/appearance.ts'; import { openSettingsModal, closeSettingsModal, switchSettingsTab, downloadBackup, handleRestoreFileSelected, @@ -548,6 +551,11 @@ Object.assign(window, { setLogLevel, saveExternalUrl, getBaseOrigin, + + // appearance + applyStylePreset, + applyBgEffect, + renderAppearanceTab, }); // ─── Global keyboard shortcuts ─── @@ -626,6 +634,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize visual effects initCardGlare(); initBgAnim(); + initAppearance(); initTabIndicator(); updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light'); const accent = localStorage.getItem('accentColor') || '#4CAF50'; @@ -659,11 +668,15 @@ document.addEventListener('DOMContentLoaded', async () => { if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice); // Always monitor server connection (even before login) - loadServerInfo(); + await loadServerInfo(); startConnectionMonitor(); - // Show modal if no API key is stored - if (!apiKey) { + // Expose auth state for inline scripts (after loadServerInfo sets it) + (window as any)._authRequired = authRequired; + if (typeof window.updateAuthUI === 'function') window.updateAuthUI(); + + // Show login modal only when auth is enabled and no API key is stored + if (authRequired && !apiKey) { setTimeout(() => { if (typeof window.showApiKeyModal === 'function') { window.showApiKeyModal(null, true); diff --git a/server/src/wled_controller/static/js/core/api.ts b/server/src/wled_controller/static/js/core/api.ts index feae854..9c12189 100644 --- a/server/src/wled_controller/static/js/core/api.ts +++ b/server/src/wled_controller/static/js/core/api.ts @@ -2,7 +2,7 @@ * API utilities — base URL, auth headers, fetch wrapper, helpers. */ -import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts'; +import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts'; import { t } from './i18n.ts'; import { showToast } from './ui.ts'; import { getEl, queryEl } from './dom-utils.ts'; @@ -137,6 +137,7 @@ export function isGameSenseDevice(type: string) { } export function handle401Error() { + if (!authRequired) return; // Auth disabled — ignore 401s if (!apiKey) return; // Already handled or no session localStorage.removeItem('wled_api_key'); setApiKey(null); @@ -200,6 +201,11 @@ export async function loadServerInfo() { window.dispatchEvent(new CustomEvent('server:reconnected')); } + // Auth mode detection + const authNeeded = data.auth_required !== false; + setAuthRequired(authNeeded); + (window as any)._authRequired = authNeeded; + // Demo mode detection if (data.demo_mode && !demoMode) { demoMode = true; diff --git a/server/src/wled_controller/static/js/core/events-ws.ts b/server/src/wled_controller/static/js/core/events-ws.ts index 4368895..74718d5 100644 --- a/server/src/wled_controller/static/js/core/events-ws.ts +++ b/server/src/wled_controller/static/js/core/events-ws.ts @@ -9,7 +9,7 @@ * server:device_health_changed — device online/offline status change */ -import { apiKey } from './state.ts'; +import { apiKey, authRequired } from './state.ts'; let _ws: WebSocket | null = null; let _reconnectTimer: ReturnType | null = null; @@ -19,10 +19,10 @@ const _RECONNECT_MAX = 30000; export function startEventsWS() { stopEventsWS(); - if (!apiKey) return; + if (authRequired && !apiKey) return; const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey)}`; + const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey || '')}`; try { _ws = new WebSocket(url); diff --git a/server/src/wled_controller/static/js/core/state.ts b/server/src/wled_controller/static/js/core/state.ts index 6448bf0..a01fd15 100644 --- a/server/src/wled_controller/static/js/core/state.ts +++ b/server/src/wled_controller/static/js/core/state.ts @@ -18,6 +18,9 @@ import type { export let apiKey: string | null = null; export function setApiKey(v: string | null) { apiKey = v; } +export let authRequired = true; +export function setAuthRequired(v: boolean) { authRequired = v; } + export let refreshInterval: ReturnType | null = null; export function setRefreshInterval(v: ReturnType | null) { refreshInterval = v; } diff --git a/server/src/wled_controller/static/js/global.d.ts b/server/src/wled_controller/static/js/global.d.ts index 1258951..fd7de2c 100644 --- a/server/src/wled_controller/static/js/global.d.ts +++ b/server/src/wled_controller/static/js/global.d.ts @@ -18,6 +18,7 @@ interface Window { // ─── Core / state ─── setApiKey: (key: string | null) => void; + _authRequired: boolean | undefined; // ─── Visual effects (called from inline