From 1637fd63f450622f6094eea698b73c4e83f33cab Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Fri, 30 Jan 2026 22:51:25 +0100
Subject: [PATCH] Add backup restore feature
- Add restoreBackup() method to UpdateExecutor with full restore workflow
- Add getBackupDetails() to retrieve backup metadata and contents info
- Add restore controller routes (backup details API, restore action)
- Add restore button to backups table in UI
- Create backup_restore_controller.js Stimulus controller for confirmation
- Add translation strings for restore feature
The restore process:
1. Acquires lock and enables maintenance mode
2. Extracts backup to temp directory
3. Restores database (MySQL/PostgreSQL SQL or SQLite file)
4. Optionally restores config files and attachments
5. Clears and warms cache
6. Disables maintenance mode
Fix backup restore database import
The restore feature was using a non-existent doctrine:database:import
command. Now properly uses mysql/psql commands directly to import
database dumps.
Changes:
- Add EntityManagerInterface dependency to UpdateExecutor
- Use mysql command with shell input redirection for MySQL restore
- Use psql -f command for PostgreSQL restore
- Properly handle database connection parameters
- Add error handling for failed imports
---
.../controllers/backup_restore_controller.js | 55 +++
src/Controller/UpdateManagerController.php | 71 ++++
src/Services/System/UpdateExecutor.php | 328 +++++++++++++++++-
.../admin/update_manager/index.html.twig | 23 +-
translations/messages.en.xlf | 24 ++
5 files changed, 499 insertions(+), 2 deletions(-)
create mode 100644 assets/controllers/backup_restore_controller.js
diff --git a/assets/controllers/backup_restore_controller.js b/assets/controllers/backup_restore_controller.js
new file mode 100644
index 00000000..85ee327b
--- /dev/null
+++ b/assets/controllers/backup_restore_controller.js
@@ -0,0 +1,55 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * 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 .
+ */
+
+import { Controller } from '@hotwired/stimulus';
+
+/**
+ * Stimulus controller for backup restore confirmation dialogs.
+ * Shows a confirmation dialog with backup details before allowing restore.
+ */
+export default class extends Controller {
+ static values = {
+ filename: { type: String, default: '' },
+ date: { type: String, default: '' },
+ confirmTitle: { type: String, default: 'Restore Backup' },
+ confirmMessage: { type: String, default: 'Are you sure you want to restore from this backup?' },
+ confirmWarning: { type: String, default: 'This will overwrite your current database. This action cannot be undone!' },
+ };
+
+ connect() {
+ this.element.addEventListener('submit', this.handleSubmit.bind(this));
+ }
+
+ handleSubmit(event) {
+ // Always prevent default first
+ event.preventDefault();
+
+ // Build confirmation message
+ const message = this.confirmTitleValue + '\n\n' +
+ 'Backup: ' + this.filenameValue + '\n' +
+ 'Date: ' + this.dateValue + '\n\n' +
+ this.confirmMessageValue + '\n\n' +
+ '⚠️ ' + this.confirmWarningValue;
+
+ // Only submit if user confirms
+ if (confirm(message)) {
+ this.element.submit();
+ }
+ }
+}
diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php
index 10a719de..8455516a 100644
--- a/src/Controller/UpdateManagerController.php
+++ b/src/Controller/UpdateManagerController.php
@@ -265,4 +265,75 @@ class UpdateManagerController extends AbstractController
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
]);
}
+
+ /**
+ * Get backup details for restore confirmation.
+ */
+ #[Route('/backup/{filename}', name: 'admin_update_manager_backup_details', methods: ['GET'])]
+ public function backupDetails(string $filename): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('@system.manage_updates');
+
+ $details = $this->updateExecutor->getBackupDetails($filename);
+
+ if (!$details) {
+ return $this->json(['error' => 'Backup not found'], 404);
+ }
+
+ return $this->json($details);
+ }
+
+ /**
+ * Restore from a backup.
+ */
+ #[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
+ public function restore(Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('@system.manage_updates');
+
+ // Validate CSRF token
+ if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) {
+ $this->addFlash('error', 'Invalid CSRF token.');
+ return $this->redirectToRoute('admin_update_manager');
+ }
+
+ // Check if already locked
+ if ($this->updateExecutor->isLocked()) {
+ $this->addFlash('error', 'An update or restore is already in progress.');
+ return $this->redirectToRoute('admin_update_manager');
+ }
+
+ $filename = $request->request->get('filename');
+ $restoreDatabase = $request->request->getBoolean('restore_database', true);
+ $restoreConfig = $request->request->getBoolean('restore_config', false);
+ $restoreAttachments = $request->request->getBoolean('restore_attachments', false);
+
+ if (!$filename) {
+ $this->addFlash('error', 'No backup file specified.');
+ return $this->redirectToRoute('admin_update_manager');
+ }
+
+ // Verify the backup exists
+ $backupDetails = $this->updateExecutor->getBackupDetails($filename);
+ if (!$backupDetails) {
+ $this->addFlash('error', 'Backup file not found.');
+ return $this->redirectToRoute('admin_update_manager');
+ }
+
+ // Execute restore (this is a synchronous operation for now - could be made async later)
+ $result = $this->updateExecutor->restoreBackup(
+ $filename,
+ $restoreDatabase,
+ $restoreConfig,
+ $restoreAttachments
+ );
+
+ if ($result['success']) {
+ $this->addFlash('success', 'Backup restored successfully.');
+ } else {
+ $this->addFlash('error', 'Restore failed: ' . ($result['error'] ?? 'Unknown error'));
+ }
+
+ return $this->redirectToRoute('admin_update_manager');
+ }
}
diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php
index 7bc997f7..837cde4c 100644
--- a/src/Services/System/UpdateExecutor.php
+++ b/src/Services/System/UpdateExecutor.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\System;
+use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -51,7 +52,8 @@ class UpdateExecutor
public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir,
private readonly LoggerInterface $logger, private readonly Filesystem $filesystem,
private readonly InstallationTypeDetector $installationTypeDetector,
- private readonly VersionManagerInterface $versionManager)
+ private readonly VersionManagerInterface $versionManager,
+ private readonly EntityManagerInterface $entityManager)
{
}
@@ -628,6 +630,330 @@ class UpdateExecutor
return $backups;
}
+ /**
+ * Get details about a specific backup file.
+ *
+ * @param string $filename The backup filename
+ * @return array|null Backup details or null if not found
+ */
+ public function getBackupDetails(string $filename): ?array
+ {
+ $backupDir = $this->project_dir . '/' . self::BACKUP_DIR;
+ $backupPath = $backupDir . '/' . basename($filename);
+
+ if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) {
+ return null;
+ }
+
+ // Parse version info from filename: pre-update-v2.5.1-to-v2.5.0-2024-01-30-185400.zip
+ $info = [
+ 'file' => basename($backupPath),
+ 'path' => $backupPath,
+ 'date' => filemtime($backupPath),
+ 'size' => filesize($backupPath),
+ 'from_version' => null,
+ 'to_version' => null,
+ ];
+
+ if (preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches)) {
+ $info['from_version'] = $matches[1];
+ $info['to_version'] = $matches[2];
+ }
+
+ // Check what the backup contains by reading the ZIP
+ try {
+ $zip = new \ZipArchive();
+ if ($zip->open($backupPath) === true) {
+ $info['contains_database'] = $zip->locateName('database.sql') !== false || $zip->locateName('var/app.db') !== false;
+ $info['contains_config'] = $zip->locateName('.env.local') !== false || $zip->locateName('config/parameters.yaml') !== false;
+ $info['contains_attachments'] = $zip->locateName('public/media/') !== false || $zip->locateName('uploads/') !== false;
+ $zip->close();
+ }
+ } catch (\Exception $e) {
+ $this->logger->warning('Could not read backup ZIP contents', ['error' => $e->getMessage()]);
+ }
+
+ return $info;
+ }
+
+ /**
+ * Restore from a backup file.
+ *
+ * @param string $filename The backup filename to restore
+ * @param bool $restoreDatabase Whether to restore the database
+ * @param bool $restoreConfig Whether to restore config files
+ * @param bool $restoreAttachments Whether to restore attachments
+ * @param callable|null $onProgress Callback for progress updates
+ * @return array{success: bool, steps: array, error: ?string}
+ */
+ public function restoreBackup(
+ string $filename,
+ bool $restoreDatabase = true,
+ bool $restoreConfig = false,
+ bool $restoreAttachments = false,
+ ?callable $onProgress = null
+ ): array {
+ $this->steps = [];
+ $startTime = microtime(true);
+
+ $log = function (string $step, string $message, bool $success, ?float $duration = null) use ($onProgress): void {
+ $entry = [
+ 'step' => $step,
+ 'message' => $message,
+ 'success' => $success,
+ 'timestamp' => (new \DateTime())->format('c'),
+ 'duration' => $duration,
+ ];
+ $this->steps[] = $entry;
+ $this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
+
+ if ($onProgress) {
+ $onProgress($entry);
+ }
+ };
+
+ try {
+ // Validate backup file
+ $backupDir = $this->project_dir . '/' . self::BACKUP_DIR;
+ $backupPath = $backupDir . '/' . basename($filename);
+
+ if (!file_exists($backupPath)) {
+ throw new \RuntimeException('Backup file not found: ' . $filename);
+ }
+
+ $stepStart = microtime(true);
+
+ // Step 1: Acquire lock
+ $this->acquireLock('restore');
+ $log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart);
+
+ // Step 2: Enable maintenance mode
+ $stepStart = microtime(true);
+ $this->enableMaintenanceMode('Restoring from backup...');
+ $log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
+
+ // Step 3: Extract backup to temp directory
+ $stepStart = microtime(true);
+ $tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid();
+ $this->filesystem->mkdir($tempDir);
+
+ $zip = new \ZipArchive();
+ if ($zip->open($backupPath) !== true) {
+ throw new \RuntimeException('Could not open backup ZIP file');
+ }
+ $zip->extractTo($tempDir);
+ $zip->close();
+ $log('extract', 'Extracted backup to temporary directory', true, microtime(true) - $stepStart);
+
+ // Step 4: Restore database if requested and present
+ if ($restoreDatabase) {
+ $stepStart = microtime(true);
+ $this->restoreDatabaseFromBackup($tempDir);
+ $log('database', 'Restored database', true, microtime(true) - $stepStart);
+ }
+
+ // Step 5: Restore config files if requested and present
+ if ($restoreConfig) {
+ $stepStart = microtime(true);
+ $this->restoreConfigFromBackup($tempDir);
+ $log('config', 'Restored configuration files', true, microtime(true) - $stepStart);
+ }
+
+ // Step 6: Restore attachments if requested and present
+ if ($restoreAttachments) {
+ $stepStart = microtime(true);
+ $this->restoreAttachmentsFromBackup($tempDir);
+ $log('attachments', 'Restored attachments', true, microtime(true) - $stepStart);
+ }
+
+ // Step 7: Clean up temp directory
+ $stepStart = microtime(true);
+ $this->filesystem->remove($tempDir);
+ $log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart);
+
+ // Step 8: Clear cache
+ $stepStart = microtime(true);
+ $this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache');
+ $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
+
+ // Step 9: Warm up cache
+ $stepStart = microtime(true);
+ $this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
+ $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
+
+ // Step 10: Disable maintenance mode
+ $stepStart = microtime(true);
+ $this->disableMaintenanceMode();
+ $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
+
+ // Step 11: Release lock
+ $this->releaseLock();
+
+ $totalDuration = microtime(true) - $startTime;
+ $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart);
+
+ return [
+ 'success' => true,
+ 'steps' => $this->steps,
+ 'error' => null,
+ ];
+
+ } catch (\Throwable $e) {
+ $this->logger->error('Restore failed: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'file' => $filename,
+ ]);
+
+ // Try to clean up
+ try {
+ $this->disableMaintenanceMode();
+ $this->releaseLock();
+ if (isset($tempDir) && is_dir($tempDir)) {
+ $this->filesystem->remove($tempDir);
+ }
+ } catch (\Throwable $cleanupError) {
+ $this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
+ }
+
+ return [
+ 'success' => false,
+ 'steps' => $this->steps,
+ 'error' => $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * Restore database from backup.
+ */
+ private function restoreDatabaseFromBackup(string $tempDir): void
+ {
+ // Check for SQL dump (MySQL/PostgreSQL)
+ $sqlFile = $tempDir . '/database.sql';
+ if (file_exists($sqlFile)) {
+ // Import SQL using mysql/psql command directly
+ // First, get database connection params from Doctrine
+ $connection = $this->entityManager->getConnection();
+ $params = $connection->getParams();
+ $platform = $connection->getDatabasePlatform();
+
+ if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
+ // Use mysql command to import - need to use shell to handle input redirection
+ $mysqlCmd = 'mysql';
+ if (isset($params['host'])) {
+ $mysqlCmd .= ' -h ' . escapeshellarg($params['host']);
+ }
+ if (isset($params['port'])) {
+ $mysqlCmd .= ' -P ' . escapeshellarg((string)$params['port']);
+ }
+ if (isset($params['user'])) {
+ $mysqlCmd .= ' -u ' . escapeshellarg($params['user']);
+ }
+ if (isset($params['password']) && $params['password']) {
+ $mysqlCmd .= ' -p' . escapeshellarg($params['password']);
+ }
+ if (isset($params['dbname'])) {
+ $mysqlCmd .= ' ' . escapeshellarg($params['dbname']);
+ }
+ $mysqlCmd .= ' < ' . escapeshellarg($sqlFile);
+
+ // Execute using shell
+ $process = Process::fromShellCommandline($mysqlCmd, $this->project_dir, null, null, 300);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
+ }
+ } elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
+ // Use psql command to import
+ $psqlCmd = 'psql';
+ if (isset($params['host'])) {
+ $psqlCmd .= ' -h ' . escapeshellarg($params['host']);
+ }
+ if (isset($params['port'])) {
+ $psqlCmd .= ' -p ' . escapeshellarg((string)$params['port']);
+ }
+ if (isset($params['user'])) {
+ $psqlCmd .= ' -U ' . escapeshellarg($params['user']);
+ }
+ if (isset($params['dbname'])) {
+ $psqlCmd .= ' -d ' . escapeshellarg($params['dbname']);
+ }
+ $psqlCmd .= ' -f ' . escapeshellarg($sqlFile);
+
+ // Set PGPASSWORD environment variable if password is provided
+ $env = null;
+ if (isset($params['password']) && $params['password']) {
+ $env = ['PGPASSWORD' => $params['password']];
+ }
+
+ // Execute using shell
+ $process = Process::fromShellCommandline($psqlCmd, $this->project_dir, $env, null, 300);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw new \RuntimeException('PostgreSQL import failed: ' . $process->getErrorOutput());
+ }
+ } else {
+ throw new \RuntimeException('Unsupported database platform for restore');
+ }
+
+ return;
+ }
+
+ // Check for SQLite database file
+ $sqliteFile = $tempDir . '/var/app.db';
+ if (file_exists($sqliteFile)) {
+ $targetDb = $this->project_dir . '/var/app.db';
+ $this->filesystem->copy($sqliteFile, $targetDb, true);
+ return;
+ }
+
+ $this->logger->warning('No database found in backup');
+ }
+
+ /**
+ * Restore config files from backup.
+ */
+ private function restoreConfigFromBackup(string $tempDir): void
+ {
+ // Restore .env.local
+ $envLocal = $tempDir . '/.env.local';
+ if (file_exists($envLocal)) {
+ $this->filesystem->copy($envLocal, $this->project_dir . '/.env.local', true);
+ }
+
+ // Restore config/parameters.yaml
+ $parametersYaml = $tempDir . '/config/parameters.yaml';
+ if (file_exists($parametersYaml)) {
+ $this->filesystem->copy($parametersYaml, $this->project_dir . '/config/parameters.yaml', true);
+ }
+
+ // Restore config/banner.md
+ $bannerMd = $tempDir . '/config/banner.md';
+ if (file_exists($bannerMd)) {
+ $this->filesystem->copy($bannerMd, $this->project_dir . '/config/banner.md', true);
+ }
+ }
+
+ /**
+ * Restore attachments from backup.
+ */
+ private function restoreAttachmentsFromBackup(string $tempDir): void
+ {
+ // Restore public/media
+ $publicMedia = $tempDir . '/public/media';
+ if (is_dir($publicMedia)) {
+ $this->filesystem->mirror($publicMedia, $this->project_dir . '/public/media', null, ['override' => true]);
+ }
+
+ // Restore uploads
+ $uploads = $tempDir . '/uploads';
+ if (is_dir($uploads)) {
+ $this->filesystem->mirror($uploads, $this->project_dir . '/uploads', null, ['override' => true]);
+ }
+ }
+
/**
* Get the path to the progress file.
*/
diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig
index 1ab6c89e..85b3ec1f 100644
--- a/templates/admin/update_manager/index.html.twig
+++ b/templates/admin/update_manager/index.html.twig
@@ -342,6 +342,7 @@
{% trans %}update_manager.date{% endtrans %}
{% trans %}update_manager.file{% endtrans %}
{% trans %}update_manager.size{% endtrans %}
+
@@ -354,10 +355,30 @@
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
+
+ {% if status.can_auto_update and validation.valid %}
+
+ {% endif %}
+
{% else %}
-
+
{% trans %}update_manager.no_backups_found{% endtrans %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index 592590a8..39b869b0 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -14994,5 +14994,29 @@ Buerklin-API Authentication server:
WARNING: This version does not include the Update Manager. After downgrading, you will need to update manually using the command line (git checkout, composer install, etc.).
+
+
+ update_manager.restore_backup
+ Restore backup
+
+
+
+
+ update_manager.restore_confirm_title
+ Restore from Backup
+
+
+
+
+ update_manager.restore_confirm_message
+ Are you sure you want to restore your database from this backup?
+
+
+
+
+ update_manager.restore_confirm_warning
+ WARNING: This will overwrite your current database with the backup data. This action cannot be undone! Make sure you have a current backup before proceeding.
+
+