SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber sends the other spelling there with 301, cms:site:origin writes the setting, the system screen shows it. The roles now match the role help (editor: drafts, publisher: live content, admin: design, settings, users, system). The installer no longer reopens on an unreachable database, the session cookie follows ADMIN_PATH, percent-encoded credentials no longer break the container, consent only accepts same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the editor, updater, download and public-output findings from the full review (ADR 099, ADR 100). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
268 lines
13 KiB
PHP
268 lines
13 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, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin)
|
||
{
|
||
}
|
||
|
||
#[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(),
|
||
// Actual runtime connection, with credentials excluded from the screen.
|
||
'database' => $this->databaseFacts(),
|
||
// Die Adresse, die Sitemap, Feed und Canonical nennen - und ob die Verwaltung gerade
|
||
// unter ihr laeuft. Die Schreibweise mit oder ohne www leitet die Anwendung selbst um;
|
||
// ein ganz anderer Host (Vorschauadresse des Hosters, IP) bleibt erreichbar und
|
||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||
'origin' => $this->originFacts($request),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
|
||
/**
|
||
* Die Datenbank, wie sie wirklich benutzt wird - samt der Datei, in der das steht.
|
||
*
|
||
* @return array{art: string, datei: ?string, bytes: ?int, schreibbar: bool, quelle: string, adresse: string}
|
||
*/
|
||
private function databaseFacts(): array
|
||
{
|
||
$url = $this->databaseUrl;
|
||
$datei = null;
|
||
if (1 === preg_match('#^sqlite:///(.+)$#', $url, $treffer)) {
|
||
$datei = str_replace(['%kernel.project_dir%', '\\'], [$this->projectDir, '/'], $treffer[1]);
|
||
}
|
||
|
||
return [
|
||
'art' => null !== $datei ? 'SQLite' : (str_starts_with($url, 'mysql') ? 'MySQL' : 'unbekannt'),
|
||
'datei' => $datei,
|
||
'bytes' => null !== $datei && is_file($datei) ? (int) filesize($datei) : null,
|
||
// Eine Datei, die PHP nicht beschreiben darf, ist der haeufigste Fehler nach einem
|
||
// Upload per FTP: Dort gehoert sie dem FTP-Benutzer, hier laeuft ein anderer.
|
||
'schreibbar' => null !== $datei ? (is_writable($datei) && is_writable(\dirname($datei))) : true,
|
||
'quelle' => $this->environmentSource('DATABASE_URL'),
|
||
// Bei MySQL steht das Passwort in der Adresse - es wird nicht angezeigt.
|
||
'adresse' => (string) preg_replace('#://([^:/@]+):[^@]*@#', '://$1:***@', $url),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array{adresse: string, quelle: string, host: string, passt: bool}
|
||
*/
|
||
private function originFacts(Request $request): array
|
||
{
|
||
$wanted = strtolower((string) parse_url(trim($this->siteOrigin), PHP_URL_HOST));
|
||
$port = parse_url(trim($this->siteOrigin), PHP_URL_PORT);
|
||
|
||
return [
|
||
'adresse' => $this->siteOrigin,
|
||
'quelle' => $this->environmentSource('SITE_ORIGIN'),
|
||
'host' => $request->getHttpHost(),
|
||
'passt' => '' !== $wanted && strtolower($request->getHost()) === $wanted && (null === $port || $request->getPort() === $port),
|
||
];
|
||
}
|
||
|
||
/** Explicit server values take precedence over the single installation file. */
|
||
private function environmentSource(string $name): string
|
||
{
|
||
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
|
||
|
||
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
|
||
}
|
||
|
||
/** 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');
|
||
}
|
||
}
|