Kansho/backend/local_backup.py
2026-08-26 10:42:20 +02:00

396 lines
14 KiB
Python

"""Consistent local backup and restore for the holiday test phase.
Uses the SQLite backup API (not a live-file copy). Includes journal media.
Never packs .env, provider keys, or logs. The archive can contain personal
unencrypted journal data.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sqlite3
import sys
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
FORMAT_KIND = "kansho.local_backup"
FORMAT_VERSION = 1
DB_NAME = "kansho.sqlite"
MANIFEST_NAME = "manifest.json"
README_NAME = "README.txt"
MEDIA_PREFIX = "media/"
DEFAULT_DIR_NAME = "local-backups"
PERSONAL_DATA_WARNING = (
"Dieses Archiv kann persönliche, unverschlüsselte Journalinhalte und Medien enthalten. "
"Es enthält keine .env-Dateien, Provider-Keys oder Logs, ist aber selbst nicht verschlüsselt."
)
EXCLUDE_NAMES = {".env", ".env.example"}
EXCLUDE_SUFFIXES = {".log", ".pem", ".key"}
EXCLUDE_DIR_NAMES = {"logs", "__pycache__"}
class BackupError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.details = details or {}
def _now_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def default_backup_dir() -> Path:
env = (os.environ.get("KANSHO_BACKUP_DIR") or "").strip()
if env:
return Path(env)
return _repo_root() / DEFAULT_DIR_NAME
def default_db_path() -> Path:
env = (os.environ.get("KANSHO_DB_PATH") or "").strip()
if env:
return Path(env)
data_dir = (os.environ.get("KANSHO_DATA_DIR") or "").strip()
if data_dir:
return Path(data_dir) / "kansho.sqlite"
return Path(__file__).resolve().parent / "data" / "kansho.sqlite"
def default_media_root() -> Path:
env = (os.environ.get("KANSHO_MEDIA_ROOT") or "").strip()
if env:
return Path(env)
data_dir = (os.environ.get("KANSHO_DATA_DIR") or "").strip()
if data_dir:
return Path(data_dir) / "media"
return Path(__file__).resolve().parent / "data" / "media"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def _is_excluded(path: Path) -> bool:
if path.name in EXCLUDE_NAMES:
return True
if path.suffix.lower() in EXCLUDE_SUFFIXES:
return True
return any(part in EXCLUDE_DIR_NAMES for part in path.parts)
def list_media_files(media_root: Path) -> list[Path]:
if not media_root.exists():
return []
files: list[Path] = []
for path in sorted(media_root.rglob("*")):
if not path.is_file() or _is_excluded(path):
continue
files.append(path)
return files
def sqlite_snapshot(src: Path, dest: Path) -> None:
if not src.exists():
raise BackupError("db_missing", f"Keine Datenbank unter {src}")
dest.parent.mkdir(parents=True, exist_ok=True)
source = sqlite3.connect(str(src), timeout=5)
try:
target = sqlite3.connect(str(dest))
try:
source.backup(target)
finally:
target.close()
except sqlite3.OperationalError as exc:
raise BackupError("db_unavailable", f"SQLite-Snapshot fehlgeschlagen: {exc}") from exc
finally:
source.close()
def assert_db_idle(path: Path) -> None:
if not path.exists():
return
conn = sqlite3.connect(str(path), timeout=1)
try:
conn.execute("BEGIN EXCLUSIVE")
conn.rollback()
except sqlite3.OperationalError as exc:
raise BackupError(
"db_in_use",
"Die Datenbank ist geöffnet. Backend beenden und Restore erneut ausführen.",
) from exc
finally:
conn.close()
def build_manifest(created: str, db_file: Path, media_files: list[tuple[str, Path]]) -> dict:
checksums = {DB_NAME: sha256_file(db_file)}
media_entries = []
for rel, path in media_files:
checksums[f"{MEDIA_PREFIX}{rel}"] = sha256_file(path)
media_entries.append({"path": rel, "bytes": path.stat().st_size})
return {
"kind": FORMAT_KIND,
"format_version": FORMAT_VERSION,
"created": created,
"warning": PERSONAL_DATA_WARNING,
"contains": ["sqlite", "media"],
"excludes": [".env", "provider keys", "logs"],
"parts": {
"sqlite": {"path": DB_NAME, "bytes": db_file.stat().st_size},
"media": media_entries,
},
"checksums": checksums,
}
def create_backup(
dest: Path | None = None,
*,
db_path: Path | None = None,
media_root: Path | None = None,
) -> dict:
db_path = Path(db_path or default_db_path())
media_root = Path(media_root or default_media_root())
if dest is None:
dest = default_backup_dir() / f"kansho-{_now_stamp()}.zip"
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
created = _now_iso()
with tempfile.TemporaryDirectory(prefix="kansho-backup-") as raw:
tmp = Path(raw)
snapshot = tmp / DB_NAME
sqlite_snapshot(db_path, snapshot)
media_pairs: list[tuple[str, Path]] = []
for path in list_media_files(media_root):
rel = path.relative_to(media_root).as_posix()
media_pairs.append((rel, path))
manifest = build_manifest(created, snapshot, media_pairs)
staging = tmp / "archive"
staging.mkdir()
shutil.copy2(snapshot, staging / DB_NAME)
(staging / README_NAME).write_text(PERSONAL_DATA_WARNING + "\n", encoding="utf-8")
(staging / MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
media_dir = staging / "media"
for rel, path in media_pairs:
target = media_dir / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
if dest.exists():
raise BackupError("backup_exists", f"Zieldatei existiert bereits: {dest}")
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in staging.rglob("*"):
if path.is_file():
zf.write(path, path.relative_to(staging).as_posix())
return {"path": str(dest), "manifest": manifest}
def _read_zip_text(zf: zipfile.ZipFile, name: str) -> str:
try:
return zf.read(name).decode("utf-8")
except KeyError as exc:
raise BackupError("archive_invalid", f"Archivteil fehlt: {name}") from exc
def verify_archive(archive: Path) -> dict:
archive = Path(archive)
if not archive.is_file():
raise BackupError("archive_missing", f"Archiv nicht gefunden: {archive}")
with zipfile.ZipFile(archive) as zf:
names = set(zf.namelist())
if MANIFEST_NAME not in names:
raise BackupError("archive_invalid", "Manifest fehlt.")
try:
manifest = json.loads(_read_zip_text(zf, MANIFEST_NAME))
except json.JSONDecodeError as exc:
raise BackupError("archive_invalid", "Manifest ist kein JSON.") from exc
if manifest.get("kind") != FORMAT_KIND:
raise BackupError("archive_invalid", "Unbekanntes Backup-Format.")
if int(manifest.get("format_version") or 0) != FORMAT_VERSION:
raise BackupError("archive_unsupported", "Backup-Formatversion wird nicht unterstützt.")
checksums = manifest.get("checksums") or {}
if not isinstance(checksums, dict) or DB_NAME not in checksums:
raise BackupError("archive_invalid", "Checksummen fehlen.")
extra = sorted(
name
for name in names
if not name.endswith("/")
and name not in {MANIFEST_NAME, README_NAME}
and name not in checksums
)
missing = sorted(key for key in checksums if key not in names)
if missing:
raise BackupError(
"checksum_mismatch",
"Im Archiv fehlen deklarierte Dateien.",
details={"missing": missing},
)
with tempfile.TemporaryDirectory(prefix="kansho-verify-") as raw:
tmp = Path(raw)
for name, expected in checksums.items():
target = tmp / name
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, target.open("wb") as dest:
shutil.copyfileobj(src, dest)
actual = sha256_file(target)
if actual != expected:
raise BackupError(
"checksum_mismatch",
f"Checksumme stimmt nicht: {name}",
details={"path": name},
)
return {
"manifest": manifest,
"skipped_extra": extra,
"names": sorted(names),
}
def restore_backup(
archive: Path,
*,
db_path: Path | None = None,
media_root: Path | None = None,
confirm: bool = False,
allow_overwrite: bool = False,
safety_dir: Path | None = None,
) -> dict:
if not confirm:
raise BackupError(
"confirm_required",
"Restore überschreibt lokale Daten nur mit ausdrücklicher Bestätigung (--confirm).",
)
db_path = Path(db_path or default_db_path())
media_root = Path(media_root or default_media_root())
verified = verify_archive(archive)
current_exists = db_path.exists() or (media_root.exists() and any(media_root.rglob("*")))
if current_exists and not allow_overwrite:
raise BackupError(
"would_overwrite",
"Ein aktueller Datenbestand existiert. Restore nicht still. "
"Mit Bestätigung und --replace nach Sicherheitsbackup fortsetzen.",
)
assert_db_idle(db_path)
safety_path = None
if current_exists:
safety_dir = Path(safety_dir or default_backup_dir())
safety_path = safety_dir / f"pre-restore-{_now_stamp()}.zip"
create_backup(safety_path, db_path=db_path, media_root=media_root)
with tempfile.TemporaryDirectory(prefix="kansho-restore-") as raw:
extracted = Path(raw) / "extracted"
extracted.mkdir()
with zipfile.ZipFile(archive) as zf:
checksums = verified["manifest"]["checksums"]
for name in checksums:
target = extracted / name
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, target.open("wb") as dest:
shutil.copyfileobj(src, dest)
if sha256_file(target) != checksums[name]:
raise BackupError("checksum_mismatch", f"Checksumme nach Entpacken ungültig: {name}")
new_db = extracted / DB_NAME
new_media = extracted / "media"
db_path.parent.mkdir(parents=True, exist_ok=True)
tmp_db = db_path.with_suffix(db_path.suffix + ".restore-tmp")
if tmp_db.exists():
tmp_db.unlink()
shutil.copy2(new_db, tmp_db)
os.replace(tmp_db, db_path)
incoming_root = Path(raw) / "media-incoming"
incoming_root.mkdir()
if new_media.exists():
for path in new_media.rglob("*"):
if path.is_file():
rel = path.relative_to(new_media)
dest = incoming_root / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest)
swap = media_root.with_name(media_root.name + ".restore-old")
if swap.exists():
shutil.rmtree(swap)
if media_root.exists():
media_root.rename(swap)
incoming_root.rename(media_root)
if swap.exists():
shutil.rmtree(swap, ignore_errors=True)
return {
"restored": str(archive),
"db_path": str(db_path),
"media_root": str(media_root),
"safety_backup": str(safety_path) if safety_path else None,
"skipped_extra": verified["skipped_extra"],
"warning": PERSONAL_DATA_WARNING,
}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Lokales Kanshō-Backup (SQLite-Snapshot + Medien). Persönliche unverschlüsselte Daten."
)
sub = parser.add_subparsers(dest="action", required=True)
create = sub.add_parser("create", help="Konsistentes lokales Backup erzeugen")
create.add_argument("--out", help="Zieldatei. Standard: local-backups/kansho-<zeit>.zip")
restore = sub.add_parser("restore", help="Backup prüfen und einspielen")
restore.add_argument("archive", help="Backup-Archiv")
restore.add_argument("--confirm", action="store_true", help="Ausdrückliche Bestätigung, sonst Abbruch")
restore.add_argument("--replace", action="store_true", help="Nach Sicherheitsbackup aktuellen Stand ersetzen")
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
if args.action == "create":
result = create_backup(Path(args.out) if args.out else None)
print(PERSONAL_DATA_WARNING)
print(f"Backup: {result['path']}")
print(f"Teile: sqlite + {len(result['manifest']['parts']['media'])} Mediendateien")
return 0
result = restore_backup(
Path(args.archive),
confirm=bool(args.confirm),
allow_overwrite=bool(args.replace),
)
print(PERSONAL_DATA_WARNING)
print(f"Restore: {result['db_path']}")
if result["safety_backup"]:
print(f"Sicherheitsbackup: {result['safety_backup']}")
if result["skipped_extra"]:
print("Zusätzliche Archivdateien ignoriert: " + ", ".join(result["skipped_extra"]))
return 0
except BackupError as exc:
print(f"ERROR {exc.code}: {exc.message}", file=sys.stderr)
if exc.details:
print(json.dumps(exc.details, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())