diff --git a/containers/novela/routers/backup.py b/containers/novela/routers/backup.py index 9a1945c..cc6244e 100644 --- a/containers/novela/routers/backup.py +++ b/containers/novela/routers/backup.py @@ -13,7 +13,7 @@ from urllib.parse import urlencode import dropbox import httpx from dropbox.exceptions import ApiError, AuthError -from fastapi import APIRouter, Request +from fastapi import APIRouter, File, Request, UploadFile from fastapi.responses import HTMLResponse from shared_templates import templates @@ -32,6 +32,9 @@ LIBRARY_DIR = Path(os.environ.get("LIBRARY_DIR", "library")) CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "config")) CONFIG_DIR.mkdir(parents=True, exist_ok=True) MANIFEST_PATH = CONFIG_DIR / "backup_manifest.json" +# Local copies of PostgreSQL dumps, so the database can be restored without a +# Dropbox token. Lives on the persistent config volume. +LOCAL_DUMP_DIR = CONFIG_DIR / "postgres_dumps" DEFAULT_DROPBOX_ROOT = "/novela" DEFAULT_RETENTION_COUNT = 14 DEFAULT_SCHEDULE_ENABLED = False @@ -851,6 +854,42 @@ def _list_pg_dump_paths(client: dropbox.Dropbox, postgres_root: str) -> list[str return sorted([p for p in files if p.endswith(".sql")], reverse=True) +def _save_local_dump(name: str, data: bytes) -> None: + LOCAL_DUMP_DIR.mkdir(parents=True, exist_ok=True) + safe = Path(name).name + if not safe.endswith(".sql"): + safe += ".sql" + (LOCAL_DUMP_DIR / safe).write_bytes(data) + + +def _list_local_dumps() -> list[Path]: + if not LOCAL_DUMP_DIR.exists(): + return [] + dumps = [p for p in LOCAL_DUMP_DIR.glob("*.sql") if p.is_file()] + return sorted(dumps, key=lambda p: p.name, reverse=True) + + +def _enforce_local_dump_retention(keep_count: int) -> None: + keep = max(1, int(keep_count)) + for old in _list_local_dumps()[keep:]: + try: + old.unlink() + except OSError: + pass + + +def _resolve_local_dump(name: str) -> Path | None: + safe = Path(name).name + if not safe.endswith(".sql"): + return None + candidate = (LOCAL_DUMP_DIR / safe).resolve() + try: + candidate.relative_to(LOCAL_DUMP_DIR.resolve()) + except ValueError: + return None + return candidate if candidate.is_file() else None + + def _has_running_backup() -> bool: with get_db_conn() as conn: with conn: @@ -1097,6 +1136,16 @@ def _run_backup_internal(*, dry_run: bool, progress_key: int | None = None) -> t uploaded_size += len(dump_data) uploaded_count += 1 + # Keep a local copy of the dump so the database can be restored without a + # Dropbox token. Only for real runs, not dry runs. + if not dry_run: + try: + _save_local_dump(dump_name, dump_data) + _enforce_local_dump_retention(retention_count) + except Exception: + # A local-copy failure must not fail the backup itself. + pass + if not dry_run: _save_manifest(new_manifest) return total_files, uploaded_count, uploaded_size @@ -1771,3 +1820,80 @@ async def restore_pg_dump(request: Request): return {"ok": False, "error": str(e)} return {"ok": True, "restored": name, "size_bytes": len(data)} + + +@router.get("/api/backup/local/dumps") +async def list_local_dumps(): + """List PostgreSQL dumps stored locally on disk (no Dropbox token needed).""" + dumps = [] + for p in _list_local_dumps(): + try: + size = p.stat().st_size + except OSError: + size = 0 + dumps.append({"name": p.name, "size_bytes": size}) + return {"ok": True, "dumps": dumps, "dir": str(LOCAL_DUMP_DIR)} + + +@router.post("/api/backup/local/restore") +async def restore_local_dump(request: Request): + """Restore the database from a local dump file. No Dropbox token required.""" + body = {} + try: + body = await request.json() + except Exception: + pass + + name = (body.get("name") or "").strip() + path = _resolve_local_dump(name) + if path is None: + return {"ok": False, "error": "Local dump not found"} + if not shutil.which("psql"): + return {"ok": False, "error": "psql is not available in this container"} + + try: + data = await asyncio.to_thread(path.read_bytes) + except Exception as e: + return {"ok": False, "error": f"Failed to read local dump: {e}"} + + try: + await asyncio.to_thread(_run_pg_restore, data) + except Exception as e: + return {"ok": False, "error": str(e)} + + return {"ok": True, "restored": path.name, "size_bytes": len(data)} + + +@router.post("/api/backup/upload-restore") +async def upload_restore_dump(file: UploadFile = File(...)): + """Restore the database from an uploaded .sql dump. No Dropbox token required. + + The uploaded dump is also saved to the local dump store so it is available + for future restores. + """ + if not shutil.which("psql"): + return {"ok": False, "error": "psql is not available in this container"} + + filename = Path(file.filename or "uploaded.sql").name + if not filename.endswith(".sql"): + return {"ok": False, "error": "Please upload a .sql dump file"} + + try: + data = await file.read() + except Exception as e: + return {"ok": False, "error": f"Failed to read upload: {e}"} + if not data: + return {"ok": False, "error": "Uploaded file is empty"} + + try: + await asyncio.to_thread(_run_pg_restore, data) + except Exception as e: + return {"ok": False, "error": str(e)} + + # Persist a local copy so it shows up under Local Database Restore afterwards. + try: + await asyncio.to_thread(_save_local_dump, filename, data) + except Exception: + pass + + return {"ok": True, "restored": filename, "size_bytes": len(data)} diff --git a/containers/novela/templates/backup.html b/containers/novela/templates/backup.html index 0dd08bd..369c3d3 100644 --- a/containers/novela/templates/backup.html +++ b/containers/novela/templates/backup.html @@ -298,6 +298,33 @@
+ +
+ Every backup also keeps a local copy of the database dump on the config volume,
+ so the database can be restored even without a Dropbox token. You can also upload
+ a .sql dump you downloaded from Dropbox manually.
+
+ ⚠ Destructive: replaces the entire current database (with automatic rollback if the dump fails to load). +
+ +