feat(release): publish minimal self-contained Magent source

This commit is contained in:
Magent release tooling
2026-09-19 16:58:12 +12:00
commit 5fa5d45535
272 changed files with 79305 additions and 0 deletions
+647
View File
@@ -0,0 +1,647 @@
"""Encrypted, portable backups and restart-only SQLite restores.
Restore is deliberately a two-step operation: the authenticated request validates
and stages it, then a single backend process applies it before opening the DB.
A durable journal and a private rollback copy protect interrupted installations.
"""
from __future__ import annotations
from contextlib import closing, contextmanager
from datetime import datetime, timezone
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import shutil
import sqlite3
import stat
import tempfile
import threading
import time
from typing import Any, BinaryIO, Iterator
import uuid
import zipfile
import zlib
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from pydantic import TypeAdapter
from ..config import Settings, settings
from ..db import _db_path
from ..installation_origin import managed_runtime, normalize_application_origin
from ..schema_migrations import MIGRATIONS
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
FORMAT_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_EXPANDED_BYTES = 128 * 1024 * 1024
MAX_ENTRIES = 20_000
MAGIC = b"MAGENT-BACKUP\x00\x01"
_LOCK = threading.Lock()
_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
# Host identity, process controls and local file locations belong to the target.
_LOCAL_FIELDS = {
"sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
"admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
"auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
"auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
"log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
"magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
"magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
"site_build_number", "site_changelog", "magent_allow_private_notification_targets",
}
class BackupError(ValueError):
"""A safe-to-display backup validation or state error."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _assets_root() -> Path:
# Matches the image and branding routers, independently of SQLITE_PATH.
return Path.cwd() / "data"
def _control_root() -> Path:
return Path(_db_path()).absolute().parent / "backups"
def _private_dir(path: Path) -> None:
if path.is_symlink():
raise BackupError("Backup directories must not be symbolic links")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
path.chmod(0o700)
def _write_private(path: Path, content: bytes) -> None:
with path.open("xb") as handle:
path.chmod(0o600)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
def _write_json(path: Path, data: dict) -> None:
temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
try:
_write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
os.replace(temporary, path)
_sync_directory(path.parent)
finally:
temporary.unlink(missing_ok=True)
def _sync_directory(path: Path) -> None:
if os.name != "nt":
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sync_tree(path: Path) -> None:
for parent, _directories, files in os.walk(path, topdown=False):
for filename in files:
with (Path(parent) / filename).open("r+b") as handle:
os.fsync(handle.fileno())
_sync_directory(Path(parent))
@contextmanager
def _exclusive_operation() -> Iterator[None]:
if not _LOCK.acquire(blocking=False):
raise BackupError("Another backup or restore operation is in progress")
handle = None
locked = False
try:
root = _control_root()
_private_dir(root)
handle = (root / "operation.lock").open("a+b")
os.chmod(handle.name, 0o600)
# OS locks are released even if a process crashes; support the dev host too.
if os.name == "nt":
import msvcrt
handle.seek(0)
if not handle.read(1):
handle.write(b"0")
handle.flush()
handle.seek(0)
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
else:
import fcntl
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
locked = True
yield
finally:
if handle is not None:
if locked:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
_LOCK.release()
def validate_passphrase(passphrase: str) -> None:
if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
raise BackupError("Use a backup passphrase between 12 and 1024 characters")
def _key(passphrase: str, salt: bytes) -> bytes:
validate_passphrase(passphrase)
return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
def _encrypt(content: bytes, passphrase: str) -> bytes:
salt, nonce = os.urandom(16), os.urandom(12)
header = MAGIC + salt + nonce
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
def _decrypt(content: bytes, passphrase: str) -> bytes:
header_size = len(MAGIC) + 28
if len(content) > MAX_UPLOAD_BYTES:
raise BackupError("Backup exceeds the 32 MiB upload limit")
if len(content) < header_size + 16 or not content.startswith(MAGIC):
raise BackupError("This is not a supported encrypted Magent backup")
salt = content[len(MAGIC):len(MAGIC) + 16]
nonce = content[len(MAGIC) + 16:header_size]
try:
return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
except InvalidTag as exc:
raise BackupError("Incorrect passphrase or damaged backup") from exc
def _database_copy(source: Path, destination: Path) -> None:
if not source.is_file() or source.is_symlink():
raise BackupError("The configured database is unavailable or is a symbolic link")
deadline = time.monotonic() + 60
def progress(_status: int, _remaining: int, _total: int) -> None:
if time.monotonic() > deadline:
raise BackupError("Database is too busy to back up; try again shortly")
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
with closing(sqlite3.connect(destination)) as dst:
destination.chmod(0o600)
src.backup(dst, pages=256, progress=progress, sleep=0.05)
dst.execute("PRAGMA journal_mode=DELETE")
def _portable_database(path: Path) -> None:
"""Materialize env-backed settings and remove source-specific encryption."""
with closing(sqlite3.connect(path)) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
# init_db recreates application-owned triggers after restoration; never
# distribute executable schema objects in a data backup.
for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
quoted = str(trigger).replace('"', '""')
conn.execute(f'DROP TRIGGER "{quoted}"')
overrides = dict(conn.execute("SELECT key, value FROM settings"))
for key, default in settings.model_dump().items():
if key in _LOCAL_FIELDS:
continue
value = overrides.get(key)
value = default if value is None else decrypt_setting_value(key, value)
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(key, "" if value is None else str(value), _now()),
)
for key in _LOCAL_FIELDS:
conn.execute("DELETE FROM settings WHERE key=?", (key,))
# Future secret keys may not yet be exposed through Settings.
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
conn.commit()
conn.execute("VACUUM")
def _asset_allowed(name: str, include_cache: bool) -> bool:
parts = PurePosixPath(name).parts
if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
return True
return bool(
include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
and parts[4] not in {".", ".."}
)
def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
root = _assets_root()
for directory in ("branding", "artwork") if include_cache else ("branding",):
base = root / directory
if not base.exists():
continue
if base.is_symlink() or root.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
for parent, directories, files in os.walk(base, followlinks=False):
if any((Path(parent) / name).is_symlink() for name in directories + files):
raise BackupError("Symbolic links are not supported in backup assets")
for filename in files:
path = Path(parent) / filename
archive_name = "files/" + path.relative_to(root).as_posix()
if _asset_allowed(archive_name, include_cache):
yield path, archive_name
def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
validate_passphrase(passphrase)
with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
directory = Path(temporary)
directory.chmod(0o700)
database = directory / "database.sqlite3"
_database_copy(Path(_db_path()).absolute(), database)
_portable_database(database)
files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
archive_path = directory / "payload.zip"
manifest = {
"format_version": FORMAT_VERSION, "created_at": _now(),
"build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
"files": {},
}
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
archive_path.chmod(0o600)
total = 0
for path, name in files:
digest = hashlib.sha256()
size = 0
with path.open("rb") as source, archive.open(name, "w") as destination:
while chunk := source.read(1024 * 1024):
total += len(chunk)
size += len(chunk)
if total > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
digest.update(chunk)
destination.write(chunk)
manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
archive.writestr("manifest.json", json.dumps(manifest))
if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
encrypted = _encrypt(archive_path.read_bytes(), passphrase)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return encrypted, f"magent-backup-{stamp}.magent-backup"
def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
try:
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
conn.execute("PRAGMA trusted_schema=OFF")
deadline = time.monotonic() + 30
conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
raise BackupError("Backup database failed its integrity check")
schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
if len(schema) > 500 or any(
kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
for kind, _name, sql in schema
):
raise BackupError("Backup contains an unsupported database schema")
if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
raise BackupError("Backup database contains broken references")
required = {
"settings": {"key", "value", "updated_at"},
"users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
"signup_invites": {"id", "code", "enabled"},
"requests_cache": {"request_id", "payload_json"},
"schema_migrations": {"version", "name", "applied_at"},
"password_reset_tokens": {"id", "token_hash"},
}
for table, fields in required.items():
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup does not contain a compatible Magent database")
optional = {
"installation_setup": {"id", "completed", "step", "completed_at"},
"installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
}
table_names = {name for kind, name, _sql in schema if kind == "table"}
for table, fields in optional.items():
if table in table_names:
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup setup state has an incompatible schema")
# An admin can stage a restore only after target initialization. Its
# schema is a trusted reference for *all* runtime columns, including
# versioned migrations that init_db will not rerun on a restored DB.
target = Path(_db_path()).absolute()
if target.is_file() and target != path:
with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
continue
quoted = str(table).replace('"', '""')
expected = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in reference.execute(f'PRAGMA table_info("{quoted}")')
}
actual = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in conn.execute(f'PRAGMA table_info("{quoted}")')
}
if expected != actual:
raise BackupError("Backup is missing database columns required by this installation")
versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
if versions != {migration.version for migration in MIGRATIONS}:
raise BackupError("Backup schema is incompatible; restore using the same Magent version")
if not conn.execute(
"SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
).fetchone():
raise BackupError("Backup must contain an active administrator account")
values = dict(conn.execute("SELECT key,value FROM settings"))
if _LOCAL_FIELDS.intersection(values):
raise BackupError("Backup contains host-specific configuration")
# Pydantic checks the types of portable settings without reading env values.
for key, value in values.items():
if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
value = decrypt_setting_value(key, value)
if key in Settings.model_fields and value not in {None, ""}:
field = Settings.model_fields[key]
TypeAdapter(field.rebuild_annotation()).validate_python(value)
except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup database or configuration is invalid") from exc
def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
try:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ENTRIES:
raise BackupError("Backup contains too many files")
names = [entry.filename for entry in entries]
if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
raise BackupError("Backup manifest is missing or contains duplicate files")
if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
for entry in entries:
parts = PurePosixPath(entry.filename).parts
mode = entry.external_attr >> 16
if (
entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
or str(PurePosixPath(entry.filename)) != entry.filename
or ":" in entry.filename or any(part in {".", ".."} for part in parts)
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
):
raise BackupError("Backup contains an unsafe archive entry")
if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
raise BackupError("Backup manifest is too large")
manifest = json.loads(archive.read("manifest.json"))
if (
not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
or not isinstance(manifest.get("include_cache"), bool)
or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
or not isinstance(manifest.get("files"), dict)
or set(manifest["files"]) != set(names) - {"manifest.json"}
):
raise BackupError("Backup manifest is invalid or unsupported")
extracted_bytes = 0
for entry in entries:
name = entry.filename
if name == "manifest.json":
continue
if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
raise BackupError("Backup contains an unsupported file")
expected = manifest["files"][name]
if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
raise BackupError("Backup file does not match its manifest")
target = directory.joinpath(*PurePosixPath(name).parts)
_private_dir(target.parent)
digest = hashlib.sha256()
with archive.open(entry) as source, target.open("xb") as destination:
target.chmod(0o600)
while chunk := source.read(1024 * 1024):
extracted_bytes += len(chunk)
if extracted_bytes > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
digest.update(chunk)
destination.write(chunk)
destination.flush()
os.fsync(destination.fileno())
if digest.hexdigest() != expected.get("sha256"):
raise BackupError("Backup file failed its checksum")
_validate_database(directory / "database.sqlite3")
return manifest
except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup archive is invalid or damaged") from exc
def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
validate_passphrase(passphrase)
with _exclusive_operation():
root = _control_root()
pending = root / "pending"
if pending.exists():
raise BackupError("A restore is already staged; cancel it before uploading another")
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
destination_origin = None
if managed_runtime():
from .public_urls import magent_public_url
try:
destination_origin = normalize_application_origin(magent_public_url())
except ValueError:
raise BackupError("Configure a valid destination application address before restoring a backup") from None
with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
stage = Path(temporary)
stage.chmod(0o700)
manifest = _extract_archive(payload, stage)
with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
if value and str(value).startswith("enc:v1:"):
raise BackupError("Backup settings are not portable")
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
if destination_origin is not None:
# The backup's hostname must not replace this installation's
# trusted browser origin or change its cookie policy.
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES ('magent_application_url',?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(destination_origin, _now()),
)
# Do not revive reset links or existing browser sessions. Invites remain intact.
conn.execute("DELETE FROM password_reset_tokens")
conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
if not manifest["include_cache"]:
conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
conn.commit()
# Remove plaintext secret remnants from replaced/free SQLite pages.
conn.execute("VACUUM")
metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
metadata["staged_at"] = _now()
_write_json(stage / "metadata.json", metadata)
# Stage survives reboot; it contains only secrets encrypted for this installation.
os.replace(stage, pending)
_sync_directory(root)
return metadata
def backup_status() -> dict[str, Any]:
root = _control_root()
pending_path = root / "pending" / "metadata.json"
last_path = root / "last-restore.json"
return {
"format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
"max_expanded_bytes": MAX_EXPANDED_BYTES,
"include_cache_default": False,
"pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
"last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
}
def cancel_restore() -> None:
with _exclusive_operation():
pending = _control_root() / "pending"
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
if pending.exists():
shutil.rmtree(pending)
def _replace_file(source: Path, target: Path) -> None:
_private_dir(target.parent)
temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
try:
shutil.copyfile(source, temporary)
temporary.chmod(0o600)
with temporary.open("r+b") as handle:
os.fsync(handle.fileno())
os.replace(temporary, target)
_sync_directory(target.parent)
finally:
temporary.unlink(missing_ok=True)
def _replace_assets(source: Path, target: Path) -> None:
if target.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
if target.exists():
shutil.rmtree(target)
if source.exists():
shutil.copytree(source, target, copy_function=shutil.copyfile)
for parent, _directories, files in os.walk(target):
Path(parent).chmod(0o700)
for filename in files:
(Path(parent) / filename).chmod(0o600)
_sync_tree(target)
if target.parent.exists():
_sync_directory(target.parent)
def _recover(journal: dict, root: Path) -> None:
rollback_name = journal.get("rollback_directory", "")
if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
raise BackupError("Restore recovery journal is invalid")
rollback = root / rollback_name
database = Path(_db_path()).absolute()
if journal["had_database"]:
_replace_file(rollback / "database.sqlite3", database)
else:
database.unlink(missing_ok=True)
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
for name in journal["asset_roots"]:
if name not in {"branding", "artwork"}:
raise BackupError("Restore recovery journal is invalid")
_replace_assets(rollback / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
"message": "An interrupted or failed restore was rolled back automatically.",
})
_write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
pending = root / "pending"
if pending.exists():
shutil.rmtree(pending)
(root / "restore-journal.json").unlink()
_sync_directory(root)
def apply_pending_restore() -> bool:
"""Call once before init_db, with no other backend processes using the DB."""
with _exclusive_operation():
root = _control_root()
journal_path = root / "restore-journal.json"
if journal_path.exists():
journal = json.loads(journal_path.read_text())
if journal.get("phase") in {"complete", "rolled_back"}:
if (root / "pending").exists():
shutil.rmtree(root / "pending")
journal_path.unlink()
_sync_directory(root)
return journal["phase"] == "complete"
_recover(journal, root)
return False
pending = root / "pending"
if not pending.exists():
return False
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
metadata = json.loads((pending / "metadata.json").read_text())
_validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
database = Path(_db_path()).absolute()
rollback = root / ("rollback-" + uuid.uuid4().hex)
_private_dir(rollback)
# Ensure all disk-space/permission failures in backup happen before replacement.
if database.exists():
_database_copy(database, rollback / "database.sqlite3")
names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
# Reject links anywhere before copying or deleting the controlled asset trees.
list(_asset_files(metadata["include_cache"]))
for name in names:
source = _assets_root() / name
if source.exists():
shutil.copytree(source, rollback / "files" / name)
_sync_tree(rollback)
journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
_write_json(journal_path, journal)
try:
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
_replace_file(pending / "database.sqlite3", database)
for name in names:
_replace_assets(pending / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
"backup_created_at": metadata["created_at"],
})
_write_json(journal_path, {**journal, "phase": "complete"})
except Exception:
_recover(journal, root)
raise
shutil.rmtree(pending)
journal_path.unlink()
_sync_directory(root)
return True