mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-14 11:19:07 +01:00
* 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>
508 lines
19 KiB
PHP
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');
|
|
}
|
|
}
|