feat: optional auth + backup/restore reliability fixes
Some checks failed
Lint & Test / test (push) Failing after 29s
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:
@@ -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,21 +57,25 @@ 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)
|
||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
auth_required = data.get("auth_required", True)
|
||||||
try:
|
if api_key:
|
||||||
async with session.get(
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
f"{server_url}/api/v1/output-targets",
|
try:
|
||||||
headers=headers,
|
async with session.get(
|
||||||
timeout=timeout,
|
f"{server_url}/api/v1/output-targets",
|
||||||
) as resp:
|
headers=headers,
|
||||||
if resp.status == 401:
|
timeout=timeout,
|
||||||
raise PermissionError("Invalid API key")
|
) as resp:
|
||||||
resp.raise_for_status()
|
if resp.status == 401:
|
||||||
except PermissionError:
|
raise PermissionError("Invalid API key")
|
||||||
raise
|
resp.raise_for_status()
|
||||||
except aiohttp.ClientError as err:
|
except PermissionError:
|
||||||
raise ConnectionError(f"API request failed: {err}") from err
|
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}
|
return {"version": version}
|
||||||
|
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
# Generate secure keys: openssl rand -hex 32
|
||||||
api_keys:
|
api_keys:
|
||||||
# Generate secure keys: openssl rand -hex 32
|
dev: "development-key-change-in-production"
|
||||||
dev: "development-key-change-in-production" # Development key - CHANGE THIS!
|
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
devices_file: "data/devices.json"
|
devices_file: "data/devices.json"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
await self._perform_backup()
|
# Prevents rapid restarts (crashes, restores) from flooding the
|
||||||
self._prune_old_backups()
|
# 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
|
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()
|
||||||
|
|||||||
@@ -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:")
|
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||||
logger.error(" 1. Generate keys: openssl rand -hex 32")
|
logger.info(f"Authorized clients: {client_labels}")
|
||||||
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")
|
|
||||||
|
|
||||||
# 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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user