Files
Part-DB-server/src/Controller/UpdateManagerController.php
Sebastian Almberg 0d58262e19 Add manual backup creation and delete buttons to Update Manager (#1255)
* 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

* Add Docker backup support: download button, SQLite restore fix, decouple from auto-update

- Decouple backup creation/restore UI from can_auto_update so Docker
  and other non-git installations can use backup features
- Add backup download endpoint for saving backups externally
- Fix SQLite restore to use configured DATABASE_URL path instead of
  hardcoded var/app.db (affects Docker and custom SQLite paths)
- Show Docker-specific warning about var/backups/ not being persisted
- Pass is_docker flag to template via InstallationTypeDetector

* Add tests for backup/update manager improvements

- Controller tests: auth, CSRF validation, 404 for missing backups, restore disabled check
- UpdateExecutor: deleteLog validation, non-existent file, successful deletion
- BackupManager: deleteBackup validation for missing/non-zip files

* Fix test failures: add locale prefix to URLs, correct log directory path

* Fix auth test: expect 401 instead of redirect for HTTP Basic auth

* Improve test coverage for update manager controller

Add happy-path tests for backup creation, deletion, download,
and log deletion with valid CSRF tokens. Also test the locked
state blocking backup creation.

* Fix CSRF tests: initialize session before getting tokens

* Fix CSRF tests: extract tokens from rendered page HTML

* Harden backup security: password confirmation, CSRF, env toggle

Address security review feedback from jbtronics:

- Add IS_AUTHENTICATED_FULLY to all sensitive endpoints (create/delete
  backup, delete log, download backup, start update, restore)
- Change backup download from GET to POST with CSRF token
- Require password confirmation before downloading backups (backups
  contain sensitive data like password hashes and secrets)
- Add DISABLE_BACKUP_DOWNLOAD env var (default: disabled) to control
  whether backup downloads are allowed
- Add password confirmation modal with security warning in template
- Add comprehensive tests: auth checks, env var blocking, POST-only
  enforcement, status/progress endpoint auth

* Fix download modal: use per-backup modals for CSP/Turbo compatibility

- Replace shared modal + inline JS with per-backup modals that have
  filename pre-set in hidden fields (no JavaScript needed)
- Add data-turbo="false" to download forms for native browser handling
- Add data-bs-dismiss="modal" to submit button to auto-close modal
- Add hidden username field for Chrome accessibility best practice
- Fix test: GET on POST-only route returns 404 not 405

* Fixed translation keys

* Fixed text justification in download modal

* Hardenened security of deleteLogEndpoint

* Show whether backup, restores and updates are allowed or disabled by sysadmin on update manager

* Added documentation for update manager related env variables

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-03-07 19:31:00 +01:00

508 lines
19 KiB
PHP

<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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 <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
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;
/**
* Controller for the Update Manager web interface.
*
* This provides a read-only view of update status and instructions.
* Actual updates should be performed via the CLI command for safety.
*/
#[Route('/system/update-manager')]
class UpdateManagerController extends AbstractController
{
public function __construct(
private readonly UpdateChecker $updateChecker,
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,
) {
}
/**
* Check if web updates are disabled and throw exception if so.
*/
private function denyIfWebUpdatesDisabled(): void
{
if ($this->webUpdatesDisabled) {
throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.');
}
}
/**
* Check if backup restore is disabled and throw exception if so.
*/
private function denyIfBackupRestoreDisabled(): void
{
if ($this->backupRestoreDisabled) {
throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.');
}
}
/**
* 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.
*/
#[Route('', name: 'admin_update_manager', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('@system.show_updates');
$status = $this->updateChecker->getUpdateStatus();
$availableUpdates = $this->updateChecker->getAvailableUpdates();
$validation = $this->updateExecutor->validateUpdatePreconditions();
return $this->render('admin/update_manager/index.html.twig', [
'status' => $status,
'available_updates' => $availableUpdates,
'all_releases' => $this->updateChecker->getAvailableReleases(10),
'validation' => $validation,
'is_locked' => $this->updateExecutor->isLocked(),
'lock_info' => $this->updateExecutor->getLockInfo(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
'update_logs' => $this->updateExecutor->getUpdateLogs(),
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
'backup_download_disabled' => $this->backupDownloadDisabled,
'is_docker' => $this->installationTypeDetector->isDocker(),
]);
}
/**
* AJAX endpoint to check update status.
*/
#[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])]
public function status(): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
return $this->json([
'status' => $this->updateChecker->getUpdateStatus(),
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'lock_info' => $this->updateExecutor->getLockInfo(),
]);
}
/**
* AJAX endpoint to refresh version information.
*/
#[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])]
public function refresh(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) {
return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN);
}
$this->updateChecker->refreshVersionInfo();
return $this->json([
'success' => true,
'status' => $this->updateChecker->getUpdateStatus(),
]);
}
/**
* View release notes for a specific version.
*/
#[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])]
public function releaseNotes(string $tag): Response
{
$this->denyAccessUnlessGranted('@system.show_updates');
$releases = $this->updateChecker->getAvailableReleases(20);
$release = null;
foreach ($releases as $r) {
if ($r['tag'] === $tag) {
$release = $r;
break;
}
}
if (!$release) {
throw $this->createNotFoundException('Release not found');
}
return $this->render('admin/update_manager/release_notes.html.twig', [
'release' => $release,
'current_version' => $this->updateChecker->getCurrentVersionString(),
]);
}
/**
* View an update log file.
*/
#[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])]
public function viewLog(string $filename): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
// Security: Only allow viewing files from the update logs directory
$logs = $this->updateExecutor->getUpdateLogs();
$logPath = null;
foreach ($logs as $log) {
if ($log['file'] === $filename) {
$logPath = $log['path'];
break;
}
}
if (!$logPath || !file_exists($logPath)) {
throw $this->createNotFoundException('Log file not found');
}
$content = file_get_contents($logPath);
return $this->render('admin/update_manager/log_viewer.html.twig', [
'filename' => $filename,
'content' => $content,
]);
}
/**
* Start an update process.
*/
#[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();
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token');
return $this->redirectToRoute('admin_update_manager');
}
// Check if update is already running
if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) {
$this->addFlash('error', 'An update is already in progress.');
return $this->redirectToRoute('admin_update_manager');
}
$targetVersion = $request->request->get('version');
$createBackup = $request->request->getBoolean('backup', true);
if (!$targetVersion) {
// Get latest version if not specified
$latest = $this->updateChecker->getLatestVersion();
if (!$latest) {
$this->addFlash('error', 'Could not determine target version.');
return $this->redirectToRoute('admin_update_manager');
}
$targetVersion = $latest['tag'];
}
// Validate preconditions
$validation = $this->updateExecutor->validateUpdatePreconditions();
if (!$validation['valid']) {
$this->addFlash('error', implode(' ', $validation['errors']));
return $this->redirectToRoute('admin_update_manager');
}
// Start the background update
$pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup);
if (!$pid) {
$this->addFlash('error', 'Failed to start update process.');
return $this->redirectToRoute('admin_update_manager');
}
// Redirect to progress page
return $this->redirectToRoute('admin_update_manager_progress');
}
/**
* Update progress page.
*/
#[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])]
public function progress(): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$progress = $this->updateExecutor->getProgress();
$currentVersion = $this->versionManager->getVersion()->toString();
// Determine if this is a downgrade
$isDowngrade = false;
if ($progress && isset($progress['target_version'])) {
$targetVersion = ltrim($progress['target_version'], 'v');
$isDowngrade = version_compare($targetVersion, $currentVersion, '<');
}
return $this->render('admin/update_manager/progress.html.twig', [
'progress' => $progress,
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'is_downgrade' => $isDowngrade,
'current_version' => $currentVersion,
]);
}
/**
* AJAX endpoint to get update progress.
*/
#[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])]
public function progressStatus(): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
$progress = $this->updateExecutor->getProgress();
return $this->json([
'progress' => $progress,
'is_locked' => $this->updateExecutor->isLocked(),
'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->backupManager->getBackupDetails($filename);
if (!$details) {
return $this->json(['error' => 'Backup not found'], 404);
}
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();
// 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->backupManager->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');
}
}