396 lines
14 KiB
Python
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())
|