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 @@
+ +
+
Local Database Restore (no Dropbox needed)
+

+ 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). +

+ +
+ + + +
+ +
+ + +
+ +
+
@@ -569,7 +596,7 @@ } async function refreshAll() { - await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps()]); + await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps(), loadLocalDumps()]); pollRunProgress(); } @@ -806,6 +833,92 @@ } } + // ── Local / upload database restore ────────────────────────────────────── + + async function loadLocalDumps() { + const sel = document.getElementById('localdump-select'); + const btn = document.getElementById('btn-local-restore'); + try { + const r = await fetch('/api/backup/local/dumps'); + const d = await r.json(); + if (!d.ok || !d.dumps.length) { + sel.innerHTML = ''; + btn.disabled = true; + return; + } + const current = sel.value; + sel.innerHTML = '' + + d.dumps.map(x => ``).join(''); + btn.disabled = !sel.value; + } catch (_) { + sel.innerHTML = ''; + btn.disabled = true; + } + } + + document.getElementById('localdump-select').addEventListener('change', (e) => { + document.getElementById('btn-local-restore').disabled = !e.target.value; + }); + + async function restoreLocalDump() { + const name = document.getElementById('localdump-select').value; + const out = document.getElementById('localdump-status'); + if (!name) return; + if (!confirm(`Restore the ENTIRE database from local dump "${name}"?\n\nThis replaces the current database. A safety snapshot is taken first and rolled back automatically if the dump fails to load.`)) return; + const btn = document.getElementById('btn-local-restore'); + btn.disabled = true; + out.className = 'status-line warn'; + out.textContent = `Restoring database from ${name}… (do not navigate away)`; + try { + const r = await fetch('/api/backup/local/restore', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name}), + }); + const d = await r.json(); + if (!d.ok) throw new Error(d.error || 'failed'); + out.className = 'status-line ok'; + out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`; + } catch (e) { + out.className = 'status-line err'; + out.textContent = `Local restore failed: ${e}`; + } finally { + btn.disabled = false; + } + } + + async function uploadRestoreDump() { + const input = document.getElementById('upload-dump-file'); + const out = document.getElementById('localdump-status'); + const f = input.files && input.files[0]; + if (!f) { + out.className = 'status-line err'; + out.textContent = 'Choose a .sql dump file to upload first.'; + return; + } + if (!confirm(`Restore the ENTIRE database from uploaded file "${f.name}"?\n\nThis replaces the current database. A safety snapshot is taken first and rolled back automatically if the dump fails to load.`)) return; + const btn = document.getElementById('btn-upload-restore'); + btn.disabled = true; + out.className = 'status-line warn'; + out.textContent = `Uploading and restoring from ${f.name}… (do not navigate away)`; + try { + const fd = new FormData(); + fd.append('file', f); + const r = await fetch('/api/backup/upload-restore', {method: 'POST', body: fd}); + const d = await r.json(); + if (!d.ok) throw new Error(d.error || 'failed'); + out.className = 'status-line ok'; + out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`; + input.value = ''; + await loadLocalDumps(); + } catch (e) { + out.className = 'status-line err'; + out.textContent = `Upload restore failed: ${e}`; + } finally { + btn.disabled = false; + } + } + refreshAll(); diff --git a/containers/novela/version.py b/containers/novela/version.py index ce888a5..abee128 100644 --- a/containers/novela/version.py +++ b/containers/novela/version.py @@ -10,7 +10,7 @@ from __future__ import annotations from changelog import CHANGELOG -BUILD = 4 +BUILD = 5 def _release_version() -> str: diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index de2ae45..392fb2f 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -1,5 +1,16 @@ # Develop Changelog +## 2026-06-01 — Local database backups + token-free / upload restore + +### Added +- Backups now keep a **local copy** of each PostgreSQL dump on the config volume (`CONFIG_DIR/postgres_dumps/`, same retention as snapshots), so the database can be restored **without a Dropbox token**. Previously dumps lived only in Dropbox, so losing the stored token meant being locked out of every backup. + - `routers/backup.py`: `LOCAL_DUMP_DIR`, helpers `_save_local_dump`, `_list_local_dumps`, `_enforce_local_dump_retention`, `_resolve_local_dump`; `_run_backup_internal` writes the dump locally (real runs only) after uploading to Dropbox (a local-copy failure never fails the backup). +- New **Local Database Restore** card on the Backup page with two token-free paths: + - Restore from a locally stored dump — `GET /api/backup/local/dumps`, `POST /api/backup/local/restore`. + - Upload a `.sql` dump (e.g. downloaded manually from dropbox.com) and restore it — `POST /api/backup/upload-restore`; the upload is also saved to the local dump store for future use. + - Both reuse the safe restore path (pre-restore safety dump + automatic rollback) and require `psql`. + - `templates/backup.html`: new card with a local-dump selector, an upload field, double-guarded restore buttons, and `fmtBytes` sizes. + ## 2026-06-01 — Full Database Restore: safety dump + automatic rollback ### Fixed