Backup & Restore: db-book restore, full/local/upload DB restore with safety rollback; pin postgresql-client 16; sidebar version display-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-06-01 21:58:02 +02:00
parent 566c52cf34
commit 3093af68d6
8 changed files with 849 additions and 25 deletions

View File

@ -2,12 +2,22 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# unrar comes from Debian bookworm non-free; postgresql-client is pinned to v16
# (matching the postgres:16 server) via the official PostgreSQL APT repo, so
# pg_dump produces PG16-native dumps that restore cleanly into the server.
RUN echo "deb http://deb.debian.org/debian bookworm non-free" >> /etc/apt/sources.list \ RUN echo "deb http://deb.debian.org/debian bookworm non-free" >> /etc/apt/sources.list \
&& apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates gnupg \
&& install -d /usr/share/postgresql-common/pgdg \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
&& echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] http://apt.postgresql.org/pub/repos/apt trixie-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list \
&& apt-get update && apt-get install -y --no-install-recommends \ && apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libmagic1 \ libmagic1 \
unrar \ unrar \
postgresql-client \ postgresql-client-16 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt COPY requirements.txt /app/requirements.txt

View File

@ -3,6 +3,31 @@ Changelog data for Novela
""" """
CHANGELOG = [ CHANGELOG = [
{
"version": "v0.2.13",
"date": "2026-06-01",
"summary": "Backup & Restore overhaul: database-stored books can now be restored individually, a full-database restore from a dump, and token-free local & upload restore — all with automatic pre-restore safety snapshots and rollback. The sidebar build version is now display-only.",
"sections": [
{
"title": "New features",
"type": "feature",
"changes": [
"Backup: database-stored books (those kept inside the database rather than as files on disk) are now included in snapshots and can be restored one by one from the Restore screen, just like file books. They previously existed only inside the full database dump and could not be restored individually. Note: they appear in snapshots created after this update; older database books are recovered via the full database restore below.",
"Backup: new Full Database Restore — restore the entire database from any Dropbox database dump. This recovers everything, including all database-stored books, reading progress, tags and settings, and is guarded behind a double confirmation.",
"Backup: token-free restore. Every restore now keeps a local pre-restore safety copy of the database on the config volume, so you can restore or roll back without a Dropbox token. You can also upload a .sql database dump (e.g. one downloaded manually from Dropbox) and restore it directly. Regular scheduled backups stay Dropbox-only.",
],
},
{
"title": "Improvements",
"type": "improvement",
"changes": [
"Backup: restores now take a safety snapshot of the current database first and automatically roll back to it if the dump fails to load, so a failed restore can no longer leave the database empty or broken.",
"Backup: a full restore tolerates PostgreSQL version differences (such as a newer dump's transaction_timeout setting) instead of aborting on them, while still failing on genuine errors.",
"Sidebar: the build version indicator at the bottom is now a plain display instead of a clickable link — it exists only to show that the running build has been updated.",
],
},
],
},
{ {
"version": "v0.2.12", "version": "v0.2.12",
"date": "2026-06-01", "date": "2026-06-01",

View File

@ -1,10 +1,11 @@
import asyncio import asyncio
import base64
import hashlib import hashlib
import json import json
import os import os
import shutil import shutil
import subprocess import subprocess
from datetime import datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from urllib.parse import urlencode from urllib.parse import urlencode
@ -12,12 +13,17 @@ 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
from db import get_db_conn from db import get_db_conn
from routers.common import scan_media, upsert_book from routers.common import (
scan_media,
upsert_book,
upsert_chapter,
upsert_cover_cache,
)
from security import decrypt_value, encrypt_value, is_encrypted_value from security import decrypt_value, encrypt_value, is_encrypted_value
router = APIRouter() router = APIRouter()
@ -26,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
@ -533,6 +542,169 @@ def _snapshot_name() -> str:
return f"snapshot-{stamp}.json" return f"snapshot-{stamp}.json"
# ── Database-stored books (storage_type='db') ────────────────────────────────
# These books have no file on disk: their content lives in PostgreSQL
# (library row + book_chapters + book_tags + library_cover_cache). Inline images
# referenced from chapters live on disk under library/images/ and are backed up
# as ordinary files. To make db-books restorable per-book (just like file books),
# each one is serialized to JSON and stored in the same content-addressed object
# store, then referenced from the snapshot with a "storage": "db" marker.
_DB_LIBRARY_COLS = [
"filename",
"media_type",
"storage_type",
"title",
"author",
"publisher",
"series",
"series_index",
"series_suffix",
"series_volume",
"publication_status",
"has_cover",
"description",
"source_url",
"publish_date",
"archived",
"want_to_read",
"needs_review",
"rating",
"created_at",
]
def _db_book_filenames(cur) -> list[str]:
cur.execute(
"SELECT filename FROM library WHERE storage_type = 'db' OR filename LIKE 'db/%' ORDER BY filename"
)
return [r[0] for r in cur.fetchall()]
def _serialize_db_book(cur, filename: str) -> dict | None:
cols = _DB_LIBRARY_COLS
cur.execute(
f"SELECT {', '.join(cols)} FROM library WHERE filename = %s LIMIT 1",
(filename,),
)
row = cur.fetchone()
if not row:
return None
lib: dict = {}
for col, val in zip(cols, row):
if isinstance(val, (datetime, date)):
val = val.isoformat()
lib[col] = val
cur.execute(
"SELECT chapter_index, title, content FROM book_chapters WHERE filename = %s ORDER BY chapter_index",
(filename,),
)
chapters = [
{"chapter_index": r[0], "title": r[1] or "", "content": r[2] or ""}
for r in cur.fetchall()
]
cur.execute(
"SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag, tag_type",
(filename,),
)
tags = [{"tag": r[0], "tag_type": r[1]} for r in cur.fetchall()]
cur.execute(
"SELECT mime_type, thumb_webp FROM library_cover_cache WHERE filename = %s LIMIT 1",
(filename,),
)
cover_row = cur.fetchone()
cover = None
if cover_row and cover_row[1] is not None:
cover = {
"mime_type": cover_row[0] or "image/webp",
"thumb_webp_b64": base64.b64encode(bytes(cover_row[1])).decode("ascii"),
}
return {
"novela_db_book": 1,
"filename": filename,
"library": lib,
"chapters": chapters,
"tags": tags,
"cover": cover,
}
def _restore_db_book(filename: str, payload: dict) -> None:
"""Re-create a db-stored book from a serialized snapshot object."""
lib = dict(payload.get("library") or {})
lib["filename"] = filename
lib.setdefault("storage_type", "db")
lib.setdefault("media_type", "epub")
cols = [c for c in _DB_LIBRARY_COLS if c in lib]
if "filename" not in cols:
cols.insert(0, "filename")
chapters = payload.get("chapters") or []
tags = payload.get("tags") or []
cover = payload.get("cover")
col_list = ", ".join(cols)
placeholders = ", ".join(["%s"] * len(cols))
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c != "filename")
values = [lib.get(c) for c in cols]
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
cur.execute(
f"""
INSERT INTO library ({col_list})
VALUES ({placeholders})
ON CONFLICT (filename) DO UPDATE SET
{updates},
updated_at = NOW()
""",
values,
)
cur.execute("DELETE FROM book_chapters WHERE filename = %s", (filename,))
for ch in chapters:
try:
idx = int(ch.get("chapter_index"))
except (TypeError, ValueError):
continue
upsert_chapter(conn, filename, idx, ch.get("title", ""), ch.get("content", ""))
with conn.cursor() as cur:
cur.execute("DELETE FROM book_tags WHERE filename = %s", (filename,))
rows = []
seen: set[tuple[str, str]] = set()
for t in tags:
tag = (t.get("tag") or "").strip()
ttype = (t.get("tag_type") or "").strip()
if not tag or not ttype:
continue
key = (tag.casefold(), ttype)
if key in seen:
continue
seen.add(key)
rows.append((filename, tag, ttype))
if rows:
cur.executemany(
"INSERT INTO book_tags (filename, tag, tag_type) VALUES (%s, %s, %s) "
"ON CONFLICT (filename, tag, tag_type) DO NOTHING",
rows,
)
if cover and cover.get("thumb_webp_b64"):
try:
thumb = base64.b64decode(cover["thumb_webp_b64"])
upsert_cover_cache(conn, filename, cover.get("mime_type", "image/webp"), thumb)
except Exception:
pass
def _object_path(objects_root: str, sha256: str) -> str: def _object_path(objects_root: str, sha256: str) -> str:
return _dropbox_join(objects_root, sha256[:2], sha256) return _dropbox_join(objects_root, sha256[:2], sha256)
@ -574,6 +746,163 @@ def _run_pg_dump() -> tuple[bytes, str]:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
def _psql_base_args() -> list[str]:
return [
"-h",
os.environ.get("POSTGRES_HOST", "postgres"),
"-p",
str(os.environ.get("POSTGRES_PORT", "5432")),
"-U",
os.environ.get("POSTGRES_USER", "novela"),
"-d",
os.environ.get("POSTGRES_DB", "novela"),
]
# Session-GUC SET statements a dump may carry that an OLDER server doesn't know.
# These are emitted in the pg_dump header and are harmless to skip — they only
# affect the restoring session, not the data. (e.g. `transaction_timeout` was
# introduced in PostgreSQL 17; restoring such a dump into a <17 server errors on
# that line.) We must not let these abort an otherwise valid restore, but any
# OTHER error still fails the restore.
_BENIGN_RESTORE_ERROR_MARKERS = (
"unrecognized configuration parameter",
)
def _real_restore_errors(stderr: str) -> list[str]:
errors = []
for line in (stderr or "").splitlines():
if "ERROR:" not in line:
continue
if any(marker in line for marker in _BENIGN_RESTORE_ERROR_MARKERS):
continue
errors.append(line.strip())
return errors
def _apply_pg_dump(dump_bytes: bytes) -> None:
"""Reset the public schema and load a dump into it.
The dump is applied WITHOUT ON_ERROR_STOP so that benign header SETs the
target server doesn't recognize (e.g. a newer pg_dump emitting
`transaction_timeout` against an older server) don't abort the load.
stderr is then inspected: any non-benign `ERROR:` line raises.
"""
env = os.environ.copy()
env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "")
base = _psql_base_args()
reset = subprocess.run(
["psql", *base, "-v", "ON_ERROR_STOP=1", "-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"],
env=env,
capture_output=True,
text=True,
)
if reset.returncode != 0:
raise RuntimeError(f"schema reset failed: {(reset.stderr or '').strip()[:500] or 'unknown error'}")
with NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
tmp_path = Path(tmp.name)
tmp_path.write_bytes(dump_bytes)
try:
proc = subprocess.run(
["psql", *base, "-f", str(tmp_path)],
env=env,
capture_output=True,
text=True,
)
real_errors = _real_restore_errors(proc.stderr)
if real_errors:
raise RuntimeError("psql restore failed: " + " | ".join(real_errors)[:500])
finally:
tmp_path.unlink(missing_ok=True)
def _run_pg_restore(dump_bytes: bytes) -> None:
"""Restore a full PostgreSQL dump, with automatic rollback on failure.
Before the destructive load, a safety dump of the CURRENT database is taken.
If applying the requested dump fails, the database is rolled back to that
safety snapshot so a failed restore never leaves an empty/broken database.
The safety dump is also written to the local dump store, so every restore
leaves behind a token-free local snapshot of the pre-restore state. Regular
backups stay Dropbox-only (they can run hourly and would otherwise fill the
disk).
"""
try:
safety_bytes, safety_name = _run_pg_dump()
except Exception:
safety_bytes, safety_name = None, ""
if safety_bytes is not None:
try:
_save_local_dump(f"pre-restore-{safety_name}", safety_bytes)
_enforce_local_dump_retention(_load_dropbox_retention_count())
except Exception:
# A local-copy failure must not block the restore.
pass
try:
_apply_pg_dump(dump_bytes)
except Exception as restore_err:
if safety_bytes is not None:
try:
_apply_pg_dump(safety_bytes)
except Exception as rollback_err:
raise RuntimeError(
f"restore failed: {restore_err}; AND rollback failed: {rollback_err}. "
"Database may be in an inconsistent state."
)
raise RuntimeError(
f"restore failed and was rolled back to the pre-restore state: {restore_err}"
)
raise
def _list_pg_dump_paths(client: dropbox.Dropbox, postgres_root: str) -> list[str]:
files = _dropbox_list_files_recursive(client, postgres_root)
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:
@ -765,6 +1094,27 @@ def _run_backup_internal(*, dry_run: bool, progress_key: int | None = None) -> t
uploaded_size += int(state["size"]) uploaded_size += int(state["size"])
uploaded_count += 1 uploaded_count += 1
# Database-stored books: serialize each into the content-addressed object
# store and reference it from the snapshot so it can be restored per-book.
with get_db_conn() as conn:
with conn.cursor() as cur:
db_filenames = _db_book_filenames(cur)
for fn in db_filenames:
payload = _serialize_db_book(cur, fn)
if payload is None:
continue
data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
sha256 = hashlib.sha256(data).hexdigest()
snapshot_files[fn] = {"size": len(data), "sha256": sha256, "storage": "db"}
object_target = _object_path(objects_root, sha256)
if client is not None:
if not _dropbox_exists(client, object_target):
uploaded_size += _dropbox_upload_bytes(client, object_target, data)
uploaded_count += 1
else:
uploaded_size += len(data)
uploaded_count += 1
_prog(total_files, total_files, "snapshot") _prog(total_files, total_files, "snapshot")
snapshot = { snapshot = {
@ -927,6 +1277,7 @@ async def backup_dropbox_credentials_delete():
async def backup_health(): async def backup_health():
token_present = bool(_load_dropbox_token()) token_present = bool(_load_dropbox_token())
pg_dump_path = shutil.which("pg_dump") pg_dump_path = shutil.which("pg_dump")
psql_path = shutil.which("psql")
dropbox_ok = False dropbox_ok = False
dropbox_error = None dropbox_error = None
@ -951,6 +1302,8 @@ async def backup_health():
"schedule_interval_hours": schedule_interval_hours, "schedule_interval_hours": schedule_interval_hours,
"pg_dump_available": bool(pg_dump_path), "pg_dump_available": bool(pg_dump_path),
"pg_dump_path": pg_dump_path, "pg_dump_path": pg_dump_path,
"psql_available": bool(psql_path),
"psql_path": psql_path,
"library_exists": LIBRARY_DIR.exists(), "library_exists": LIBRARY_DIR.exists(),
"library_path": str(LIBRARY_DIR.resolve()), "library_path": str(LIBRARY_DIR.resolve()),
} }
@ -1250,6 +1603,13 @@ def _parse_snapshot_date(name: str) -> str:
return "" return ""
def _entry_storage(rel: str, info: dict) -> str:
storage = str(info.get("storage") or "").strip().lower()
if storage:
return storage
return "db" if rel.startswith("db/") else "file"
def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str, info: dict) -> None: def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str, info: dict) -> None:
sha256 = str(info.get("sha256") or "") sha256 = str(info.get("sha256") or "")
if not sha256: if not sha256:
@ -1257,6 +1617,17 @@ def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str,
obj_path = _object_path(objects_root, sha256) obj_path = _object_path(objects_root, sha256)
_meta, res = client.files_download(obj_path) _meta, res = client.files_download(obj_path)
data = res.content data = res.content
if _entry_storage(rel, info) == "db":
try:
payload = json.loads(data.decode("utf-8", errors="replace"))
except Exception as e:
raise ValueError(f"Invalid db-book snapshot object: {e}")
if not isinstance(payload, dict):
raise ValueError("db-book snapshot object is not an object")
_restore_db_book(rel, payload)
return
dest = LIBRARY_DIR / rel dest = LIBRARY_DIR / rel
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data) dest.write_bytes(data)
@ -1306,16 +1677,37 @@ async def snapshot_files(snapshot_name: str):
return {"ok": False, "error": str(e), "files": []} return {"ok": False, "error": str(e), "files": []}
files_data = snap.get("files", {}) files_data = snap.get("files", {})
result = [
# db-books "exist" when their row is present in the library table, not on disk.
db_rels = [
rel
for rel, info in files_data.items()
if isinstance(info, dict) and _entry_storage(rel, info) == "db"
]
existing_db: set[str] = set()
if db_rels:
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT filename FROM library WHERE filename = ANY(%s)", (db_rels,)
)
existing_db = {r[0] for r in cur.fetchall()}
result = []
for rel, info in sorted(files_data.items()):
if not isinstance(info, dict):
continue
storage = _entry_storage(rel, info)
exists = rel in existing_db if storage == "db" else (LIBRARY_DIR / rel).exists()
result.append(
{ {
"path": rel, "path": rel,
"size": info.get("size", 0), "size": info.get("size", 0),
"sha256": info.get("sha256", ""), "sha256": info.get("sha256", ""),
"exists_locally": (LIBRARY_DIR / rel).exists(), "storage": storage,
"exists_locally": exists,
} }
for rel, info in sorted(files_data.items()) )
if isinstance(info, dict)
]
return {"ok": True, "snapshot": snapshot_name, "files": result} return {"ok": True, "snapshot": snapshot_name, "files": result}
@ -1365,3 +1757,140 @@ async def restore_files(request: Request):
ok_count = sum(1 for r in results if r["ok"]) ok_count = sum(1 for r in results if r["ok"])
return {"ok": True, "restored": ok_count, "total": len(results), "results": results} return {"ok": True, "restored": ok_count, "total": len(results), "results": results}
@router.get("/api/backup/postgres/dumps")
async def list_pg_dumps():
try:
client = await asyncio.to_thread(_dbx)
except Exception as e:
return {"ok": False, "error": str(e), "dumps": []}
dropbox_root = _load_dropbox_root()
postgres_root = _dropbox_join(dropbox_root, "postgres")
try:
paths = await asyncio.to_thread(_list_pg_dump_paths, client, postgres_root)
except Exception as e:
return {"ok": False, "error": str(e), "dumps": []}
dumps = [{"name": Path(p).name} for p in paths]
return {"ok": True, "dumps": dumps}
@router.post("/api/backup/postgres/restore")
async def restore_pg_dump(request: Request):
"""Restore the entire PostgreSQL database from a Dropbox pg_dump.
DESTRUCTIVE: drops and recreates the public schema before applying the
dump. This recovers everything, including database-stored books, but
overwrites the current database.
"""
body = {}
try:
body = await request.json()
except Exception:
pass
name = Path((body.get("name") or "").strip()).name
if not name or not name.endswith(".sql"):
return {"ok": False, "error": "A valid .sql dump name is required"}
if not shutil.which("psql"):
return {"ok": False, "error": "psql is not available in this container"}
try:
client = await asyncio.to_thread(_dbx)
except Exception as e:
return {"ok": False, "error": str(e)}
dropbox_root = _load_dropbox_root()
postgres_root = _dropbox_join(dropbox_root, "postgres")
dump_path = _dropbox_join(postgres_root, name)
try:
def _download() -> bytes:
_meta, res = client.files_download(dump_path)
return res.content
data = await asyncio.to_thread(_download)
except Exception as e:
return {"ok": False, "error": f"Failed to download 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": 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.
A local pre-restore safety snapshot is taken automatically (by _run_pg_restore)
before the load; the uploaded file itself is not persisted.
"""
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)}
return {"ok": True, "restored": filename, "size_bytes": len(data)}

View File

@ -125,10 +125,10 @@ html {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.68rem; font-size: 0.68rem;
color: var(--text-dim); color: var(--text-dim);
text-decoration: none;
opacity: 0.75; opacity: 0.75;
cursor: default;
user-select: none;
} }
.sidebar-version:hover { opacity: 1; }
.disk-warning { .disk-warning {
display: flex; display: flex;

View File

@ -289,7 +289,7 @@
</svg> </svg>
<span id="rescan-label">Rescan library</span> <span id="rescan-label">Rescan library</span>
</button> </button>
<a href="/changelog" class="sidebar-version" title="Running Novela build">{{ app_version() }}</a> <span class="sidebar-version" title="Running Novela build">{{ app_version() }}</span>
</div> </div>
</aside> </aside>

View File

@ -242,7 +242,10 @@
<section class="card"> <section class="card">
<div class="card-head">Restore</div> <div class="card-head">Restore</div>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;"> <p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
Browse a snapshot and restore individual books from Dropbox back to disk. Browse a snapshot and restore individual books from Dropbox. File books are
written back to disk; database books (format <strong>DB</strong>) are re-inserted
into the library. Database books only appear in snapshots created after this
feature was added — to recover older database books, use Full Database Restore below.
</p> </p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;"> <div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="snapshot-select" style="flex:1;min-width:220px;margin:0;" onchange="onSnapshotChange()"> <select class="field-input" id="snapshot-select" style="flex:1;min-width:220px;margin:0;" onchange="onSnapshotChange()">
@ -275,6 +278,54 @@
</div> </div>
<div class="status-line" id="restore-status"></div> <div class="status-line" id="restore-status"></div>
</section> </section>
<section class="card">
<div class="card-head">Full Database Restore</div>
<p class="muted" style="margin-top:0;margin-bottom:0.6rem;">
Restore the entire PostgreSQL database from a Dropbox <code>pg_dump</code>. This
recovers <strong>everything</strong> — including all database-stored books, reading
progress, tags and settings — from any backup.
</p>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;color:var(--err);">
⚠ Destructive: the current database is dropped and replaced entirely. There is no undo.
</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="pgdump-select" style="flex:1;min-width:220px;margin:0;">
<option value="">— select database dump —</option>
</select>
<button class="btn" onclick="loadPgDumps()">Refresh</button>
<button class="btn primary" id="btn-pg-restore" onclick="restorePgDump()" disabled>Restore database</button>
</div>
<div class="status-line" id="pgdump-status"></div>
</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;">
Before every restore a safety snapshot of the current database is saved locally
on the config volume (named <code>pre-restore-…</code>), so you can roll back or
restore without a Dropbox token. You can also upload a <code>.sql</code> dump you
downloaded from Dropbox manually. Regular backups remain Dropbox-only.
</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>
@ -302,6 +353,7 @@
rowHtml('Snapshots keep', d.retention_count ?? 14), rowHtml('Snapshots keep', d.retention_count ?? 14),
rowHtml('Schedule', d.schedule_enabled ? `enabled (${d.schedule_interval_hours || 24}h)` : 'disabled'), rowHtml('Schedule', d.schedule_enabled ? `enabled (${d.schedule_interval_hours || 24}h)` : 'disabled'),
rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'), rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'),
rowHtml('psql', d.psql_available ? (d.psql_path || 'available') : 'missing'),
rowHtml('Library exists', fmtStatus(d.library_exists)), rowHtml('Library exists', fmtStatus(d.library_exists)),
rowHtml('Library path', d.library_path || '-'), rowHtml('Library path', d.library_path || '-'),
].join(''); ].join('');
@ -545,7 +597,7 @@
} }
async function refreshAll() { async function refreshAll() {
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots()]); await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps(), loadLocalDumps()]);
pollRunProgress(); pollRunProgress();
} }
@ -648,13 +700,18 @@
return; return;
} }
body.innerHTML = filtered.map(f => { body.innerHTML = filtered.map(f => {
const ext = f.path.split('.').pop().toUpperCase(); const isDb = f.storage === 'db';
const ext = isDb ? 'DB' : f.path.split('.').pop().toUpperCase();
const parts = f.path.split('/'); const parts = f.path.split('/');
const name = parts[parts.length - 1]; const name = parts[parts.length - 1];
const dir = parts.slice(0, -1).join('/'); const dir = parts.slice(0, -1).join('/');
const onDisk = f.exists_locally const onDisk = isDb
? (f.exists_locally
? '<span class="ok" title="Book already present in the library">&#10003; in library</span>'
: '<span class="warn">not in library</span>')
: (f.exists_locally
? '<span class="ok" title="File already on disk">&#10003; exists</span>' ? '<span class="ok" title="File already on disk">&#10003; exists</span>'
: '<span class="warn">missing</span>'; : '<span class="warn">missing</span>');
return `<tr> return `<tr>
<td><input type="checkbox" class="restore-chk" data-path="${esc(f.path)}" onchange="updateRestoreBtn()"/></td> <td><input type="checkbox" class="restore-chk" data-path="${esc(f.path)}" onchange="updateRestoreBtn()"/></td>
<td><span style="font-family:var(--mono);font-size:0.68rem;color:var(--text-dim)">${esc(ext)}</span></td> <td><span style="font-family:var(--mono);font-size:0.68rem;color:var(--text-dim)">${esc(ext)}</span></td>
@ -722,6 +779,147 @@
} }
} }
// ── Full database restore ────────────────────────────────────────────────
async function loadPgDumps() {
const sel = document.getElementById('pgdump-select');
const btn = document.getElementById('btn-pg-restore');
try {
const r = await fetch('/api/backup/postgres/dumps');
const d = await r.json();
if (!d.ok || !d.dumps.length) {
sel.innerHTML = '<option value="">— no database dumps available —</option>';
btn.disabled = true;
return;
}
const current = sel.value;
sel.innerHTML = '<option value="">— select database dump —</option>' +
d.dumps.map(x => `<option value="${esc(x.name)}"${x.name === current ? ' selected' : ''}>${esc(x.name)}</option>`).join('');
btn.disabled = !sel.value;
} catch (_) {
sel.innerHTML = '<option value="">— Dropbox not configured —</option>';
btn.disabled = true;
}
}
document.getElementById('pgdump-select').addEventListener('change', (e) => {
document.getElementById('btn-pg-restore').disabled = !e.target.value;
});
async function restorePgDump() {
const name = document.getElementById('pgdump-select').value;
const out = document.getElementById('pgdump-status');
if (!name) return;
if (!confirm(`Restore the ENTIRE database from "${name}"?\n\nThis drops and replaces the current database. All current data will be lost. This cannot be undone.`)) return;
if (!confirm('Are you absolutely sure? This is your last chance to cancel.')) return;
const btn = document.getElementById('btn-pg-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/postgres/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 = `Database restore failed: ${e}`;
} finally {
btn.disabled = false;
}
}
// ── 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

@ -1,5 +1,51 @@
# Develop Changelog # Develop Changelog
## 2026-06-01 — Sidebar version is display-only
### Changed
- The build version at the bottom of the sidebar is no longer a clickable link to the changelog — it is now a plain text display. It exists only to show that the running build number has incremented (so it's clear the new code is active).
- `templates/_sidebar.html`: `<a href="/changelog" class="sidebar-version">``<span class="sidebar-version">`.
- `static/sidebar.css`: dropped the link/hover styling; added `cursor: default` and `user-select: none`.
## 2026-06-01 — Local pre-restore snapshots + token-free / upload restore
### Added
- Every restore now writes its **pre-restore safety dump to a local store** on the config volume (`CONFIG_DIR/postgres_dumps/`, named `pre-restore-…`, with snapshot-equal retention), so the database can be restored or rolled back **without a Dropbox token**. Regular backups are intentionally left **Dropbox-only** — production backs up hourly, so writing every backup to disk would fill the volume.
- `routers/backup.py`: `LOCAL_DUMP_DIR`, helpers `_save_local_dump`, `_list_local_dumps`, `_enforce_local_dump_retention`, `_resolve_local_dump`; `_run_pg_restore` persists the safety dump locally (and prunes by retention) before the destructive load. `_run_backup_internal` is unchanged (no local copy).
- 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 itself is not persisted; the automatic pre-restore safety snapshot covers the local copy.
- 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
- Full Database Restore could leave the database **empty** if the dump failed to load: the restore dropped and recreated the public schema first and only then applied the dump, so any failure during the load (e.g. a header `transaction_timeout` error with `ON_ERROR_STOP`) wiped all data with nothing to fall back to.
- `routers/backup.py`: split the load into `_apply_pg_dump`; `_run_pg_restore` now takes a safety `pg_dump` of the current database before the destructive load and, if applying the requested dump fails, automatically rolls back to that safety snapshot. A failed restore therefore no longer leaves an empty or broken database.
## 2026-06-01 — Full Database Restore: tolerate PostgreSQL version mismatch
### Fixed
- Full Database Restore failed with `ERROR: unrecognized configuration parameter "transaction_timeout"` when the dump was produced by a newer `pg_dump` (PostgreSQL 17+, which emits `SET transaction_timeout = 0;` in the header) but restored into an older server (<17) that doesn't know that session parameter. The restore ran with `ON_ERROR_STOP=1` and aborted on that harmless header line.
- `routers/backup.py` (`_run_pg_restore`): the dump is now applied without `ON_ERROR_STOP`; stderr is inspected afterwards via `_real_restore_errors`, which ignores benign "unrecognized configuration parameter" errors but still fails the restore on any other `ERROR:` line. The schema-reset step keeps `ON_ERROR_STOP=1` (it is our own controlled SQL).
## 2026-06-01 — Backup/Restore: database-stored books are now restorable
### Fixed
- Database-stored books (`storage_type='db'`, synthetic `db/...` filenames) could not be restored through the Backup → Restore option. The restore UI only listed and restored files from the on-disk library object store, but db-books have no file on disk — their content lives entirely in PostgreSQL (`book_chapters` + `library` row + `book_tags` + `library_cover_cache`). They were captured only in the full `pg_dump`, which the UI offered no way to restore. As a result db-books never appeared in the restore list and were effectively unrecoverable per-book.
### Added
- **Per-book db restore.** The backup writer now serializes each database book to JSON (library row, all chapters, tags, and the cached cover) and stores it in the same content-addressed Dropbox object store used for file books, referenced from the snapshot with a `"storage": "db"` marker. Such books now appear in the Restore table (format **DB**) and can be restored individually; restore re-inserts the library row, chapters, tags and cover into PostgreSQL. Inline chapter images are unaffected — they already live on disk under `library/images/` and are backed up as ordinary files.
- `routers/backup.py`: new `_db_book_filenames`, `_serialize_db_book`, `_restore_db_book`, `_entry_storage` helpers; db-books serialized during `_run_backup_internal`; `_download_and_restore` branches on storage type; `/api/backup/snapshots/{name}/files` now reports `storage` and computes `exists_locally` for db-books from the `library` table.
- Note: db-books only appear in snapshots created *after* this change. Older backups still contain them only in the `pg_dump` — recover those via Full Database Restore below.
- **Full Database Restore.** New Backup-page card and endpoints to restore the entire PostgreSQL database from any Dropbox `pg_dump`. This recovers everything (all db-books, reading progress, tags, settings) from existing backups too. It is destructive: the public schema is dropped and recreated before the dump is applied, so any plain dump (with or without `--clean`) restores cleanly. Guarded behind a double confirmation in the UI.
- `routers/backup.py`: `_psql_base_args`, `_run_pg_restore`, `_list_pg_dump_paths`; endpoints `GET /api/backup/postgres/dumps` and `POST /api/backup/postgres/restore`; health endpoint now also reports `psql_available`/`psql_path`.
- `templates/backup.html`: db-books shown with **DB** format and "in library / not in library" status in the restore table; new "Full Database Restore" card with dump selector, double-confirm, and `psql` health row.
---
*Released as v0.2.13 on 2026-06-01*
## 2026-06-01 — Reader: Sepia reading theme ## 2026-06-01 — Reader: Sepia reading theme
### Added ### Added

View File

@ -1,5 +1,21 @@
# Changelog # Changelog
## v0.2.13 — 2026-06-01
### New features
- Backup: database-stored books (those kept inside the database rather than as files on disk) are now included in snapshots and can be restored one by one from the Restore screen, just like file books. They previously existed only inside the full database dump and could not be restored individually. Note: they appear in snapshots created after this update; older database books are recovered via the full database restore below.
- Backup: new **Full Database Restore** — restore the entire database from any Dropbox database dump. This recovers everything, including all database-stored books, reading progress, tags and settings, and is guarded behind a double confirmation.
- Backup: **token-free restore**. Every restore now keeps a local pre-restore safety copy of the database on the config volume, so you can restore or roll back without a Dropbox token. You can also upload a `.sql` database dump (e.g. one downloaded manually from Dropbox) and restore it directly. Regular scheduled backups stay Dropbox-only.
### Improvements
- Backup: restores now take a safety snapshot of the current database first and automatically roll back to it if the dump fails to load, so a failed restore can no longer leave the database empty or broken.
- Backup: a full restore tolerates PostgreSQL version differences (such as a newer dump's `transaction_timeout` setting) instead of aborting on them, while still failing on genuine errors.
- Sidebar: the build version indicator at the bottom is now a plain display instead of a clickable link — it exists only to show that the running build has been updated.
---
## v0.2.12 — 2026-06-01 ## v0.2.12 — 2026-06-01
### New features ### New features