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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user