diff --git a/containers/novela/Dockerfile b/containers/novela/Dockerfile
index ddf227e..a1df6cf 100644
--- a/containers/novela/Dockerfile
+++ b/containers/novela/Dockerfile
@@ -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
diff --git a/containers/novela/changelog.py b/containers/novela/changelog.py
index 1bc6574..57ef5b5 100644
--- a/containers/novela/changelog.py
+++ b/containers/novela/changelog.py
@@ -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",
diff --git a/containers/novela/routers/backup.py b/containers/novela/routers/backup.py
index 1a1cb2f..41ad799 100644
--- a/containers/novela/routers/backup.py
+++ b/containers/novela/routers/backup.py
@@ -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)}
diff --git a/containers/novela/static/sidebar.css b/containers/novela/static/sidebar.css
index 6608942..348dc54 100644
--- a/containers/novela/static/sidebar.css
+++ b/containers/novela/static/sidebar.css
@@ -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;
diff --git a/containers/novela/templates/_sidebar.html b/containers/novela/templates/_sidebar.html
index ff4e686..dfb7a11 100644
--- a/containers/novela/templates/_sidebar.html
+++ b/containers/novela/templates/_sidebar.html
@@ -289,7 +289,7 @@
Rescan library
- {{ app_version() }}
+
diff --git a/containers/novela/templates/backup.html b/containers/novela/templates/backup.html
index f18eea1..dcd4869 100644
--- a/containers/novela/templates/backup.html
+++ b/containers/novela/templates/backup.html
@@ -242,7 +242,10 @@
- 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 DB) 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.
+ Restore the entire PostgreSQL database from a Dropbox
+ ⚠ Destructive: the current database is dropped and replaced entirely. There is no undo.
+
+ Before every restore a safety snapshot of the current database is saved locally
+ on the config volume (named
+ ⚠ Destructive: replaces the entire current database (with automatic rollback if the dump fails to load).
+ pg_dump. This
+ recovers everything — including all database-stored books, reading
+ progress, tags and settings — from any backup.
+ pre-restore-…), so you can roll back or
+ restore without a Dropbox token. You can also upload a .sql dump you
+ downloaded from Dropbox manually. Regular backups remain Dropbox-only.
+