Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
###################################################################################
Expand Down
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions src/Controller/UpdateManagerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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,
) {
}

Expand All @@ -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.
*/
Expand All @@ -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(),
]);
}

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down
13 changes: 7 additions & 6 deletions src/Services/System/BackupManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
27 changes: 27 additions & 0 deletions src/Services/System/UpdateExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading
Loading