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
This commit is contained in:
Sebastian Almberg 2026-02-20 23:41:52 +01:00
parent 31380fdcc4
commit 3c41597262
4 changed files with 59 additions and 9 deletions

View file

@ -24,14 +24,17 @@ declare(strict_types=1);
namespace App\Controller;
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\Routing\Attribute\Route;
@ -49,6 +52,7 @@ class UpdateManagerController extends AbstractController
private readonly UpdateExecutor $updateExecutor,
private readonly VersionManagerInterface $versionManager,
private readonly BackupManager $backupManager,
private readonly InstallationTypeDetector $installationTypeDetector,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
@ -101,6 +105,7 @@ class UpdateManagerController extends AbstractController
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
'is_docker' => $this->installationTypeDetector->isDocker(),
]);
}
@ -388,6 +393,25 @@ class UpdateManagerController extends AbstractController
return $this->redirectToRoute('admin_update_manager');
}
/**
* Download a backup file.
*/
#[Route('/backup/download/{filename}', name: 'admin_update_manager_backup_download', methods: ['GET'])]
public function downloadBackup(string $filename): BinaryFileResponse
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$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.
*/

View file

@ -327,14 +327,14 @@ readonly class BackupManager
*/
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 @@ readonly class BackupManager
// 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;
}

View file

@ -377,17 +377,23 @@
</div>
</div>
<div class="tab-pane fade" id="backups-tab">
{% if is_granted('@system.manage_updates') and status.can_auto_update and not is_locked %}
{% if is_granted('@system.manage_updates') and not is_locked %}
<div class="p-2 border-bottom">
<form action="{{ path('admin_update_manager_backup') }}" method="post" class="d-inline"
data-turbo-confirm="{% trans %}update_manager.backup.create.confirm{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_backup') }}">
<button type="submit" class="btn btn-sm btn-success">
<i class="fas fa-download me-1"></i>{% trans %}update_manager.backup.create{% endtrans %}
<i class="fas fa-plus me-1"></i>{% trans %}update_manager.backup.create{% endtrans %}
</button>
</form>
</div>
{% endif %}
{% if is_docker %}
<div class="alert alert-info alert-sm m-2 mb-0 py-2 small">
<i class="fas fa-info-circle me-1"></i>
{% trans %}update_manager.backup.docker_warning{% endtrans %}
</div>
{% endif %}
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
@ -410,7 +416,14 @@
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
{% if is_granted('@system.manage_updates') %}
<a href="{{ path('admin_update_manager_backup_download', {filename: backup.file}) }}"
class="btn btn-outline-secondary"
title="{% trans %}update_manager.backup.download{% endtrans %}">
<i class="fas fa-download"></i>
</a>
{% endif %}
{% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
data-controller="backup-restore"
data-backup-restore-filename-value="{{ backup.file }}"

View file

@ -12377,6 +12377,18 @@ Buerklin-API Authentication server:
<target>Delete</target>
</segment>
</unit>
<unit id="um_bk_download" name="update_manager.backup.download">
<segment state="translated">
<source>update_manager.backup.download</source>
<target>Download backup</target>
</segment>
</unit>
<unit id="um_bk_docker_warning" name="update_manager.backup.docker_warning">
<segment state="translated">
<source>update_manager.backup.docker_warning</source>
<target>Docker installation detected. Backups are stored in var/backups/ which is not a persistent volume. Use the download button to save backups externally, or mount var/backups/ as a volume in your docker-compose.yml.</target>
</segment>
</unit>
<unit id="kHKChQB" name="settings.ips.conrad">
<segment state="translated">
<source>settings.ips.conrad</source>