"""System routes: backup, restore, auto-backup. All backups are SQLite database snapshots (.db files). """ import asyncio import io import subprocess import sys import threading from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import StreamingResponse from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import get_auto_backup_engine, get_database from wled_controller.api.schemas.system import ( AutoBackupSettings, AutoBackupStatusResponse, BackupFileInfo, BackupListResponse, RestoreResponse, ) from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.storage.database import Database, freeze_writes from wled_controller.utils import get_logger logger = get_logger(__name__) router = APIRouter() _SERVER_DIR = Path(__file__).resolve().parents[4] def _schedule_restart() -> None: """Spawn a restart script after a short delay so the HTTP response completes.""" def _restart(): import time time.sleep(1) if sys.platform == "win32": subprocess.Popen( ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(_SERVER_DIR / "restart.ps1")], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, ) else: subprocess.Popen( ["bash", str(_SERVER_DIR / "restart.sh")], start_new_session=True, ) threading.Thread(target=_restart, daemon=True).start() # --------------------------------------------------------------------------- # Backup / restore (SQLite snapshots) # --------------------------------------------------------------------------- @router.get("/api/v1/system/backup", tags=["System"]) def backup_config(_: AuthRequired, db: Database = Depends(get_database)): """Download a full database backup as a .db file.""" import tempfile with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: tmp_path = Path(tmp.name) try: db.backup_to(tmp_path) content = tmp_path.read_bytes() finally: tmp_path.unlink(missing_ok=True) from datetime import datetime, timezone timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S") filename = f"ledgrab-backup-{timestamp}.db" return StreamingResponse( io.BytesIO(content), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"]) async def restore_config( _: AuthRequired, file: UploadFile = File(...), db: Database = Depends(get_database), ): """Upload a .db backup file to restore all configuration. Triggers server restart.""" raw = await file.read() if len(raw) > 50 * 1024 * 1024: # 50 MB limit raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)") if len(raw) < 100: raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database") # SQLite files start with "SQLite format 3\000" if not raw[:16].startswith(b"SQLite format 3"): raise HTTPException(status_code=400, detail="Not a valid SQLite database file") import tempfile with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: tmp.write(raw) tmp_path = Path(tmp.name) try: def _restore(): db.restore_from(tmp_path) await asyncio.to_thread(_restore) finally: tmp_path.unlink(missing_ok=True) freeze_writes() logger.info("Database restored from uploaded backup. Scheduling restart...") _schedule_restart() return RestoreResponse( status="restored", restart_scheduled=True, message="Database restored from backup. Server restarting...", ) @router.post("/api/v1/system/restart", tags=["System"]) def restart_server(_: AuthRequired): """Schedule a server restart and return immediately.""" from wled_controller.server_ref import _broadcast_restarting _broadcast_restarting() _schedule_restart() return {"status": "restarting"} @router.post("/api/v1/system/shutdown", tags=["System"]) def shutdown_server(_: AuthRequired): """Gracefully shut down the server.""" from wled_controller.server_ref import request_shutdown request_shutdown() return {"status": "shutting_down"} # --------------------------------------------------------------------------- # Auto-backup settings & saved backups # --------------------------------------------------------------------------- @router.get( "/api/v1/system/auto-backup/settings", response_model=AutoBackupStatusResponse, tags=["System"], ) async def get_auto_backup_settings( _: AuthRequired, engine: AutoBackupEngine = Depends(get_auto_backup_engine), ): """Get auto-backup settings and status.""" return engine.get_settings() @router.put( "/api/v1/system/auto-backup/settings", response_model=AutoBackupStatusResponse, tags=["System"], ) async def update_auto_backup_settings( _: AuthRequired, body: AutoBackupSettings, engine: AutoBackupEngine = Depends(get_auto_backup_engine), ): """Update auto-backup settings (enable/disable, interval, max backups).""" return await engine.update_settings( enabled=body.enabled, interval_hours=body.interval_hours, max_backups=body.max_backups, ) @router.post("/api/v1/system/auto-backup/trigger", tags=["System"]) async def trigger_backup( _: AuthRequired, engine: AutoBackupEngine = Depends(get_auto_backup_engine), ): """Manually trigger a backup now.""" backup = await engine.trigger_backup() return {"status": "ok", "backup": backup} @router.get( "/api/v1/system/backups", response_model=BackupListResponse, tags=["System"], ) async def list_backups( _: AuthRequired, engine: AutoBackupEngine = Depends(get_auto_backup_engine), ): """List all saved backup files.""" backups = engine.list_backups() return BackupListResponse( backups=[BackupFileInfo(**b) for b in backups], count=len(backups), ) @router.get("/api/v1/system/backups/{filename}", tags=["System"]) def download_saved_backup( filename: str, _: AuthRequired, engine: AutoBackupEngine = Depends(get_auto_backup_engine), ): """Download a specific saved backup file.""" try: path = engine.get_backup_path(filename) except (ValueError, FileNotFoundError) as e: raise HTTPException(status_code=404, detail=str(e)) content = path.read_bytes() return StreamingResponse( io.BytesIO(content), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.delete("/api/v1/system/backups/{filename}", tags=["System"]) async def delete_saved_backup( filename: str, _: AuthRequired, engine: AutoBackupEngine = Depends(get_auto_backup_engine), ): """Delete a specific saved backup file.""" try: engine.delete_backup(filename) except (ValueError, FileNotFoundError) as e: raise HTTPException(status_code=404, detail=str(e)) return {"status": "deleted", "filename": filename}