Compare commits
3 Commits
566c52cf34
...
f739bee5e6
| Author | SHA1 | Date | |
|---|---|---|---|
| f739bee5e6 | |||
| 57526beaaa | |||
| 3093af68d6 |
@ -2,12 +2,22 @@ FROM python:3.12-slim
|
||||
|
||||
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 \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libmagic1 \
|
||||
unrar \
|
||||
postgresql-client \
|
||||
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 \
|
||||
build-essential \
|
||||
libmagic1 \
|
||||
unrar \
|
||||
postgresql-client-16 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
|
||||
@ -3,6 +3,31 @@ Changelog data for Novela
|
||||
"""
|
||||
|
||||
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",
|
||||
"date": "2026-06-01",
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from urllib.parse import urlencode
|
||||
@ -12,12 +13,17 @@ 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
|
||||
|
||||
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
|
||||
|
||||
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.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
|
||||
@ -533,6 +542,169 @@ def _snapshot_name() -> str:
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
with get_db_conn() as 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_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")
|
||||
|
||||
snapshot = {
|
||||
@ -927,6 +1277,7 @@ async def backup_dropbox_credentials_delete():
|
||||
async def backup_health():
|
||||
token_present = bool(_load_dropbox_token())
|
||||
pg_dump_path = shutil.which("pg_dump")
|
||||
psql_path = shutil.which("psql")
|
||||
|
||||
dropbox_ok = False
|
||||
dropbox_error = None
|
||||
@ -951,6 +1302,8 @@ async def backup_health():
|
||||
"schedule_interval_hours": schedule_interval_hours,
|
||||
"pg_dump_available": bool(pg_dump_path),
|
||||
"pg_dump_path": pg_dump_path,
|
||||
"psql_available": bool(psql_path),
|
||||
"psql_path": psql_path,
|
||||
"library_exists": LIBRARY_DIR.exists(),
|
||||
"library_path": str(LIBRARY_DIR.resolve()),
|
||||
}
|
||||
@ -1250,6 +1603,13 @@ def _parse_snapshot_date(name: str) -> str:
|
||||
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:
|
||||
sha256 = str(info.get("sha256") or "")
|
||||
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)
|
||||
_meta, res = client.files_download(obj_path)
|
||||
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.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(data)
|
||||
@ -1306,16 +1677,37 @@ async def snapshot_files(snapshot_name: str):
|
||||
return {"ok": False, "error": str(e), "files": []}
|
||||
|
||||
files_data = snap.get("files", {})
|
||||
result = [
|
||||
{
|
||||
"path": rel,
|
||||
"size": info.get("size", 0),
|
||||
"sha256": info.get("sha256", ""),
|
||||
"exists_locally": (LIBRARY_DIR / rel).exists(),
|
||||
}
|
||||
for rel, info in sorted(files_data.items())
|
||||
if isinstance(info, dict)
|
||||
|
||||
# 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,
|
||||
"size": info.get("size", 0),
|
||||
"sha256": info.get("sha256", ""),
|
||||
"storage": storage,
|
||||
"exists_locally": exists,
|
||||
}
|
||||
)
|
||||
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"])
|
||||
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)}
|
||||
|
||||
@ -125,10 +125,10 @@ html {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.sidebar-version:hover { opacity: 1; }
|
||||
|
||||
.disk-warning {
|
||||
display: flex;
|
||||
|
||||
@ -289,7 +289,7 @@
|
||||
</svg>
|
||||
<span id="rescan-label">Rescan library</span>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
|
||||
@ -242,7 +242,10 @@
|
||||
<section class="card">
|
||||
<div class="card-head">Restore</div>
|
||||
<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>
|
||||
<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()">
|
||||
@ -275,6 +278,54 @@
|
||||
</div>
|
||||
<div class="status-line" id="restore-status"></div>
|
||||
</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 & restore</button>
|
||||
</div>
|
||||
|
||||
<div class="status-line" id="localdump-status"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/books.js"></script>
|
||||
@ -302,6 +353,7 @@
|
||||
rowHtml('Snapshots keep', d.retention_count ?? 14),
|
||||
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('psql', d.psql_available ? (d.psql_path || 'available') : 'missing'),
|
||||
rowHtml('Library exists', fmtStatus(d.library_exists)),
|
||||
rowHtml('Library path', d.library_path || '-'),
|
||||
].join('');
|
||||
@ -545,7 +597,7 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots()]);
|
||||
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps(), loadLocalDumps()]);
|
||||
pollRunProgress();
|
||||
}
|
||||
|
||||
@ -648,13 +700,18 @@
|
||||
return;
|
||||
}
|
||||
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 name = parts[parts.length - 1];
|
||||
const dir = parts.slice(0, -1).join('/');
|
||||
const onDisk = f.exists_locally
|
||||
? '<span class="ok" title="File already on disk">✓ exists</span>'
|
||||
: '<span class="warn">missing</span>';
|
||||
const onDisk = isDb
|
||||
? (f.exists_locally
|
||||
? '<span class="ok" title="Book already present in the library">✓ in library</span>'
|
||||
: '<span class="warn">not in library</span>')
|
||||
: (f.exists_locally
|
||||
? '<span class="ok" title="File already on disk">✓ exists</span>'
|
||||
: '<span class="warn">missing</span>');
|
||||
return `<tr>
|
||||
<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>
|
||||
@ -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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -1,5 +1,51 @@
|
||||
# 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
|
||||
|
||||
### Added
|
||||
|
||||
@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
### New features
|
||||
|
||||
Loading…
Reference in New Issue
Block a user