From 3b1dfc09485110c7a63e41ee380ba38813ada005 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 21 Sep 2026 08:36:38 +0200 Subject: [PATCH] Set the site address in the administration, checked against the installation itself - System > This installation > Address has a field and a link to the other spelling (with or without www), like the WordPress address. It writes SITE_ORIGIN to .env through Environment::write and takes effect on the next request. With SITE_ORIGIN from the server environment there is no field, and a POST is refused. - Before saving, OriginCheck requests /.well-known/kantsites-origin under the new address; it has to return an HMAC of APP_SECRET that only this installation knows. Switching to www without DNS or certificate would otherwise send every page, the administration included, nowhere. "Save anyway" appears only after a refusal. The probe also answers during maintenance. - The message says when the administration changes host and needs a new sign-in, since the session cookie belongs to one spelling. Tests: 584 PHPUnit on PHP 8.2 and 8.4, 35 node, test:editor and test:browser green (test:browser reports only the known editor colour contrast on /grundlagen). Co-Authored-By: Claude Opus 5 --- README.md | 2 +- config/services.yaml | 5 ++ docs/ARCHITECTURE.md | 2 +- docs/STATUS.md | 10 +++ public/assets/admin.css | 8 ++ src/Controller/SystemController.php | 62 +++++++++++++- src/EventSubscriber/MaintenanceSubscriber.php | 2 +- src/Ops/OriginCheck.php | 49 +++++++++++ templates/admin/system.html.twig | 23 +++++- tests/SystemScreenTest.php | 82 ++++++++++++++++++- 10 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 src/Ops/OriginCheck.php diff --git a/README.md b/README.md index 468b539..a1fa86f 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse. -Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`. +Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`. `composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen. diff --git a/config/services.yaml b/config/services.yaml index 20d8e5f..7ccc8ab 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -79,6 +79,11 @@ services: $projectDir: '%kernel.project_dir%' $adminPath: '%kant.admin_path%' $environment: '%kernel.environment%' + # Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive. + App\Ops\OriginCheck: + arguments: + $secret: '%kernel.secret%' + $download: null App\Command\SiteOriginCommand: arguments: $projectDir: '%kernel.project_dir%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dca944b..830f904 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -128,7 +128,7 @@ Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so e **Anlass:** Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in `public/.htaccess` und musste von Hand zur Einstellung `SITE_ORIGIN` passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet. -**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin ` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft. +**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin ` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft. Seit 1.21.8 lässt sie sich dort auch ändern, wie die „WordPress-Adresse“: Das Feld schreibt über `Environment::write` in `.env`, aber erst nachdem `OriginCheck` die Installation unter der neuen Adresse erreicht hat (`GET /.well-known/kantsites-origin` liefert einen HMAC aus `APP_SECRET`). Eine Umstellung auf www ohne DNS oder Zertifikat würde sonst jede Seite samt Verwaltung ins Leere leiten. „Trotzdem übernehmen“ erscheint erst nach einer Ablehnung. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld. **Bewusst nicht:** ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, `localhost`, eine Subdomain – alles bleibt erreichbar, auch wenn `SITE_ORIGIN` etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur `http`, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in `.htaccess`, wo der Webserver die Verbindung wirklich kennt. diff --git a/docs/STATUS.md b/docs/STATUS.md index 336a955..fd815cf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -13,6 +13,16 @@ **Geprüft:** Aufbau per Reset-Skript fehlerfrei; Startseite, Impressum, Datenschutz 200, unbekannte Adresse 404; im Browser (1280 px und 375 px, kein seitliches Scrollen) alle drei Schritte, Bedingung Personenzahl, Zusammenfassung und Absenden – die Anfrage liegt vollständig in `form_submission`. Mailversand ist lokal nicht eingerichtet. Lighthouse und axe noch nicht gelaufen. +## Adresse der Website in der Verwaltung (21.09.2026) + +**Anlass:** Umut: „ja bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird und übernommen wird“ – mit oder ohne www wie die „WordPress-Adresse“, statt nur über `cms:site:origin` oder die `.env` von Hand. + +- **System → Diese Installation → Adresse** hat ein Feld „Adresse der Website“ und einen Link, der direkt auf die andere Schreibweise (mit/ohne www) umstellt. Gespeichert wird `SITE_ORIGIN` in `.env` über `Environment::write`. Das gilt ab der nächsten Anfrage, weil `%env()%` zur Laufzeit gelesen wird. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld, und ein POST wird abgewiesen. +- **Schutz vor dem Aussperren:** Vor dem Speichern fragt die Installation sich selbst unter der neuen Adresse an (`OriginCheck`, `GET /.well-known/kantsites-origin`). Zurückkommen muss ein HMAC aus `APP_SECRET`, den nur diese Installation kennt. Ohne DNS-Eintrag oder Zertifikat für www wird nichts geändert – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen. Erst nach einer Ablehnung erscheint „Trotzdem übernehmen“ (etwa wenn der Server sich selbst nicht erreicht, oder bei http im lokalen Betrieb). Die Prüfadresse ist auch im Wartungszustand erreichbar. +- Wechselt die Verwaltung dadurch den Host, ist dort eine neue Anmeldung nötig. Das Sitzungscookie gilt nur für die Schreibweise, unter der es entstand. Die Meldung sagt das. + +**Geprüft:** zwei neue Tests in `SystemScreenTest` (Server-Umgebung ohne Feld, Ablehnung einer nicht erreichbaren Adresse ohne Schreiben in `.env`, falsche Eingaben, Prüfadresse; `OriginCheck` mit fremder und eigener Kennung). Im Browser mit der Pilotkopie bei 1280 und 375 px (kein seitliches Scrollen) Feld, Ablehnung und „Trotzdem übernehmen“ angesehen. + ## Favicon auch für die Google-Suche (21.09.2026) **Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“. diff --git a/public/assets/admin.css b/public/assets/admin.css index b8d03cc..11be738 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -684,6 +684,14 @@ details.card > summary:hover { background: #fafbf8 } .facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px } .facts dt { color: var(--muted) } .facts dd { margin: 0; overflow-wrap: anywhere } +/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */ +.origin-form { margin-top: 4px; max-width: 560px } +.origin-form label:first-of-type { margin-top: 10px } +.origin-row { display: flex; flex-wrap: wrap; gap: 8px } +.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px } +.card-body .origin-row .secondary { margin: 0 } +.origin-form .check-label { margin-top: 10px } +.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer } .check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 } .check-list li { display: flex; gap: 11px; align-items: flex-start } .check-list li > span { min-width: 0; overflow-wrap: anywhere } diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index a6748bd..c9cb7f6 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -2,10 +2,13 @@ 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\Installation; +use App\Ops\OriginCheck; use App\Ops\Updater; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -21,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route; */ 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) + 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) { } @@ -55,6 +58,7 @@ final class SystemController extends AbstractController // 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'), ]); } @@ -258,6 +262,62 @@ final class SystemController extends AbstractController : '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'); + } + + /** 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']); + } + private function back(string $message, string $type = 'error'): Response { $this->addFlash($type, $message); diff --git a/src/EventSubscriber/MaintenanceSubscriber.php b/src/EventSubscriber/MaintenanceSubscriber.php index b31ef27..3be76fb 100644 --- a/src/EventSubscriber/MaintenanceSubscriber.php +++ b/src/EventSubscriber/MaintenanceSubscriber.php @@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface return; } $path = $event->getRequest()->getPathInfo(); - if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) { + if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) { return; } $since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE); diff --git a/src/Ops/OriginCheck.php b/src/Ops/OriginCheck.php new file mode 100644 index 0000000..b539d99 --- /dev/null +++ b/src/Ops/OriginCheck.php @@ -0,0 +1,49 @@ +download = $download ?? new Download(5, 10); + } + + public function token(): string + { + return hash_hmac('sha256', 'site-origin', $this->secret); + } + + /** Null, wenn die Adresse hier ankommt - sonst der Grund in einem Satz. */ + public function problem(string $origin): ?string + { + if (!str_starts_with($origin, 'https://')) { + return 'Geprüft werden nur HTTPS-Adressen. Eine Adresse mit http:// lässt sich nur mit „Trotzdem übernehmen“ speichern.'; + } + $antwort = $this->download->get($origin.self::PATH); + if (null === $antwort) { + $grund = $this->download->reason(); + + return 'Unter '.$origin.' antwortet diese Website nicht'.('' !== $grund ? ': '.rtrim($grund, '.').'.' : '.').' DNS-Eintrag und Zertifikat für diese Domain prüfen.'; + } + if (!hash_equals($this->token(), trim($antwort))) { + return 'Unter '.$origin.' antwortet eine andere Website. Zeigt die Domain auf diesen Server?'; + } + + return null; + } +} diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index 0389a1d..fe0bf22 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -40,9 +40,28 @@ {% if origin.passt %} – Canonical, Sitemap und Feed nennen sie; die Schreibweise {{ origin.adresse matches '{^https?://www\.}' ? 'ohne' : 'mit' }} www leitet hierher um. {% else %} -
Diese Verwaltung läuft gerade unter {{ origin.host }}. Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr: php bin/console cms:site:origin https://… oder SITE_ORIGIN in .env ändern. +
Diese Verwaltung läuft gerade unter {{ origin.host }}. Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr, unten ändern. + {% endif %} + {% if origin.quelle == '.env' %} + {# Wie „WordPress-Adresse“: eine Stelle für mit oder ohne www. Vor dem Speichern fragt + die Installation sich selbst unter der neuen Adresse an. #} + {% set host = origin.adresse|split('://')|last|split(':')|first %} + {% set twin = host starts with 'www.' ? host|slice(4) : 'www.' ~ host %} +
+ + +
+ + +
+ {% if originRejected %} + + {% endif %} +

Nur Schema und Domain. Vor dem Speichern wird geprüft, ob die Website unter der neuen Adresse antwortet – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen.{% if origin.adresse and host matches '/^[a-z0-9-]+(\.[a-z0-9-]+)+$/' and not (host matches '/^[0-9.]+$/') %} Umstellen auf .{% endif %}

+
+ {% else %} +
Eingestellt in der Server-Umgebung; sie hat Vorrang vor .env und lässt sich nur beim Hoster ändern. {% endif %} -
Eingestellt in {{ origin.quelle }}; Server-Umgebungsvariablen haben Vorrang. {% if state.worktree %}
Stand
{{ state.describe }}{% if state.commit != 'unbekannt' %} · Commit {{ state.commit }}{% endif %}
diff --git a/tests/SystemScreenTest.php b/tests/SystemScreenTest.php index 3eaadfb..a10a883 100644 --- a/tests/SystemScreenTest.php +++ b/tests/SystemScreenTest.php @@ -14,6 +14,7 @@ use App\Ops\ArchiveUpdate; use App\Ops\Backup; use App\Ops\CommandRunner; use App\Ops\Download; +use App\Ops\OriginCheck; use App\Ops\Updater; use Doctrine\DBAL\Connection; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -133,7 +134,7 @@ final class SystemScreenTest extends WebTestCase $html = (string) $client->getResponse()->getContent(); self::assertStringContainsString('https://cms.test', $html); self::assertStringContainsString('läuft gerade unter localhost', $html); - self::assertStringContainsString('cms:site:origin', $html, 'Der Weg, es zu aendern, steht dabei.'); + self::assertStringContainsString('Stimmt die nicht mehr, unten ändern.', $html); // Unter der eingetragenen Adresse selbst: kein Hinweis, dafuer die Richtung der Umleitung. $client->request('GET', 'https://cms.test/admin/login'); @@ -149,6 +150,85 @@ final class SystemScreenTest extends WebTestCase self::assertSame('https://cms.test/admin/system', $client->getResponse()->headers->get('Location')); } + public function testTheAddressIsSetInTheAdministrationButOnlyWhereItReallyArrives(): void + { + // Umut: „bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird“ - + // wie die WordPress-Adresse. Im Test kommt SITE_ORIGIN aus der Server-Umgebung; dort hat + // sie Vorrang, also gibt es kein Feld, und auch ein POST ändert nichts. + $client = $this->login(); + $client->request('GET', '/admin/system'); + $html = (string) $client->getResponse()->getContent(); + self::assertStringContainsString('lässt sich nur beim Hoster ändern', $html); + self::assertStringNotContainsString('name="origin"', $html); + $token = (string) $client->getCrawler()->filter('form[action$="/system/wartung"] input[name=_csrf_token]')->attr('value'); + $client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://www.cms.test']); + $client->followRedirect(); + self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent()); + + // Steht sie in .env, gibt es das Feld samt Umschalter auf die andere Schreibweise. + $geladen = $_SERVER['SYMFONY_DOTENV_VARS'] ?? null; + $_SERVER['SYMFONY_DOTENV_VARS'] = 'SITE_ORIGIN'; + try { + $client->request('GET', '/admin/system'); + $html = (string) $client->getResponse()->getContent(); + self::assertStringContainsString('name="origin" type="url"', $html); + self::assertStringContainsString('value="https://www.cms.test" formnovalidate>www.cms.test', $html); + + // Eine Adresse, unter der diese Website nicht antwortet, wird nicht gespeichert - sonst + // leitete die andere Schreibweise ins Leere. Das Feld behält sie, und erst jetzt + // erscheint „Trotzdem übernehmen“. + $client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://kein-server.invalid/']); + $client->followRedirect(); + $html = (string) $client->getResponse()->getContent(); + self::assertStringContainsString('Unter https://kein-server.invalid antwortet diese Website nicht', $html); + self::assertStringContainsString('Nichts geändert.', $html); + self::assertStringContainsString('value="https://kein-server.invalid"', $html); + self::assertStringContainsString('name="force"', $html); + foreach (['https://cms.test/start' => 'ohne Pfad', 'ftp://cms.test' => 'beginnt mit https://'] as $falsch => $meldung) { + $client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => $falsch]); + $client->followRedirect(); + self::assertStringContainsString($meldung, (string) $client->getResponse()->getContent()); + } + } finally { + if (null === $geladen) { + unset($_SERVER['SYMFONY_DOTENV_VARS']); + } else { + $_SERVER['SYMFONY_DOTENV_VARS'] = $geladen; + } + } + self::assertStringContainsString('SITE_ORIGIN', (string) file_get_contents(dirname(__DIR__).'/.env')); + self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env')); + + // Die Gegenstelle der Prüfung: öffentlich, ohne Anmeldung, und nur die Kennung. + $client->request('GET', '/.well-known/kantsites-origin'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame(hash_hmac('sha256', 'site-origin', 'isolated-test-secret'), $client->getResponse()->getContent()); + } + + public function testTheAddressCheckWantsThisInstallationsOwnAnswer(): void + { + $antwort = null; + $download = new class($antwort) extends Download { + public function __construct(private ?string &$antwort) + { + parent::__construct(); + } + + public function get(string $url): ?string + { + return $this->antwort; + } + }; + $check = new OriginCheck('geheim', $download); + self::assertStringContainsString('antwortet diese Website nicht', (string) $check->problem('https://www.example.de')); + $antwort = hash_hmac('sha256', 'site-origin', 'anderes-geheimnis'); + self::assertStringContainsString('antwortet eine andere Website', (string) $check->problem('https://www.example.de')); + $antwort = $check->token().' +'; + self::assertNull($check->problem('https://www.example.de')); + self::assertStringContainsString('nur HTTPS', (string) $check->problem('http://www.example.de')); + } + public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void { // Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine