diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 474c86fc..480e86a0 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -24,14 +24,17 @@ declare(strict_types=1); namespace App\Controller; use App\Services\System\BackupManager; +use App\Services\System\InstallationTypeDetector; use App\Services\System\UpdateChecker; use App\Services\System\UpdateExecutor; use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Attribute\Route; @@ -49,6 +52,7 @@ class UpdateManagerController extends AbstractController private readonly UpdateExecutor $updateExecutor, private readonly VersionManagerInterface $versionManager, private readonly BackupManager $backupManager, + private readonly InstallationTypeDetector $installationTypeDetector, #[Autowire(env: 'bool:DISABLE_WEB_UPDATES')] private readonly bool $webUpdatesDisabled = false, #[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')] @@ -101,6 +105,7 @@ class UpdateManagerController extends AbstractController 'backups' => $this->backupManager->getBackups(), 'web_updates_disabled' => $this->webUpdatesDisabled, 'backup_restore_disabled' => $this->backupRestoreDisabled, + 'is_docker' => $this->installationTypeDetector->isDocker(), ]); } @@ -314,6 +319,99 @@ class UpdateManagerController extends AbstractController return $this->json($details); } + /** + * Create a manual backup. + */ + #[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])] + public function createBackup(Request $request): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + if ($this->updateExecutor->isLocked()) { + $this->addFlash('error', 'Cannot create backup while an update is in progress.'); + return $this->redirectToRoute('admin_update_manager'); + } + + try { + $backupPath = $this->backupManager->createBackup(null, 'manual'); + $this->addFlash('success', 'update_manager.backup.created'); + } catch (\Exception $e) { + $this->addFlash('error', 'Backup failed: ' . $e->getMessage()); + } + + return $this->redirectToRoute('admin_update_manager'); + } + + /** + * Delete a backup file. + */ + #[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])] + public function deleteBackup(Request $request): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $filename = $request->request->get('filename'); + if ($filename && $this->backupManager->deleteBackup($filename)) { + $this->addFlash('success', 'update_manager.backup.deleted'); + } else { + $this->addFlash('error', 'update_manager.backup.delete_error'); + } + + return $this->redirectToRoute('admin_update_manager'); + } + + /** + * Delete an update log file. + */ + #[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])] + public function deleteLog(Request $request): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $filename = $request->request->get('filename'); + if ($filename && $this->updateExecutor->deleteLog($filename)) { + $this->addFlash('success', 'update_manager.log.deleted'); + } else { + $this->addFlash('error', 'update_manager.log.delete_error'); + } + + return $this->redirectToRoute('admin_update_manager'); + } + + /** + * Download a backup file. + */ + #[Route('/backup/download/{filename}', name: 'admin_update_manager_backup_download', methods: ['GET'])] + public function downloadBackup(string $filename): BinaryFileResponse + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + $details = $this->backupManager->getBackupDetails($filename); + if (!$details) { + throw $this->createNotFoundException('Backup not found'); + } + + $response = new BinaryFileResponse($details['path']); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']); + + return $response; + } + /** * Restore from a backup. */ diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php index 4946bc24..621b58d7 100644 --- a/src/Services/System/BackupManager.php +++ b/src/Services/System/BackupManager.php @@ -327,14 +327,14 @@ readonly class BackupManager */ private function restoreDatabaseFromBackup(string $tempDir): void { + // Get database connection params from Doctrine + $connection = $this->entityManager->getConnection(); + $params = $connection->getParams(); + $platform = $connection->getDatabasePlatform(); + // 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 AbstractMySQLPlatform) { // Use mysql command to import - need to use shell to handle input redirection @@ -403,7 +403,8 @@ readonly class BackupManager // Check for SQLite database file $sqliteFile = $tempDir . '/var/app.db'; if (file_exists($sqliteFile)) { - $targetDb = $this->projectDir . '/var/app.db'; + // Use the actual configured SQLite path from Doctrine, not a hardcoded path + $targetDb = $params['path'] ?? $this->projectDir . '/var/app.db'; $this->filesystem->copy($sqliteFile, $targetDb, true); return; } diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 2fe54173..fca7d1fa 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -602,6 +602,33 @@ class UpdateExecutor } + /** + * Delete a specific update log file. + */ + public function deleteLog(string $filename): bool + { + // Validate filename pattern for security + if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) { + $this->logger->warning('Attempted to delete invalid log filename: ' . $filename); + return false; + } + + $logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . $filename; + + if (!file_exists($logPath)) { + return false; + } + + try { + $this->filesystem->remove($logPath); + $this->logger->info('Deleted update log: ' . $filename); + return true; + } catch (\Exception $e) { + $this->logger->error('Failed to delete update log: ' . $e->getMessage()); + return false; + } + } + /** * Restore from a backup file with maintenance mode and cache clearing. * diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 44b9f8c0..4b6ff237 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -343,11 +343,26 @@ {{ log.date|date('Y-m-d H:i') }}
{{ log.file }}|
- {% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
-
- {% endif %}
+
+ {% if is_granted('@system.manage_updates') %}
+
+
+
+ {% endif %}
+ {% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
+
+ {% endif %}
+ {% if is_granted('@system.manage_updates') %}
+
+ {% endif %}
+
|
{% else %}
diff --git a/tests/Controller/UpdateManagerControllerTest.php b/tests/Controller/UpdateManagerControllerTest.php
new file mode 100644
index 00000000..e770a5bb
--- /dev/null
+++ b/tests/Controller/UpdateManagerControllerTest.php
@@ -0,0 +1,138 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Controller;
+
+use App\Entity\UserSystem\User;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+#[Group("slow")]
+#[Group("DB")]
+final class UpdateManagerControllerTest extends WebTestCase
+{
+ private function loginAsAdmin($client): void
+ {
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => 'admin']);
+
+ if (!$user) {
+ $this->markTestSkipped('Admin user not found');
+ }
+
+ $client->loginUser($user);
+ }
+
+ public function testIndexPageRequiresAuth(): void
+ {
+ $client = static::createClient();
+
+ $client->request('GET', '/en/system/update-manager');
+
+ // Should deny access (401 with HTTP Basic auth in test env)
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ public function testIndexPageAccessibleByAdmin(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('GET', '/en/system/update-manager');
+
+ $this->assertResponseIsSuccessful();
+ }
+
+ public function testCreateBackupRequiresCsrf(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('POST', '/en/system/update-manager/backup', [
+ '_token' => 'invalid',
+ ]);
+
+ // Should redirect with error flash
+ $this->assertResponseRedirects();
+ }
+
+ public function testDeleteBackupRequiresCsrf(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('POST', '/en/system/update-manager/backup/delete', [
+ '_token' => 'invalid',
+ 'filename' => 'test.zip',
+ ]);
+
+ $this->assertResponseRedirects();
+ }
+
+ public function testDeleteLogRequiresCsrf(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('POST', '/en/system/update-manager/log/delete', [
+ '_token' => 'invalid',
+ 'filename' => 'test.log',
+ ]);
+
+ $this->assertResponseRedirects();
+ }
+
+ public function testDownloadBackupReturns404ForNonExistent(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('GET', '/en/system/update-manager/backup/download/nonexistent.zip');
+
+ $this->assertResponseStatusCodeSame(404);
+ }
+
+ public function testBackupDetailsReturns404ForNonExistent(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('GET', '/en/system/update-manager/backup/nonexistent.zip');
+
+ $this->assertResponseStatusCodeSame(404);
+ }
+
+ public function testRestoreBlockedWhenDisabled(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ // DISABLE_BACKUP_RESTORE=1 is the default in .env, so this should return 403
+ $client->request('POST', '/en/system/update-manager/restore', [
+ '_token' => 'invalid',
+ 'filename' => 'test.zip',
+ ]);
+
+ $this->assertResponseStatusCodeSame(403);
+ }
+}
diff --git a/tests/Services/System/BackupManagerTest.php b/tests/Services/System/BackupManagerTest.php
index f75ef8f3..9aa92813 100644
--- a/tests/Services/System/BackupManagerTest.php
+++ b/tests/Services/System/BackupManagerTest.php
@@ -82,6 +82,16 @@ final class BackupManagerTest extends KernelTestCase
$this->assertSame('2.6.0', $matches[2]);
}
+ public function testDeleteBackupReturnsFalseForNonExistentFile(): void
+ {
+ $this->assertFalse($this->backupManager->deleteBackup('non-existent.zip'));
+ }
+
+ public function testDeleteBackupReturnsFalseForNonZipFile(): void
+ {
+ $this->assertFalse($this->backupManager->deleteBackup('not-a-zip.txt'));
+ }
+
/**
* Test version parsing with different filename formats.
*/
diff --git a/tests/Services/System/UpdateExecutorTest.php b/tests/Services/System/UpdateExecutorTest.php
index 48cddf8d..8b95b3b0 100644
--- a/tests/Services/System/UpdateExecutorTest.php
+++ b/tests/Services/System/UpdateExecutorTest.php
@@ -139,6 +139,38 @@ final class UpdateExecutorTest extends KernelTestCase
$this->assertFalse($this->updateExecutor->isLocked());
}
+ public function testDeleteLogRejectsInvalidFilename(): void
+ {
+ // Path traversal attempts should be rejected
+ $this->assertFalse($this->updateExecutor->deleteLog('../../../etc/passwd'));
+ $this->assertFalse($this->updateExecutor->deleteLog('malicious.txt'));
+ $this->assertFalse($this->updateExecutor->deleteLog(''));
+ // Must start with "update-"
+ $this->assertFalse($this->updateExecutor->deleteLog('backup-v1.0.0.log'));
+ }
+
+ public function testDeleteLogReturnsFalseForNonExistentFile(): void
+ {
+ $this->assertFalse($this->updateExecutor->deleteLog('update-nonexistent-file.log'));
+ }
+
+ public function testDeleteLogDeletesExistingFile(): void
+ {
+ // Create a temporary log file in the update logs directory
+ $projectDir = self::getContainer()->getParameter('kernel.project_dir');
+ $logDir = $projectDir . '/var/log/updates';
+
+ if (!is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ $testFile = 'update-test-delete-' . uniqid() . '.log';
+ file_put_contents($logDir . '/' . $testFile, 'test log content');
+
+ $this->assertTrue($this->updateExecutor->deleteLog($testFile));
+ $this->assertFileDoesNotExist($logDir . '/' . $testFile);
+ }
+
public function testEnableAndDisableMaintenanceMode(): void
{
// First, ensure maintenance mode is off
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index 7bf613ae..7b103574 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -12335,6 +12335,84 @@ Buerklin-API Authentication server: