feat: optional auth + backup/restore reliability fixes
Some checks failed
Lint & Test / test (push) Failing after 29s

Auth is now optional: when `auth.api_keys` is empty, all endpoints are
open (no login screen, no Bearer tokens). Health endpoint reports
`auth_required` so the frontend knows which mode to use.

Backup/restore fixes:
- Auto-backup uses atomic writes (was `write_text`, risked corruption)
- Startup backup skipped if recent backup exists (<5 min cooldown),
  preventing rapid restarts from rotating out good backups
- Restore rejects all-empty backups to prevent accidental data wipes
- Store saves frozen after restore to prevent stale in-memory data
  from overwriting freshly-restored files before restart completes
- Missing stores during restore logged as warnings
- STORE_MAP completeness verified at startup against StorageConfig
This commit is contained in:
2026-03-23 14:50:25 +03:00
parent cd3137b0ec
commit 4975a74ff3
18 changed files with 189 additions and 67 deletions

View File

@@ -21,7 +21,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str, vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): 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,7 +57,9 @@ async def validate_server(
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ConnectionError(f"Cannot connect to server: {err}") from err raise ConnectionError(f"Cannot connect to server: {err}") from err
# Step 2: Validate API key via authenticated endpoint # 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}"} headers = {"Authorization": f"Bearer {api_key}"}
try: try:
async with session.get( async with session.get(
@@ -72,6 +74,8 @@ async def validate_server(
raise raise
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ConnectionError(f"API request failed: {err}") from err raise ConnectionError(f"API request failed: {err}") from err
elif auth_required:
raise PermissionError("Server requires an API key")
return {"version": version} return {"version": version}

View File

@@ -36,7 +36,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
self.session = session self.session = session
self.api_key = api_key self.api_key = api_key
self.server_version = "unknown" 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) self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__( super().__init__(

View File

@@ -23,7 +23,8 @@ Server uses API key authentication via Bearer token in `Authorization` header.
- Config: `config/default_config.yaml` under `auth.api_keys` - Config: `config/default_config.yaml` under `auth.api_keys`
- Env var: `WLED_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 ## Common Tasks

View File

@@ -8,11 +8,11 @@ server:
- "http://localhost:8080" - "http://localhost:8080"
auth: auth:
# API keys are REQUIRED - authentication is always enforced # API keys — when empty, authentication is disabled (open access).
# Format: label: "api-key" # To enable auth, add one or more label: "api-key" entries.
api_keys:
# Generate secure keys: openssl rand -hex 32 # Generate secure keys: openssl rand -hex 32
dev: "development-key-change-in-production" # Development key - CHANGE THIS! api_keys:
dev: "development-key-change-in-production"
storage: storage:
devices_file: "data/devices.json" devices_file: "data/devices.json"

View File

@@ -15,11 +15,19 @@ logger = get_logger(__name__)
security = HTTPBearer(auto_error=False) 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( def verify_api_key(
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)] credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
) -> str: ) -> str:
"""Verify API key from Authorization header. """Verify API key from Authorization header.
When no API keys are configured, authentication is disabled and all
requests are allowed through as "anonymous".
Args: Args:
credentials: HTTP authorization credentials credentials: HTTP authorization credentials
@@ -31,6 +39,10 @@ def verify_api_key(
""" """
config = get_config() config = get_config()
# No keys configured → auth disabled, allow all requests
if not config.auth.api_keys:
return "anonymous"
# Check if credentials are provided # Check if credentials are provided
if not credentials: if not credentials:
logger.warning("Request missing Authorization header") logger.warning("Request missing Authorization header")
@@ -43,14 +55,6 @@ def verify_api_key(
# Extract token # Extract token
token = credentials.credentials 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 # Find matching key and return its label using constant-time comparison
authenticated_as = None authenticated_as = None
for label, api_key in config.auth.api_keys.items(): 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: def verify_ws_token(token: str) -> bool:
"""Check a WebSocket query-param token against configured API keys. """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() 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(): for _label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key): if secrets.compare_digest(token, api_key):
return True return True

View File

@@ -27,6 +27,7 @@ from wled_controller.api.schemas.system import (
) )
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config 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 from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -175,6 +176,7 @@ async def import_store(
return len(incoming) return len(incoming)
count = await asyncio.to_thread(_write) count = await asyncio.to_thread(_write)
freeze_saves()
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...") logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart() _schedule_restart()
return { return {
@@ -269,6 +271,27 @@ async def restore_config(
if not isinstance(stores[key], dict): if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object") 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) # Write store files atomically (in thread to avoid blocking event loop)
config = get_config() config = get_config()
@@ -284,10 +307,13 @@ async def restore_config(
written = await asyncio.to_thread(_write_stores) 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...") logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart() _schedule_restart()
missing = known_keys - present_keys
return RestoreResponse( return RestoreResponse(
status="restored", status="restored",
stores_written=written, stores_written=written,

View File

@@ -14,7 +14,7 @@ import psutil
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__ 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 ( from wled_controller.api.dependencies import (
get_audio_source_store, get_audio_source_store,
get_audio_template_store, get_audio_template_store,
@@ -107,6 +107,7 @@ async def health_check():
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
version=__version__, version=__version__,
demo_mode=get_config().demo, demo_mode=get_config().demo,
auth_required=is_auth_enabled(),
) )

View File

@@ -13,6 +13,7 @@ class HealthResponse(BaseModel):
timestamp: datetime = Field(description="Current server time") timestamp: datetime = Field(description="Current server time")
version: str = Field(description="Application version") version: str = Field(description="Application version")
demo_mode: bool = Field(default=False, description="Whether demo mode is active") 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): class VersionResponse(BaseModel):

View File

@@ -21,7 +21,7 @@ class ServerConfig(BaseSettings):
class AuthConfig(BaseSettings): class AuthConfig(BaseSettings):
"""Authentication configuration.""" """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): class StorageConfig(BaseSettings):

View File

@@ -3,7 +3,7 @@
import asyncio import asyncio
import json import json
import os import os
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -18,6 +18,11 @@ DEFAULT_SETTINGS = {
"max_backups": 10, "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: class AutoBackupEngine:
"""Creates periodic backups of all configuration stores.""" """Creates periodic backups of all configuration stores."""
@@ -83,11 +88,28 @@ class AutoBackupEngine:
self._task.cancel() self._task.cancel()
self._task = None 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: async def _backup_loop(self) -> None:
try: try:
# Perform first backup immediately on start # 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() await self._perform_backup()
self._prune_old_backups() 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 interval_secs = self._settings["interval_hours"] * 3600
while True: while True:
@@ -133,8 +155,7 @@ class AutoBackupEngine:
filename = f"ledgrab-autobackup-{timestamp}.json" filename = f"ledgrab-autobackup-{timestamp}.json"
file_path = self._backup_dir / filename file_path = self._backup_dir / filename
content = json.dumps(backup, indent=2, ensure_ascii=False) atomic_write_json(file_path, backup)
file_path.write_text(content, encoding="utf-8")
self._last_backup_time = now self._last_backup_time = now
logger.info(f"Auto-backup created: {filename}") logger.info(f"Auto-backup created: {filename}")
@@ -156,7 +177,6 @@ class AutoBackupEngine:
def get_settings(self) -> dict: def get_settings(self) -> dict:
next_backup = None next_backup = None
if self._settings["enabled"] and self._last_backup_time: if self._settings["enabled"] and self._last_backup_time:
from datetime import timedelta
next_backup = ( next_backup = (
self._last_backup_time + timedelta(hours=self._settings["interval_hours"]) self._last_backup_time + timedelta(hours=self._settings["interval_hours"])
).isoformat() ).isoformat()

View File

@@ -103,23 +103,13 @@ async def lifespan(app: FastAPI):
print(f" Open http://localhost:{config.server.port} in your browser") print(f" Open http://localhost:{config.server.port} in your browser")
print(" =============================================\n") print(" =============================================\n")
# Validate authentication configuration # Log authentication mode
if not config.auth.api_keys: if not config.auth.api_keys:
logger.error("=" * 70) logger.info("Authentication disabled (no API keys configured)")
logger.error("CRITICAL: No API keys configured!") else:
logger.error("Authentication is REQUIRED for all API requests.") logger.info(f"Authentication enabled ({len(config.auth.api_keys)} API key(s) configured)")
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()) client_labels = ", ".join(config.auth.api_keys.keys())
logger.info(f"Authorized clients: {client_labels}") logger.info(f"Authorized clients: {client_labels}")
logger.info("All API requests require valid Bearer token authentication")
# Create MQTT service (shared broker connection) # Create MQTT service (shared broker connection)
mqtt_service = MQTTService(config.mqtt) mqtt_service = MQTTService(config.mqtt)
@@ -144,6 +134,21 @@ async def lifespan(app: FastAPI):
storage_config=config.storage, 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 # Initialize API dependencies
init_dependencies( init_dependencies(
device_store, template_store, processor_manager, device_store, template_store, processor_manager,

View File

@@ -3,7 +3,7 @@
*/ */
// Layer 0: state // 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 { Modal } from './core/modal.ts';
import { queryEl } from './core/dom-utils.ts'; import { queryEl } from './core/dom-utils.ts';
@@ -180,6 +180,9 @@ import {
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts'; import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
import { navigateToCard } from './core/navigation.ts'; import { navigateToCard } from './core/navigation.ts';
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts'; import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
import {
applyStylePreset, applyBgEffect, renderAppearanceTab, initAppearance,
} from './features/appearance.ts';
import { import {
openSettingsModal, closeSettingsModal, switchSettingsTab, openSettingsModal, closeSettingsModal, switchSettingsTab,
downloadBackup, handleRestoreFileSelected, downloadBackup, handleRestoreFileSelected,
@@ -548,6 +551,11 @@ Object.assign(window, {
setLogLevel, setLogLevel,
saveExternalUrl, saveExternalUrl,
getBaseOrigin, getBaseOrigin,
// appearance
applyStylePreset,
applyBgEffect,
renderAppearanceTab,
}); });
// ─── Global keyboard shortcuts ─── // ─── Global keyboard shortcuts ───
@@ -626,6 +634,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize visual effects // Initialize visual effects
initCardGlare(); initCardGlare();
initBgAnim(); initBgAnim();
initAppearance();
initTabIndicator(); initTabIndicator();
updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light'); updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light');
const accent = localStorage.getItem('accentColor') || '#4CAF50'; const accent = localStorage.getItem('accentColor') || '#4CAF50';
@@ -659,11 +668,15 @@ document.addEventListener('DOMContentLoaded', async () => {
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice); if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
// Always monitor server connection (even before login) // Always monitor server connection (even before login)
loadServerInfo(); await loadServerInfo();
startConnectionMonitor(); startConnectionMonitor();
// Show modal if no API key is stored // Expose auth state for inline scripts (after loadServerInfo sets it)
if (!apiKey) { (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(() => { setTimeout(() => {
if (typeof window.showApiKeyModal === 'function') { if (typeof window.showApiKeyModal === 'function') {
window.showApiKeyModal(null, true); window.showApiKeyModal(null, true);

View File

@@ -2,7 +2,7 @@
* API utilities — base URL, auth headers, fetch wrapper, helpers. * 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 { t } from './i18n.ts';
import { showToast } from './ui.ts'; import { showToast } from './ui.ts';
import { getEl, queryEl } from './dom-utils.ts'; import { getEl, queryEl } from './dom-utils.ts';
@@ -137,6 +137,7 @@ export function isGameSenseDevice(type: string) {
} }
export function handle401Error() { export function handle401Error() {
if (!authRequired) return; // Auth disabled — ignore 401s
if (!apiKey) return; // Already handled or no session if (!apiKey) return; // Already handled or no session
localStorage.removeItem('wled_api_key'); localStorage.removeItem('wled_api_key');
setApiKey(null); setApiKey(null);
@@ -200,6 +201,11 @@ export async function loadServerInfo() {
window.dispatchEvent(new CustomEvent('server:reconnected')); 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 // Demo mode detection
if (data.demo_mode && !demoMode) { if (data.demo_mode && !demoMode) {
demoMode = true; demoMode = true;

View File

@@ -9,7 +9,7 @@
* server:device_health_changed — device online/offline status change * 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 _ws: WebSocket | null = null;
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null; let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -19,10 +19,10 @@ const _RECONNECT_MAX = 30000;
export function startEventsWS() { export function startEventsWS() {
stopEventsWS(); stopEventsWS();
if (!apiKey) return; if (authRequired && !apiKey) return;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; 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 { try {
_ws = new WebSocket(url); _ws = new WebSocket(url);

View File

@@ -18,6 +18,9 @@ import type {
export let apiKey: string | null = null; export let apiKey: string | null = null;
export function setApiKey(v: string | null) { apiKey = v; } export function setApiKey(v: string | null) { apiKey = v; }
export let authRequired = true;
export function setAuthRequired(v: boolean) { authRequired = v; }
export let refreshInterval: ReturnType<typeof setInterval> | null = null; export let refreshInterval: ReturnType<typeof setInterval> | null = null;
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; } export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }

View File

@@ -18,6 +18,7 @@ interface Window {
// ─── Core / state ─── // ─── Core / state ───
setApiKey: (key: string | null) => void; setApiKey: (key: string | null) => void;
_authRequired: boolean | undefined;
// ─── Visual effects (called from inline <script>) ─── // ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void; _updateBgAnimAccent: (accent: string) => void;
@@ -372,6 +373,11 @@ interface Window {
saveExternalUrl: (...args: any[]) => any; saveExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any; getBaseOrigin: (...args: any[]) => any;
// ─── Appearance ───
applyStylePreset: (id: string) => void;
applyBgEffect: (id: string) => void;
renderAppearanceTab: () => void;
// ─── Overlay spinner internals ─── // ─── Overlay spinner internals ───
overlaySpinnerTimer: ReturnType<typeof setInterval> | null; overlaySpinnerTimer: ReturnType<typeof setInterval> | null;
_overlayEscHandler: ((e: KeyboardEvent) => void) | null; _overlayEscHandler: ((e: KeyboardEvent) => void) | null;

View File

@@ -11,6 +11,18 @@ from wled_controller.utils import atomic_write_json, get_logger
T = TypeVar("T") T = TypeVar("T")
logger = get_logger(__name__) logger = get_logger(__name__)
# When True, all store saves are suppressed. Set by the restore flow
# to prevent the old server process from overwriting freshly-restored
# files with stale in-memory data before the restart completes.
_saves_frozen = False
def freeze_saves() -> None:
"""Block all store saves until the process exits (used after restore)."""
global _saves_frozen
_saves_frozen = True
logger.info("Store saves frozen — awaiting server restart")
class EntityNotFoundError(ValueError): class EntityNotFoundError(ValueError):
"""Raised when an entity is not found in the store.""" """Raised when an entity is not found in the store."""
@@ -94,6 +106,9 @@ class BaseJsonStore(Generic[T]):
stores). Acceptable for user-initiated CRUD; not suitable for hot loops. stores). Acceptable for user-initiated CRUD; not suitable for hot loops.
Callers must hold ``self._lock``. Callers must hold ``self._lock``.
""" """
if _saves_frozen:
logger.warning(f"Save blocked (frozen after restore): {self._json_key}")
return
try: try:
data = { data = {
"version": self._version, "version": self._version,

View File

@@ -341,14 +341,27 @@
const savedAccent = localStorage.getItem('accentColor'); const savedAccent = localStorage.getItem('accentColor');
if (savedAccent) applyAccentColor(savedAccent, true); if (savedAccent) applyAccentColor(savedAccent, true);
// Early-apply saved background effect class (before bundle loads)
const savedBgEffect = localStorage.getItem('bgEffect');
if (savedBgEffect && savedBgEffect !== 'none') {
const effectClasses = { grid: 'bg-effect-grid', mesh: 'bg-effect-mesh', scanlines: 'bg-effect-scanlines', particles: 'bg-effect-particles' };
if (effectClasses[savedBgEffect]) document.documentElement.classList.add(effectClasses[savedBgEffect]);
}
// Initialize auth state // Initialize auth state
function updateAuthUI() { function updateAuthUI() {
const apiKey = localStorage.getItem('wled_api_key'); const apiKey = localStorage.getItem('wled_api_key');
const loginBtn = document.getElementById('login-btn'); const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn'); const logoutBtn = document.getElementById('logout-btn');
const tabBar = document.querySelector('.tab-bar'); const tabBar = document.querySelector('.tab-bar');
const authDisabled = window._authRequired === false;
if (apiKey) { if (authDisabled) {
// Auth disabled — hide login/logout, always show tabs
loginBtn.style.display = 'none';
logoutBtn.style.display = 'none';
if (tabBar) tabBar.style.display = '';
} else if (apiKey) {
loginBtn.style.display = 'none'; loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block'; logoutBtn.style.display = 'inline-block';
if (tabBar) tabBar.style.display = ''; if (tabBar) tabBar.style.display = '';