"""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-.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())