Dev build 2026-06-01 21:38
This commit is contained in:
parent
9e9ba825e0
commit
3e2700b1bc
@ -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)}
|
||||||
|
|||||||
@ -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 & 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>
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user