Files
KantSites/src/Controller/SystemController.php
T
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
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>
2026-10-14 10:43:13 +02:00

534 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
}
}