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
166 lines
5 KiB
Python
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
|
|
)
|