Alle drei von Umut auf seinem Hosting gefunden, keiner davon auf meinem Rechner sichtbar. - Der Updateknopf endete mit 500. Das Update lief vollstaendig durch - alle neun Schritte, Dateien getauscht, var/ und .env unberuehrt -, aber die Ergebnisseite starb daran: der Updater ueber Git nennt einen Schritt "step", der ueber das Archiv nannte ihn "label", beide legen ihn unter demselben Sitzungsschluessel ab, und die Vorlage liest "step". Damit war auch der zweite Knopf unerreichbar. Zehn Tests prueften das Rueckgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie (ADR 071). - Die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git. "git ist installiert" und "das hier ist eine Arbeitskopie" waren derselbe Schalter; auf dem Hosting ist Git vorhanden, die Installation kam aber aus dem Archiv. Dort stand "fatal: Not a git repository" an der Stelle der Fassungsnummer. Ohne Arbeitskopie verschwinden die Git-Karten jetzt (ADR 072). - "Sicherung anlegen" endete mit 500: VACUUM INTO gibt es erst seit SQLite 3.27, das Hosting hat eine aeltere Fassung - alt genug fuer die Installation, zu alt fuer die Sicherung. Bei aelteren wird jetzt unter einer Schreibsperre kopiert, und der Knopf faengt jeden Fehler ab statt mit einer 500-Seite zu enden (ADR 072). Nachgestellt wurde das mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Archiv-Updater laeuft dort vollstaendig durch: 1.4.3 -> 1.4.4, neun Schritte, var/ und .env unberuehrt. Geprueft: 433 Tests auf PHP 8.2 und 8.4, Editor-Browsertest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
9.8 KiB
PHP
211 lines
9.8 KiB
PHP
<?php
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Domain\InvalidPage;
|
||
use App\Ops\ArchiveUpdate;
|
||
use App\Ops\Backup;
|
||
use App\Ops\Installation;
|
||
use App\Ops\Updater;
|
||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
use Symfony\Component\Routing\Attribute\Route;
|
||
|
||
/**
|
||
* Version, preflight and the update itself.
|
||
*
|
||
* Administrators only, and every write with a token. The console stays the more reliable way: an
|
||
* update in a browser request has to finish within whatever the hosting allows, and that is not
|
||
* something this controller can promise - so it says so instead.
|
||
*/
|
||
final class SystemController extends AbstractController
|
||
{
|
||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
|
||
{
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/system', name: 'admin_system', methods: ['GET'])]
|
||
public function index(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
$checks = $this->updater->check();
|
||
|
||
return $this->render('admin/system.html.twig', [
|
||
'state' => $this->updater->state(),
|
||
'checks' => $checks,
|
||
'ready' => [] === array_filter($checks, static fn (array $check): bool => !$check['ok']),
|
||
// Asking the remote costs a network round trip, so only when it is wanted.
|
||
'available' => $request->query->getBoolean('pruefen') ? $this->updater->availableVersion() : null,
|
||
'asked' => $request->query->getBoolean('pruefen'),
|
||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||
'backups' => $this->backup->all(),
|
||
'adminPath' => $this->adminPath,
|
||
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
|
||
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
|
||
'archivePending' => $this->archive->pending(),
|
||
// Wer die Dateien von Hand hochgeladen hat - über FTP, ohne Git und ohne das
|
||
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
|
||
// ist die einzige Stelle, an der das jemand merken kann.
|
||
'pendingMigrations' => $this->pendingMigrations(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Update über das Releasearchiv - für Installationen, die kein Git kennen.
|
||
*
|
||
* Zwei Schritte, und zwar mit Absicht: Der erste ersetzt die Dateien, der zweite führt die
|
||
* Migrationen aus. Dazwischen liegt eine neue Anfrage, denn erst die startet mit dem Code der
|
||
* neuen Fassung; ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.
|
||
*/
|
||
/**
|
||
* Ausstehende Migrationen ausführen - ohne Git, ohne Archiv, ohne Konsole.
|
||
*
|
||
* Der Weg für ein Update, das von Hand eingespielt wurde: Dateien liegen neu, die Datenbank
|
||
* ist alt. Ohne diesen Knopf bräuchte es eine Konsole, und genau die gibt es auf geteiltem
|
||
* Hosting nicht.
|
||
*/
|
||
#[Route('/%kant.admin_path%/system/migrationen', name: 'admin_system_migrate', methods: ['POST'])]
|
||
public function migrate(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
@set_time_limit(0);
|
||
|
||
try {
|
||
$namen = $this->installation->migrate();
|
||
$this->addFlash('success', [] === $namen
|
||
? 'Es stand keine Migration an - die Datenbank war bereits auf dem Stand des Codes.'
|
||
: sprintf('%d Migration(en) ausgeführt: %s.', count($namen), implode(', ', $namen)));
|
||
} catch (\Throwable $fehler) {
|
||
$this->addFlash('error', 'Die Migration ist fehlgeschlagen: '.$fehler->getMessage());
|
||
}
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
|
||
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
|
||
private function pendingMigrations(): array
|
||
{
|
||
try {
|
||
return $this->installation->pendingMigrations();
|
||
} catch (\Throwable) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/system/archiv', name: 'admin_system_archive', methods: ['POST'])]
|
||
public function archive(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
@set_time_limit(0);
|
||
if ($request->request->getBoolean('finish')) {
|
||
$ergebnis = $this->archive->finish($this->installation);
|
||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
|
||
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
|
||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||
if ($ergebnis['migrate'] ?? false) {
|
||
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||
? 'Es war nichts einzuspielen.'
|
||
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||
public function update(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
// Checkout, composer and migration take longer than a page usually may. Where the hosting
|
||
// allows it, the limit is lifted; where it does not, the console is the way.
|
||
@set_time_limit(0);
|
||
$target = $request->request->getString('target');
|
||
$result = $this->updater->update('' === $target ? null : $target, $request->request->getBoolean('dryRun'));
|
||
$request->getSession()->set('update_steps', $result['steps']);
|
||
$this->addFlash(
|
||
$result['ok'] ? 'success' : 'error',
|
||
$result['ok']
|
||
? sprintf('Update auf %s abgeschlossen.', $result['target'] ?? 'die aktuelle Fassung')
|
||
: 'Das Update ist gescheitert. Die Schritte unten sagen, wo – und ob die Website wieder online ist.',
|
||
);
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
|
||
/**
|
||
* Creating a backup is safe, so it has a button. Putting one back is not, so it has none -
|
||
* `cms:restore` is the way, and the list says which backups exist and whether their checksum
|
||
* still matches.
|
||
*/
|
||
#[Route('/%kant.admin_path%/system/sicherung', name: 'admin_system_backup', methods: ['POST'])]
|
||
public function backup(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
@set_time_limit(0);
|
||
try {
|
||
$result = $this->backup->create('Verwaltung');
|
||
} catch (InvalidPage $error) {
|
||
return $this->back($error->getMessage());
|
||
} catch (\Throwable $error) {
|
||
// Alles andere ist kein erwarteter Fall, sondern einer, den dieses Hosting mitbringt -
|
||
// eine alte SQLite-Fassung, ein fehlendes Schreibrecht, ein voller Datentraeger. Eine
|
||
// 500-Seite laesst den Betreiber damit allein; der Satz hier sagt wenigstens, woran es
|
||
// lag.
|
||
return $this->back('Die Sicherung ist fehlgeschlagen: '.$error->getMessage());
|
||
}
|
||
$removed = $this->backup->rotate();
|
||
|
||
return $this->back(sprintf(
|
||
'%s angelegt: %d Seite(n), %d Fassung(en), %d Mediendatei(en).%s',
|
||
$result['name'],
|
||
$result['manifest']['inhalt']['seiten'],
|
||
$result['manifest']['inhalt']['fassungen'],
|
||
$result['manifest']['medien']['dateien'],
|
||
[] === $removed ? '' : ' Ältere entfernt: '.implode(', ', $removed).'.',
|
||
), 'success');
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
|
||
public function maintenance(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
$on = 'an' === $request->request->getString('state');
|
||
$this->updater->maintenance($on);
|
||
|
||
return $this->back($on
|
||
? 'Der Wartungszustand ist eingeschaltet: die Website antwortet mit 503, die Verwaltung bleibt erreichbar.'
|
||
: 'Der Wartungszustand ist aufgehoben.', 'success');
|
||
}
|
||
|
||
private function back(string $message, string $type = 'error'): Response
|
||
{
|
||
$this->addFlash($type, $message);
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
}
|