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

@@ -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,