From 14300f2cf10299c01832e60e3711626fcc1d4fbe Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:20:01 +0100 Subject: [PATCH 01/15] Add manual backup creation and delete buttons to Update Manager - Add "Create Backup" button in the backups tab for on-demand backups - Add delete buttons (trash icons) for update logs and backups - New controller routes with CSRF protection and permission checks - Use data-turbo-confirm for CSP-safe confirmation dialogs - Add deleteLog() method to UpdateExecutor with filename validation --- src/Controller/UpdateManagerController.php | 74 ++++++++++++++++ src/Services/System/UpdateExecutor.php | 27 ++++++ .../admin/update_manager/index.html.twig | 86 ++++++++++++++----- translations/messages.en.xlf | 66 ++++++++++++++ 4 files changed, 230 insertions(+), 23 deletions(-) diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 474c86fc3..0afc79051 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -314,6 +314,80 @@ public function backupDetails(string $filename): JsonResponse 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'); + } + /** * Restore from a backup. */ diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 2fe541739..fca7d1fa5 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -602,6 +602,33 @@ public function getUpdateLogs(): array } + /** + * 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 44b9f8c00..8afa44726 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 status.can_auto_update and validation.valid and not backup_restore_disabled %}
+
+ {% endif %}
+ {% if is_granted('@system.manage_updates') %}
+
+ {% endif %}
+
|
{% else %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index b84a18754..1900c60e6 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -12491,6 +12491,72 @@ Buerklin-API Authentication server:
|
- {% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
+ {% if is_granted('@system.manage_updates') %}
+
+
+
+ {% endif %}
+ {% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
|
- {% if is_granted('@system.manage_updates') %}
-
+ {% if not backup_download_disabled and is_granted('@system.manage_updates') %}
+
+
{% endif %}
{% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
+
+{# Password confirmation modal for backup download #}
+{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
+
+
+{% endif %}
{% endblock %}
diff --git a/tests/Controller/UpdateManagerControllerTest.php b/tests/Controller/UpdateManagerControllerTest.php
index 1df093f99..c37d413e1 100644
--- a/tests/Controller/UpdateManagerControllerTest.php
+++ b/tests/Controller/UpdateManagerControllerTest.php
@@ -58,6 +58,8 @@ private function getCsrfTokenFromPage($crawler, string $formAction): string
return $form->filter('input[name="_token"]')->attr('value');
}
+ // ---- Authentication tests ----
+
public function testIndexPageRequiresAuth(): void
{
$client = static::createClient();
@@ -78,6 +80,8 @@ public function testIndexPageAccessibleByAdmin(): void
$this->assertResponseIsSuccessful();
}
+ // ---- Backup creation tests ----
+
public function testCreateBackupRequiresCsrf(): void
{
$client = static::createClient();
@@ -116,6 +120,33 @@ public function testCreateBackupWithValidCsrf(): void
}
}
+ public function testCreateBackupBlockedWhenLocked(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ // Load the page first to get CSRF token before locking
+ $crawler = $client->request('GET', '/en/system/update-manager');
+ $csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
+
+ // Acquire lock to simulate update in progress
+ $updateExecutor = $client->getContainer()->get(UpdateExecutor::class);
+ $updateExecutor->acquireLock();
+
+ try {
+ $client->request('POST', '/en/system/update-manager/backup', [
+ '_token' => $csrfToken,
+ ]);
+
+ $this->assertResponseRedirects();
+ } finally {
+ // Always release lock
+ $updateExecutor->releaseLock();
+ }
+ }
+
+ // ---- Backup deletion tests ----
+
public function testDeleteBackupRequiresCsrf(): void
{
$client = static::createClient();
@@ -156,6 +187,8 @@ public function testDeleteBackupWithValidCsrf(): void
$this->assertFileDoesNotExist($backupDir . '/' . $testFile);
}
+ // ---- Log deletion tests ----
+
public function testDeleteLogRequiresCsrf(): void
{
$client = static::createClient();
@@ -196,39 +229,50 @@ public function testDeleteLogWithValidCsrf(): void
$this->assertFileDoesNotExist($logDir . '/' . $testFile);
}
- public function testDownloadBackupReturns404ForNonExistent(): void
+ // ---- Backup download tests ----
+
+ public function testDownloadBackupBlockedByDefault(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
- $client->request('GET', '/en/system/update-manager/backup/download/nonexistent.zip');
+ // DISABLE_BACKUP_DOWNLOAD=1 is the default in .env, so this should return 403
+ $client->request('POST', '/en/system/update-manager/backup/download', [
+ '_token' => 'any',
+ 'filename' => 'test.zip',
+ 'password' => 'test',
+ ]);
- $this->assertResponseStatusCodeSame(404);
+ $this->assertResponseStatusCodeSame(403);
}
- public function testDownloadBackupSuccess(): void
+ public function testDownloadBackupRequiresPost(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
- // Create a temporary backup file to download
- $backupManager = $client->getContainer()->get(BackupManager::class);
- $backupDir = $backupManager->getBackupDir();
- if (!is_dir($backupDir)) {
- mkdir($backupDir, 0755, true);
- }
- $testFile = 'test-download-' . uniqid() . '.zip';
- file_put_contents($backupDir . '/' . $testFile, 'fake zip content');
+ // GET should return 405 Method Not Allowed
+ $client->request('GET', '/en/system/update-manager/backup/download');
- $client->request('GET', '/en/system/update-manager/backup/download/' . $testFile);
+ $this->assertResponseStatusCodeSame(405);
+ }
- $this->assertResponseIsSuccessful();
- $this->assertResponseHeaderSame('content-disposition', 'attachment; filename=' . $testFile);
+ public function testDownloadBackupRequiresAuth(): void
+ {
+ $client = static::createClient();
+
+ $client->request('POST', '/en/system/update-manager/backup/download', [
+ '_token' => 'any',
+ 'filename' => 'test.zip',
+ 'password' => 'test',
+ ]);
- // Clean up
- @unlink($backupDir . '/' . $testFile);
+ // Should deny access (401 with HTTP Basic auth in test env)
+ $this->assertResponseStatusCodeSame(401);
}
+ // ---- Backup details tests ----
+
public function testBackupDetailsReturns404ForNonExistent(): void
{
$client = static::createClient();
@@ -239,6 +283,8 @@ public function testBackupDetailsReturns404ForNonExistent(): void
$this->assertResponseStatusCodeSame(404);
}
+ // ---- Restore tests ----
+
public function testRestoreBlockedWhenDisabled(): void
{
$client = static::createClient();
@@ -253,28 +299,83 @@ public function testRestoreBlockedWhenDisabled(): void
$this->assertResponseStatusCodeSame(403);
}
- public function testCreateBackupBlockedWhenLocked(): void
+ public function testRestoreRequiresAuth(): void
+ {
+ $client = static::createClient();
+
+ $client->request('POST', '/en/system/update-manager/restore', [
+ '_token' => 'invalid',
+ 'filename' => 'test.zip',
+ ]);
+
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ // ---- Start update tests ----
+
+ public function testStartUpdateRequiresAuth(): void
+ {
+ $client = static::createClient();
+
+ $client->request('POST', '/en/system/update-manager/start', [
+ '_token' => 'invalid',
+ 'version' => 'v1.0.0',
+ ]);
+
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ public function testStartUpdateBlockedWhenWebUpdatesDisabled(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
- // Load the page first to get CSRF token before locking
- $crawler = $client->request('GET', '/en/system/update-manager');
- $csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
+ // DISABLE_WEB_UPDATES=1 is the default in .env
+ $client->request('POST', '/en/system/update-manager/start', [
+ '_token' => 'invalid',
+ 'version' => 'v1.0.0',
+ ]);
- // Acquire lock to simulate update in progress
- $updateExecutor = $client->getContainer()->get(UpdateExecutor::class);
- $updateExecutor->acquireLock();
+ $this->assertResponseStatusCodeSame(403);
+ }
- try {
- $client->request('POST', '/en/system/update-manager/backup', [
- '_token' => $csrfToken,
- ]);
+ // ---- Status and progress tests ----
- $this->assertResponseRedirects();
- } finally {
- // Always release lock
- $updateExecutor->releaseLock();
- }
+ public function testStatusEndpointRequiresAuth(): void
+ {
+ $client = static::createClient();
+
+ $client->request('GET', '/en/system/update-manager/status');
+
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ public function testStatusEndpointAccessibleByAdmin(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('GET', '/en/system/update-manager/status');
+
+ $this->assertResponseIsSuccessful();
+ }
+
+ public function testProgressStatusEndpointRequiresAuth(): void
+ {
+ $client = static::createClient();
+
+ $client->request('GET', '/en/system/update-manager/progress/status');
+
+ $this->assertResponseStatusCodeSame(401);
+ }
+
+ public function testProgressStatusEndpointAccessibleByAdmin(): void
+ {
+ $client = static::createClient();
+ $this->loginAsAdmin($client);
+
+ $client->request('GET', '/en/system/update-manager/progress/status');
+
+ $this->assertResponseIsSuccessful();
}
}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index ac68c77ed..6ccc92d5b 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -12563,6 +12563,24 @@ Buerklin-API Authentication server:
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
@@ -457,6 +456,48 @@
{% endif %}
+ {% if not backup_download_disabled and is_granted('@system.manage_updates') %}
+ {# Per-backup download modal - no inline JS needed, CSP compatible with Turbo #}
+
+ {% endif %}
|
{% else %}
@@ -477,52 +518,4 @@
-{# Password confirmation modal for backup download #}
-{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
-
-
-{% endif %}
{% endblock %}
diff --git a/tests/Controller/UpdateManagerControllerTest.php b/tests/Controller/UpdateManagerControllerTest.php
index c37d413e1..0c2b2224b 100644
--- a/tests/Controller/UpdateManagerControllerTest.php
+++ b/tests/Controller/UpdateManagerControllerTest.php
@@ -251,10 +251,10 @@ public function testDownloadBackupRequiresPost(): void
$client = static::createClient();
$this->loginAsAdmin($client);
- // GET should return 405 Method Not Allowed
+ // GET returns 404 since no GET route exists for this path
$client->request('GET', '/en/system/update-manager/backup/download');
- $this->assertResponseStatusCodeSame(405);
+ $this->assertResponseStatusCodeSame(404);
}
public function testDownloadBackupRequiresAuth(): void
From ba11eab4e860848ce85ef8e917964041546ce5ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20B=C3=B6hmer?=