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 $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 .= '
  • '.($schritt['ok'] ? '✓ ' : '! ').$e($schritt['step']).'
  • '; } 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 '' .'Update wird abgeschlossen' .'' .'

    Update auf '.$e($fassung).' wird abgeschlossen …

    ' .'

    Die neuen Dateien sind da. Jetzt laufen die Migrationen, danach geht es von selbst zur Systemseite.

    ' .'

    Weiter, falls nichts passiert

    '; 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'); } }