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