diff --git a/.env b/.env
index 3ba3d65d7..447ff5dee 100644
--- a/.env
+++ b/.env
@@ -71,6 +71,11 @@ DISABLE_WEB_UPDATES=1
# Restoring backups is a destructive operation that could overwrite your database.
DISABLE_BACKUP_RESTORE=1
+# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
+# Backups contain sensitive data including password hashes and secrets.
+# When enabled, users must confirm their password before downloading.
+DISABLE_BACKUP_DOWNLOAD=1
+
###################################################################################
# SAML Single sign on-settings
###################################################################################
diff --git a/docs/configuration.md b/docs/configuration.md
index c5e46f214..a2f585a1d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -144,6 +144,18 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
+### Update manager settings
+* `DISABLE_WEB_UPDATES` (default `1`): Set this to 0 to enable web-based updates. When enabled, you can perform updates
+ via the web interface in the update manager. This is disabled by default for security reasons, as it can be a risk if
+ not used carefully. You can still use the CLI commands to perform updates, even when web updates are disabled.
+* `DISABLE_BACKUP_RESTORE` (default `1`): Set this to 0 to enable backup restore via the web interface. When enabled, you can
+ restore backups via the web interface in the update manager. This is disabled by default for security reasons, as it can
+ be a risk if not used carefully. You can still use the CLI commands to perform backup restores, even when web-based
+ backup restore is disabled.
+* `DISABLE_BACKUP_DOWNLOAD` (default `1`): Set this to 0 to enable backup download via the web interface. When enabled, you can download backups via the web interface
+ in the update manager. This is disabled by default for security reasons, as it can be a risk if not used carefully, as
+ the downloads contain sensitive data like password hashes or secrets.
+
### Table related settings
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set
diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php
index 474c86fc3..70be714d2 100644
--- a/src/Controller/UpdateManagerController.php
+++ b/src/Controller/UpdateManagerController.php
@@ -23,16 +23,21 @@
namespace App\Controller;
+use App\Entity\UserSystem\User;
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\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -49,10 +54,14 @@ public function __construct(
private readonly UpdateExecutor $updateExecutor,
private readonly VersionManagerInterface $versionManager,
private readonly BackupManager $backupManager,
+ private readonly InstallationTypeDetector $installationTypeDetector,
+ private readonly UserPasswordHasherInterface $passwordHasher,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
private readonly bool $backupRestoreDisabled = false,
+ #[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')]
+ private readonly bool $backupDownloadDisabled = false,
) {
}
@@ -76,6 +85,16 @@ private function denyIfBackupRestoreDisabled(): void
}
}
+ /**
+ * Check if backup download is disabled and throw exception if so.
+ */
+ private function denyIfBackupDownloadDisabled(): void
+ {
+ if ($this->backupDownloadDisabled) {
+ throw new AccessDeniedHttpException('Backup download is disabled by server configuration.');
+ }
+ }
+
/**
* Main update manager page.
*/
@@ -101,6 +120,8 @@ public function index(): Response
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
+ 'backup_download_disabled' => $this->backupDownloadDisabled,
+ 'is_docker' => $this->installationTypeDetector->isDocker(),
]);
}
@@ -206,6 +227,7 @@ public function viewLog(string $filename): Response
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
public function startUpdate(Request $request): Response
{
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
@@ -314,12 +336,126 @@ 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('IS_AUTHENTICATED_FULLY');
+ $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 {
+ $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('IS_AUTHENTICATED_FULLY');
+ $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('IS_AUTHENTICATED_FULLY');
+ $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.
+ * Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.).
+ */
+ #[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])]
+ public function downloadBackup(Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $this->denyAccessUnlessGranted('@system.manage_updates');
+ $this->denyIfBackupDownloadDisabled();
+
+ if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) {
+ $this->addFlash('error', 'Invalid CSRF token.');
+ return $this->redirectToRoute('admin_update_manager');
+ }
+
+ // Verify password
+ $password = $request->request->get('password', '');
+ $user = $this->getUser();
+ if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) {
+ $this->addFlash('error', 'update_manager.backup.download.invalid_password');
+ return $this->redirectToRoute('admin_update_manager');
+ }
+
+ $filename = $request->request->get('filename', '');
+ $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.
*/
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
public function restore(Request $request): Response
{
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupRestoreDisabled();
diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php
index 4946bc240..621b58d77 100644
--- a/src/Services/System/BackupManager.php
+++ b/src/Services/System/BackupManager.php
@@ -327,14 +327,14 @@ public function restoreBackup(
*/
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 @@ private function restoreDatabaseFromBackup(string $tempDir): void
// 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 2fe541739..6704517aa 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 . '/' . basename($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..2c6db63c8 100644
--- a/templates/admin/update_manager/index.html.twig
+++ b/templates/admin/update_manager/index.html.twig
@@ -1,5 +1,7 @@
{% extends "main_card.html.twig" %}
+{% import "helper.twig" as helper %}
+
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
{% block card_title %}
@@ -7,60 +9,60 @@
{% endblock %}
{% block card_content %}
-