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.
+
+