Dev build 2026-06-01 21:38

This commit is contained in:
Ivo Oskamp 2026-06-01 21:38:12 +02:00
parent 9e9ba825e0
commit 3e2700b1bc
4 changed files with 253 additions and 3 deletions

View File

@ -13,7 +13,7 @@ from urllib.parse import urlencode
import dropbox import dropbox
import httpx import httpx
from dropbox.exceptions import ApiError, AuthError from dropbox.exceptions import ApiError, AuthError
from fastapi import APIRouter, Request from fastapi import APIRouter, File, Request, UploadFile
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from shared_templates import templates 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 = Path(os.environ.get("CONFIG_DIR", "config"))
CONFIG_DIR.mkdir(parents=True, exist_ok=True) CONFIG_DIR.mkdir(parents=True, exist_ok=True)
MANIFEST_PATH = CONFIG_DIR / "backup_manifest.json" 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_DROPBOX_ROOT = "/novela"
DEFAULT_RETENTION_COUNT = 14 DEFAULT_RETENTION_COUNT = 14
DEFAULT_SCHEDULE_ENABLED = False 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) 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: def _has_running_backup() -> bool:
with get_db_conn() as conn: with get_db_conn() as conn:
with 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_size += len(dump_data)
uploaded_count += 1 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: if not dry_run:
_save_manifest(new_manifest) _save_manifest(new_manifest)
return total_files, uploaded_count, uploaded_size 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": False, "error": str(e)}
return {"ok": True, "restored": name, "size_bytes": len(data)} 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)}

View File

@ -298,6 +298,33 @@
</div> </div>
<div class="status-line" id="pgdump-status"></div> <div class="status-line" id="pgdump-status"></div>
</section> </section>
<section class="card">
<div class="card-head">Local Database Restore (no Dropbox needed)</div>
<p class="muted" style="margin-top:0;margin-bottom:0.6rem;">
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 <code>.sql</code> dump you downloaded from Dropbox manually.
</p>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;color:var(--err);">
⚠ Destructive: replaces the entire current database (with automatic rollback if the dump fails to load).
</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="localdump-select" style="flex:1;min-width:220px;margin:0;">
<option value="">— select local dump —</option>
</select>
<button class="btn" onclick="loadLocalDumps()">Refresh</button>
<button class="btn primary" id="btn-local-restore" onclick="restoreLocalDump()" disabled>Restore from local</button>
</div>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-top:0.4rem;border-top:1px solid var(--border);padding-top:0.8rem;">
<input class="field-input" id="upload-dump-file" type="file" accept=".sql" style="flex:1;min-width:220px;margin:0;"/>
<button class="btn primary" id="btn-upload-restore" onclick="uploadRestoreDump()">Upload &amp; restore</button>
</div>
<div class="status-line" id="localdump-status"></div>
</section>
</main> </main>
<script src="/static/books.js"></script> <script src="/static/books.js"></script>
@ -569,7 +596,7 @@
} }
async function refreshAll() { async function refreshAll() {
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps()]); await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps(), loadLocalDumps()]);
pollRunProgress(); 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 = '<option value="">— no local dumps yet —</option>';
btn.disabled = true;
return;
}
const current = sel.value;
sel.innerHTML = '<option value="">— select local dump —</option>' +
d.dumps.map(x => `<option value="${esc(x.name)}"${x.name === current ? ' selected' : ''}>${esc(x.name)} (${fmtBytes(x.size_bytes)})</option>`).join('');
btn.disabled = !sel.value;
} catch (_) {
sel.innerHTML = '<option value="">— unavailable —</option>';
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(); refreshAll();
</script> </script>
</body> </body>

View File

@ -10,7 +10,7 @@ from __future__ import annotations
from changelog import CHANGELOG from changelog import CHANGELOG
BUILD = 4 BUILD = 5
def _release_version() -> str: def _release_version() -> str:

View File

@ -1,5 +1,16 @@
# Develop Changelog # 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 ## 2026-06-01 — Full Database Restore: safety dump + automatic rollback
### Fixed ### Fixed