libervia-backend/libervia/backend/tools/backup/handlers.py
Goffi 86a91b5850 tools (backup): add backup/restore:
Add an offline backup/restore engine in `tools.backup`:

- format based on XEP-0227 (pie), extended with Libervia sections
- handle several data types (params, private, roster, pep , gpg, files and db)
- per profile export/import and global handlers
- a manifest is used to have metadata and checksums
- full database snapshot/restore with the `db` type
- sqla: add `get_db_url`
- tools.common.utils: add `is_safe_relpath` and `is_process_running`

rel 480
2026-09-15 11:48:11 +02:00

166 lines
5 KiB
Python

#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import shutil
from libervia.backend.core.constants import Const as C
from .core import (
DB_FILE,
DbDriver,
FILES_FILE_TPL,
GlobalHandler,
Handler,
ProfileHandler,
Report,
Staging,
get_db_driver,
sqlite_db_config,
)
from .gpg import GpgHandler
from .pie import ParamsHandler, PepHandler, PrivateHandler, RosterHandler
class FilesHandler(GlobalHandler):
name = "files"
def export(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
) -> None:
"""Copy the file sharing blobs to the staging
@param local_dir: local data directory
@param staging: staging where the blobs are copied
@param report: export report
"""
files_dir = local_dir / C.FILES_DIR
if not files_dir.is_dir():
return
count = 0
for path in sorted(files_dir.rglob("*")):
if path.is_file():
staging.add_file(
FILES_FILE_TPL.format(path=path.relative_to(files_dir).as_posix()),
path,
0o600,
)
count += 1
if count:
report.done.append(f"files: {count} file(s)")
def import_(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
force: bool = False,
) -> None:
"""Copy the staged file sharing blobs to the local data directory
The blobs are content-addressed (the name is the content hash), so overwriting an
existing file is safe and repairs a corrupted one.
@param local_dir: local data directory
@param staging: staging holding the blobs
@param report: import report
"""
files_dir = local_dir / C.FILES_DIR
# entry name prefix, derived from the staging name template
prefix = FILES_FILE_TPL.split("{}")[0]
count = 0
for name in staging.names():
if not name.startswith(prefix):
continue
dst = files_dir / name[len(prefix) :]
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(staging.path(name), dst)
count += 1
if count:
report.done.append(f"files: {count} file(s)")
class DbHandler(GlobalHandler):
name = "db"
def _driver(self, local_dir: Path) -> DbDriver:
return get_db_driver(sqlite_db_config(local_dir))
def export(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
) -> None:
"""Write a snapshot of the database to the staging
@param report: export report
"""
self._driver(local_dir).snapshot(staging)
def import_(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
force: bool = False,
) -> None:
"""Restore the database snapshot from the staging (destructive)
@param report: import report
@param force: the restore is destructive, it requires force
"""
if DB_FILE not in staging:
return
res = self._driver(local_dir).restore(staging, force)
report.done.extend(res.done)
report.skipped.extend(res.skipped)
# one handler per data type, in export order (the order is semantic: the database restore
# comes last, it replaces the database)
REGISTRY: list[Handler] = [
ParamsHandler(),
PrivateHandler(),
RosterHandler(),
PepHandler(),
GpgHandler(),
FilesHandler(),
DbHandler(),
]
# "db" is a non-portable copy of the database (it will be migrated automatically on start
# if the Libervia version is newer): it covers all the data, while the portable types
# don't cover everything yet.
DATA_TYPES: tuple[str, ...] = tuple(handler.name for handler in REGISTRY)
# data types stored in the database: a database restore brings them back, so
# there is no need to import them on top of it
DB_BACKED_TYPES: tuple[str, ...] = tuple(
handler.name
for handler in REGISTRY
if isinstance(handler, ProfileHandler) and handler.uses_db
)