diff --git a/frontend/pages/admin/backups.vue b/frontend/pages/admin/backups.vue index 77464b074..06e1ccce4 100644 --- a/frontend/pages/admin/backups.vue +++ b/frontend/pages/admin/backups.vue @@ -5,7 +5,7 @@

- {{ selected.name }} + {{ selected }}

@@ -47,9 +47,12 @@ Backups a total snapshots of the database and data directory of the site. This includes all data and cannot be set to exclude subsets of data. You can think off this as a snapshot of Mealie at a specific time. - Currently, this backup mechanism is not cross-version and therefore cannot be used to migrate data between - versions (data migrations are not done automatically). These serve as a database agnostic way to export and - import data or backup the site to an external location. + Currently, + + this backup mechanism is not cross-version and therefore cannot be used to migrate data between versions + + (data migrations are not done automatically). These serve as a database agnostic way to export and import + data or backup the site to an external location. {{ $t("settings.backup.create-heading") }} @@ -78,7 +81,7 @@ > {{ $globals.icons.delete }} - + @@ -137,11 +140,15 @@ export default defineComponent({ } async function restoreBackup(fileName: string) { - const { data } = await adminApi.backups.restore(fileName); + const { error } = await adminApi.backups.restore(fileName); - if (!data?.error) { - $auth.logout(); + if (error) { + console.log(error); + state.importDialog = false; + return; } + + $auth.logout(); } const deleteTarget = ref(""); diff --git a/mealie/routes/admin/admin_backups.py b/mealie/routes/admin/admin_backups.py index 7b33cbf6f..46c21f581 100644 --- a/mealie/routes/admin/admin_backups.py +++ b/mealie/routes/admin/admin_backups.py @@ -9,8 +9,8 @@ from mealie.core.security import create_file_token from mealie.pkgs.stats.fs_stats import pretty_size from mealie.routes._base import BaseAdminController, controller from mealie.schema.admin.backup import AllBackups, BackupFile -from mealie.schema.response.responses import FileTokenResponse, SuccessResponse -from mealie.services.backups_v2.backup_v2 import BackupV2 +from mealie.schema.response.responses import ErrorResponse, FileTokenResponse, SuccessResponse +from mealie.services.backups_v2.backup_v2 import BackupSchemaMismatch, BackupV2 router = APIRouter(prefix="/backups") @@ -100,6 +100,11 @@ class AdminBackupController(BaseAdminController): try: backup.restore(file) + except BackupSchemaMismatch as e: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + ErrorResponse.respond("database backup schema version does not match current database"), + ) from e except Exception as e: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) from e diff --git a/mealie/services/backups_v2/backup_file.py b/mealie/services/backups_v2/backup_file.py index 187ed3b3e..ce7d9ad53 100644 --- a/mealie/services/backups_v2/backup_file.py +++ b/mealie/services/backups_v2/backup_file.py @@ -5,6 +5,8 @@ from pathlib import Path class BackupContents: + _tables: dict = None + def __init__(self, file: Path) -> None: self.base = file self.data_directory = self.base / "data" @@ -22,9 +24,22 @@ class BackupContents: return True + def schema_version(self) -> str: + tables = self.read_tables() + + alembic_version = tables.get("alembic_version", []) + + if not alembic_version: + return "" + + return alembic_version[0].get("version_num", "") + def read_tables(self) -> dict: - with open(self.tables) as f: - return json.loads(f.read()) + if self._tables is None: + with open(self.tables, "r") as f: + self._tables = json.load(f) + + return self._tables class BackupFile: diff --git a/mealie/services/backups_v2/backup_v2.py b/mealie/services/backups_v2/backup_v2.py index 65a4b44e2..4a5a9b034 100644 --- a/mealie/services/backups_v2/backup_v2.py +++ b/mealie/services/backups_v2/backup_v2.py @@ -9,6 +9,10 @@ from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter from mealie.services.backups_v2.backup_file import BackupFile +class BackupSchemaMismatch(Exception): + ... + + class BackupV2(BaseService): def __init__(self, db_url: str = None) -> None: super().__init__() @@ -73,18 +77,28 @@ class BackupV2(BaseService): self._postgres() with backup as contents: + # ================================ + # Validation if not contents.validate(): self.logger.error( "Invalid backup file. file does not contain required elements (data directory and database.json" ) raise ValueError("Invalid backup file") - # Purge the Database + database_json = contents.read_tables() + + if not AlchemyExporter.validate_schemas(database_json, self.db_exporter.dump()): + self.logger.error("Invalid backup file. Database schemas do not match") + raise BackupSchemaMismatch("Invalid backup file. Database schemas do not match") + + # ================================ + # Purge Database self.logger.info("dropping all database tables") self.db_exporter.drop_all() - database_json = contents.read_tables() + # ================================ + # Restore Database self.logger.info("importing database tables") self.db_exporter.restore(database_json)