Every installation now reports address and version to sites.kant-it.de every 15 minutes and picks up update orders there (ADR 123). Reports and answers are HMAC-signed with a per-installation key sent on first contact; an order can only install a published release with checksum. The next request after the file swap runs the migrations, so a remote update finishes without anyone in the admin. The platform itself lives in zentrale/ (plain PHP + SQLite, not part of the release archive): overview with outdated, silent and unassigned installations, single and bulk updates, per-site detail and history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
534 lines
28 KiB
PHP
534 lines
28 KiB
PHP
<?php
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Command\SiteOriginCommand;
|
||
use App\Domain\InvalidPage;
|
||
use App\Ops\ArchiveUpdate;
|
||
use App\Ops\Backup;
|
||
use App\Ops\Environment;
|
||
use App\Ops\Funktionspruefung;
|
||
use App\Ops\HttpsRedirect;
|
||
use App\Ops\Installation;
|
||
use App\Ops\OriginCheck;
|
||
use App\Ops\SitePackage;
|
||
use App\Ops\Updater;
|
||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
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, private OriginCheck $originCheck, private Funktionspruefung $pruefung, private SitePackage $packages, private HttpsRedirect $https)
|
||
{
|
||
}
|
||
|
||
#[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(),
|
||
// Inhaltspakete (ADR 118): die per FTP abgelegten und die Grenze für Uploads.
|
||
'packages' => $this->packages->waiting(),
|
||
'uploadLimit' => $this->uploadLimit(),
|
||
'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),
|
||
'originRejected' => $request->getSession()->remove('origin_rejected'),
|
||
// HTTPS erzwingen und HSTS (ADR 120): der Stand in public/.htaccess.
|
||
'https' => $this->https->state(),
|
||
'secure' => $request->isSecure(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
// Alles, was nach dem Dateitausch noch gebraucht wird, steht vorher fest: Danach darf diese
|
||
// Anfrage keinen Code mehr nachladen (siehe sendAndStop).
|
||
$abschluss = '/'.trim($this->adminPath, '/').'/system/abschliessen?token=';
|
||
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
|
||
if ($ergebnis['migrate'] ?? false) {
|
||
$this->sendAndStop((string) $ergebnis['fassung'], $abschluss.$ergebnis['token'], $ergebnis['steps']);
|
||
}
|
||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||
$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');
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------------------
|
||
// Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Die Systemseite fragt
|
||
// beim Öffnen selbst nach einer neuen Fassung, ein Knopf startet das Update, und die Seite
|
||
// zeigt Schritt für Schritt, was gerade passiert - bis „abgeschlossen“ oder „zurückgeschaltet“.
|
||
// -----------------------------------------------------------------------------------------
|
||
|
||
/** Gibt es eine neue Fassung? Für die Systemseite, die das beim Öffnen fragt. */
|
||
#[Route('/%kant.admin_path%/system/update/pruefen', name: 'admin_update_check', methods: ['GET'])]
|
||
public function updateCheck(): JsonResponse
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
$installiert = $this->updater->version();
|
||
$neueste = $this->archive->latest();
|
||
$antwort = ['installiert' => $installiert, 'status' => $this->archive->status(), 'offen' => null !== $this->archive->pending()];
|
||
if (null === $neueste) {
|
||
$antwort['fehler'] = 'Die Releaseseite antwortet gerade nicht. Später noch einmal versuchen.';
|
||
} elseif (version_compare($neueste['version'], $installiert, '>')) {
|
||
$antwort['neu'] = ['version' => $neueste['version'], 'name' => $neueste['name'], 'notizen' => $neueste['notes'], 'datum' => substr($neueste['published'], 0, 10)];
|
||
}
|
||
|
||
return new JsonResponse($antwort, 200, ['Cache-Control' => 'no-store']);
|
||
}
|
||
|
||
/** Der Fortschritt, alle zwei Sekunden abgefragt, solange das Update läuft. */
|
||
#[Route('/%kant.admin_path%/system/update/status', name: 'admin_update_status', methods: ['GET'])]
|
||
public function updateStatus(): JsonResponse
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
|
||
return new JsonResponse(['status' => $this->archive->status(), 'installiert' => $this->updater->version()], 200, ['Cache-Control' => 'no-store']);
|
||
}
|
||
|
||
/**
|
||
* Startet das Update: Sichern, Laden, Prüfen, Umschalten. Die Antwort kommt nach dem Tausch
|
||
* als reines JSON ohne Framework (siehe sendJsonAndStop); den Abschluss ruft die Seite
|
||
* danach selbst auf.
|
||
*/
|
||
#[Route('/%kant.admin_path%/system/update/starten', name: 'admin_update_start', methods: ['POST'])]
|
||
public function updateStart(Request $request): JsonResponse
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('system', $request->headers->get('X-CSRF-Token'))) {
|
||
return new JsonResponse(['ok' => false, 'meldung' => 'Deine Sitzung ist abgelaufen. Bitte die Seite neu laden.'], 403);
|
||
}
|
||
@set_time_limit(0);
|
||
$ergebnis = $this->archive->run($request->request->getString('version') ?: null);
|
||
if ($ergebnis['migrate'] ?? false) {
|
||
$this->sendJsonAndStop(['ok' => true, 'token' => $ergebnis['token'], 'fassung' => $ergebnis['fassung']]);
|
||
}
|
||
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
|
||
|
||
return new JsonResponse(['ok' => $ergebnis['ok'], 'meldung' => $letzter['detail'], 'status' => $this->archive->status()]);
|
||
}
|
||
|
||
/**
|
||
* Der Abschluss in einer frischen Anfrage mit dem neuen Code: Migrationen, Prüfung der
|
||
* Startseite, Wartung aus - oder Zurückschalten auf die vorherige Fassung. Die Antwort ist
|
||
* wieder reines JSON, denn nach einem Zurückschalten liegt erneut anderer Code auf der Platte.
|
||
*/
|
||
#[Route('/%kant.admin_path%/system/update/abschliessen', name: 'admin_update_finish', methods: ['POST'])]
|
||
public function updateFinish(Request $request): JsonResponse
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
$offen = $this->archive->pending();
|
||
if (null === $offen) {
|
||
return new JsonResponse(['ok' => true, 'meldung' => 'Das Update ist bereits abgeschlossen.', 'status' => $this->archive->status()]);
|
||
}
|
||
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->request->getString('token'))) {
|
||
return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403);
|
||
}
|
||
@set_time_limit(0);
|
||
$ergebnis = $this->archive->finish($this->installation, $this->pruefung);
|
||
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
|
||
}
|
||
|
||
/** Wie sendAndStop, nur als JSON für die Systemseite. */
|
||
private function sendJsonAndStop(array $daten): never
|
||
{
|
||
while (ob_get_level() > 0) {
|
||
ob_end_clean();
|
||
}
|
||
http_response_code(200);
|
||
header('Content-Type: application/json; charset=UTF-8');
|
||
header('Cache-Control: no-store');
|
||
header('X-Robots-Tag: noindex');
|
||
echo json_encode($daten, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Der zweite Schritt des Archivupdates, aufgerufen von der Zwischenseite selbst (ADR 112).
|
||
*
|
||
* Läuft in einer eigenen Anfrage und damit mit dem Code der neuen Fassung: Migrationen,
|
||
* Aufräumen, Wartungszustand aus. Der Einmalschlüssel aus var/update/offen.json ersetzt das
|
||
* CSRF-Token, das die Zwischenseite nicht mehr erzeugen kann; ohne Administratorrolle geht es
|
||
* ohnehin nicht. Ein zweiter Aufruf (Neuladen) findet nichts mehr offen und meldet das.
|
||
*/
|
||
#[Route('/%kant.admin_path%/system/abschliessen', name: 'admin_system_finish', methods: ['GET'])]
|
||
public function finishArchive(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
$offen = $this->archive->pending();
|
||
if (null === $offen) {
|
||
$this->addFlash('success', 'Das Update ist bereits abgeschlossen.');
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->query->getString('token'))) {
|
||
return $this->back('Dieser Link gehört zu keinem offenen Update. Schließe es oben mit „Migrationen ausführen“ ab.');
|
||
}
|
||
@set_time_limit(0);
|
||
$ergebnis = $this->archive->finish($this->installation);
|
||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||
? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung'])
|
||
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
|
||
/**
|
||
* Beendet die Anfrage, die gerade die Dateien getauscht hat, mit einer schlichten Seite.
|
||
*
|
||
* Dieser Prozess hat den alten Code im Speicher, auf der Platte liegt der neue. Alles, was
|
||
* jetzt noch nachgeladen würde - Antwort-Listener, Sitzung, Vorlagen -, käme aus der neuen
|
||
* Fassung und passte nicht zu dem, was schon geladen ist. Genau das endete vorher mit einem
|
||
* 500er, obwohl das Update gelungen war. Deshalb hier nur noch PHP selbst: Kopfzeilen, Text,
|
||
* Ende. Die Seite ruft den Abschluss sofort selbst auf, ein Link steht für den Fall daneben,
|
||
* dass der Browser das Weiterleiten unterdrückt.
|
||
*
|
||
* @param list<array{step: string, ok: bool, detail: string}> $schritte
|
||
*/
|
||
private function sendAndStop(string $fassung, string $weiter, array $schritte): never
|
||
{
|
||
while (ob_get_level() > 0) {
|
||
ob_end_clean();
|
||
}
|
||
$e = static fn (string $text): string => htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||
$liste = '';
|
||
foreach ($schritte as $schritt) {
|
||
$liste .= '<li>'.($schritt['ok'] ? '✓ ' : '! ').$e($schritt['step']).'</li>';
|
||
}
|
||
http_response_code(200);
|
||
header('Content-Type: text/html; charset=UTF-8');
|
||
header('Cache-Control: no-store');
|
||
header('X-Robots-Tag: noindex');
|
||
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'");
|
||
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
|
||
.'<meta http-equiv="refresh" content="1;url='.$e($weiter).'"><title>Update wird abgeschlossen</title>'
|
||
.'<style>body{font:16px/1.5 system-ui,sans-serif;background:#f3f5f2;color:#203b34;margin:0;display:grid;place-items:center;min-height:100vh}main{background:#fff;border:1px solid #dfe5e1;border-radius:12px;padding:28px 32px;max-width:460px}h1{font-size:20px;margin:0 0 8px}ul{padding-left:18px;color:#46514c;font-size:14px}a{color:#1f5f45}</style></head>'
|
||
.'<body><main><h1>Update auf '.$e($fassung).' wird abgeschlossen …</h1>'
|
||
.'<p role="status">Die neuen Dateien sind da. Jetzt laufen die Migrationen, danach geht es von selbst zur Systemseite.</p>'
|
||
.'<ul>'.$liste.'</ul><p><a href="'.$e($weiter).'">Weiter, falls nichts passiert</a></p></main></body></html>';
|
||
exit;
|
||
}
|
||
|
||
#[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');
|
||
}
|
||
|
||
/**
|
||
* Die Adresse der Website, wie „WordPress-Adresse“ in WordPress: sie landet in .env als
|
||
* SITE_ORIGIN und gilt ab der nächsten Anfrage.
|
||
*
|
||
* Vorher fragt die Installation sich selbst unter der neuen Adresse an. Eine Umstellung auf
|
||
* www ohne DNS-Eintrag leitete sonst jede Seite ins Leere, die Verwaltung eingeschlossen.
|
||
* Wer weiß, was er tut (Server erreicht sich selbst nicht, http im lokalen Betrieb), kann die
|
||
* Prüfung ausdrücklich übergehen.
|
||
*/
|
||
#[Route('/%kant.admin_path%/system/adresse', name: 'admin_system_origin', methods: ['POST'])]
|
||
public function origin(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.');
|
||
}
|
||
if ('.env' !== $this->environmentSource('SITE_ORIGIN')) {
|
||
return $this->back('Die Adresse ist in der Server-Umgebung gesetzt und hat dort Vorrang. Sie lässt sich nur beim Hoster ändern.');
|
||
}
|
||
try {
|
||
$neu = SiteOriginCommand::normalize($request->request->getString('origin'));
|
||
} catch (\InvalidArgumentException $error) {
|
||
return $this->back($error->getMessage());
|
||
}
|
||
if ($neu === rtrim(trim($this->siteOrigin), '/')) {
|
||
return $this->back('Die Adresse ist unverändert.', 'success');
|
||
}
|
||
if (!$request->request->getBoolean('force') && null !== $problem = $this->originCheck->problem($neu)) {
|
||
$request->getSession()->set('origin_rejected', $neu);
|
||
|
||
return $this->back($problem.' Nichts geändert.');
|
||
}
|
||
try {
|
||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||
} catch (\RuntimeException $error) {
|
||
return $this->back($error->getMessage());
|
||
}
|
||
|
||
// Die Anmeldung gilt nur für die Schreibweise, unter der sie entstand. Wechselt die
|
||
// Verwaltung dadurch den Host, ist dort eine neue Anmeldung fällig - das sagt die Meldung,
|
||
// solange sie noch auf dem alten Host steht.
|
||
return $this->back(sprintf(
|
||
'Die Website ist jetzt eingetragen als %s. Canonical, Sitemap und Feed nennen diese Adresse, die Schreibweise %s www leitet dorthin um.%s',
|
||
$neu,
|
||
str_starts_with((string) parse_url($neu, PHP_URL_HOST), 'www.') ? 'ohne' : 'mit',
|
||
strtolower($request->getHost()) !== strtolower((string) parse_url($neu, PHP_URL_HOST)) ? ' Unter der neuen Adresse ist eine neue Anmeldung nötig.' : '',
|
||
), 'success');
|
||
}
|
||
|
||
/**
|
||
* HTTPS erzwingen oder HSTS schalten (ADR 120). Die Umleitung wird nur eingeschaltet, wenn
|
||
* HTTPS vorher und nachher hier ankommt; sonst bleibt public/.htaccess, wie sie war.
|
||
*/
|
||
#[Route('/%kant.admin_path%/system/https', name: 'admin_system_https', methods: ['POST'])]
|
||
public function https(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.');
|
||
}
|
||
$result = match ($request->request->getString('action')) {
|
||
'redirect-an' => $this->https->enableRedirect($this->siteOrigin),
|
||
'redirect-aus' => $this->https->disableRedirect(),
|
||
'hsts-an' => $this->https->switchHsts(true, $this->siteOrigin),
|
||
'hsts-aus' => $this->https->switchHsts(false, $this->siteOrigin),
|
||
default => ['ok' => false, 'message' => 'Unbekannte Aktion.'],
|
||
};
|
||
|
||
return $this->back($result['message'], $result['ok'] ? 'success' : 'error');
|
||
}
|
||
|
||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||
public function originProbe(): Response
|
||
{
|
||
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
|
||
}
|
||
|
||
/** Die kleinere der beiden PHP-Grenzen, die einen Upload begrenzen - so, wie sie in der php.ini steht. */
|
||
private function uploadLimit(): string
|
||
{
|
||
$bytes = static function (string $value): int {
|
||
$value = trim($value);
|
||
$number = (int) $value;
|
||
|
||
return match (strtolower(substr($value, -1))) {
|
||
'g' => $number * 1024 ** 3, 'm' => $number * 1024 ** 2, 'k' => $number * 1024, default => $number,
|
||
};
|
||
};
|
||
$limits = array_filter([$bytes((string) ini_get('upload_max_filesize')), $bytes((string) ini_get('post_max_size'))]);
|
||
$min = [] === $limits ? 0 : min($limits);
|
||
|
||
return 0 === $min ? 'unbegrenzt' : number_format($min / 1048576, 0, ',', '.').' MB';
|
||
}
|
||
|
||
private function back(string $message, string $type = 'error'): Response
|
||
{
|
||
$this->addFlash($type, $message);
|
||
|
||
return $this->redirectToRoute('admin_system');
|
||
}
|
||
}
|