diff --git a/README.md b/README.md index f57a3ce..d6ef878 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,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. 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`. +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 selbst; unter „System“ → „HTTPS“ schaltet „HTTPS erzwingen“ die drei Zeilen in `public/.htaccess` ein – nur wenn die Adresse vorher über HTTPS hier ankommt, mit Probe danach und Rücknahme bei einer Schleife. HSTS ist ein eigener Schalter. Konsole: `php bin/console cms:https` (Stand), `cms:https an|aus|hsts-an|hsts-aus` (ADR 120). `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 e46dc94..a7572a4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -90,6 +90,14 @@ services: arguments: $secret: '%kernel.secret%' $download: null + # HTTPS erzwingen und HSTS (ADR 120); die Probe ueber http kommt im Test als Closure herein. + App\Ops\HttpsRedirect: + arguments: + $projectDir: '%kernel.project_dir%' + $httpProbe: null + App\Command\HttpsCommand: + arguments: + $siteOrigin: '%env(SITE_ORIGIN)%' App\Command\SiteOriginCommand: arguments: $projectDir: '%kernel.project_dir%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7e23378..49ecde4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,25 @@ Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 120 – HTTPS erzwingen aus der Verwaltung (12.10.2026) + +Anlass: Umut: „HTTPS wird noch nicht erzwungen, wie machen wir das allgemein?“ Bisher hieß das: per FTP die drei auskommentierten Zeilen in `public/.htaccess` einschalten. Die Anwendung selbst erzwingt bewusst nichts (ADR zur Adresse: hinter einem Proxy sieht PHP oft nur `http`, ohne vertrauenswürdige Proxys wäre das eine Schleife). + +**Zwei Fehler, beim Bau gefunden und auf einem echten Apache (XAMPP 2.4) nachgestellt.** +1. Seit 1.28.1 stand der HTTPS-Block **hinter** `RewriteRule ^index\.php$ - [L]` und der Regel für `index.php`. Nach dem Umschreiben auf `index.php` beginnt die Runde neu und endet an der ersten Regel, die Umleitung kam für Seiten nie an die Reihe: Mit eingeschalteten Zeilen antworteten `/` und `/leistungen` mit 200, nur echte Dateien (`/x.css`) mit 301. Der Block steht jetzt direkt nach `RewriteBase`; nachgemessen: alle Pfade 301, mit `X-Forwarded-Proto: https` 200 (keine Schleife hinter einem Proxy). +2. `FilesMatch "^\."` sperrte auch `/.well-known/…`: Apache prüft den ersten nicht vorhandenen Pfadteil gegen die Regel. Die Probe `/.well-known/kantsites-origin` antwortete deshalb unter Apache mit 403 – auch die Prüfung bei „Adresse speichern“ konnte dort nie gelingen. Ausgenommen ist jetzt genau `.well-known` (`^\.(?!well-known$)`); `.env`, `.htaccess`, `.htpasswd` bleiben 403 (nachgemessen). + +**Entscheidung.** `App\Ops\HttpsRedirect` schaltet die vorhandenen Zeilen ein und aus, unter System („HTTPS“) und als `cms:https [an|aus|hsts-an|hsts-aus]`: +- Einschalten nur mit `SITE_ORIGIN` auf `https://` und nur, wenn die Installation dort vorher mit gültigem Zertifikat ankommt (`OriginCheck`, dieselbe Kennungsprobe wie beim Adresswechsel). +- Danach dieselbe Probe noch einmal. Kommt sie nicht mehr an, ist es eine Schleife (der Server meldet HTTPS nicht und setzt keinen Proxy-Kopf): Die Datei wird sofort zurückgeschrieben. +- Dann `http://` ohne Weiterleitungen folgen: 301/308 auf genau die https-Adresse ist der Erfolg. Antwortet http anders, liest der Server die Regel nicht (mod_rewrite, AllowOverride) – die Zeilen schaden nicht, die Meldung verweist auf die Weiterleitung des Hosters. Erreicht der Server sich selbst nicht über http, sagt die Meldung das statt zu raten. +- HSTS ist ein eigener zweiter Schalter, nur bei eingeschalteter Umleitung und funktionierendem HTTPS, mit Warnung: Ein Browser hält den Kopf ein Jahr. „Umleitung ausschalten“ nimmt HSTS mit. +- Darf PHP die Datei nicht schreiben oder fehlen die Zeilen, zeigt System, was von Hand zu tun ist. + +**Updates.** Die Zusammenführung, die eingeschaltete Zeilen in die neue `.htaccess` mitnimmt, steht jetzt in `HttpsRedirect::keepEnabled()` und gilt für beide Wege: das Archiv-Update wie bisher, und neu der Git-Updater, der die Datei vorher mit `checkout --force` überschrieb. Eine geänderte `public/.htaccess` zählt im Git-Weg nicht mehr als lokale Änderung, die das Update verweigert. Weil die Zeilen nach Inhalt und nicht nach Position gefunden werden, landet eine von Hand eingeschaltete Umleitung beim Update an der neuen, richtigen Stelle. + +**Folgen.** Keine Migration. Installationen, die die Zeilen schon von Hand eingeschaltet haben, leiteten bisher nur Dateien um; mit dem Update auf 1.33.0 leiten sie alles um. + ## ADR 119 – Das Menü schließt nach einer Wahl (12.10.2026) Anlass: Umut: „Wenn man Links zu Ankern, also #kontakt, im Menü klickt, dann schließt sich das Menü nicht beim Mobilmenü. Also immer schließen, wenn man ausgewählt hat, egal was.“ diff --git a/docs/LIVEGANG.md b/docs/LIVEGANG.md index a966d2e..b47f6cb 100644 --- a/docs/LIVEGANG.md +++ b/docs/LIVEGANG.md @@ -20,7 +20,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben ## Nicht geprüft – muss vor dem Umschalten passieren 1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung. -2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen. +2. **HTTPS.** Zertifikat einrichten, dann unter System → „HTTPS“ auf „HTTPS erzwingen“ (prüft vorher und nachher, ADR 120; von Hand: die drei Zeilen in `public/.htaccess` einschalten). **Erst wenn das eine Weile läuft**, „HSTS einschalten“ – HSTS lässt sich in einem Browser ein Jahr lang nicht zurücknehmen. 3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. Die Entscheidung steht an einer Stelle: `SITE_ORIGIN` in `.env` (`php bin/console cms:site:origin https://www.kant-it.de`). Die jeweils andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um; in `.htaccess` gibt es dafür keine Regel mehr. Der Bildschirm „System“ zeigt die eingetragene Adresse und warnt, wenn die Verwaltung gerade unter einer anderen läuft. 4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden. 5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg. diff --git a/docs/STATUS.md b/docs/STATUS.md index a6a5b22..f43d4a7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## Fassung 1.33.0: HTTPS erzwingen aus der Verwaltung (12.10.2026) + +**Anlass:** Umut: HTTPS wird noch nicht erzwungen – allgemein lösen. + +**Änderung (ADR 120):** System → „HTTPS“ mit „HTTPS erzwingen“, danach „HSTS einschalten“ und „Umleitung ausschalten“; Konsole `cms:https`. Eingeschaltet wird nur nach erfolgreicher Probe über HTTPS, nach dem Einschalten wird erneut geprüft und bei einer Schleife sofort zurückgenommen; die http-Antwort wird gemeldet. Mitbehoben: Der HTTPS-Block in `public/.htaccess` stand hinter der Regel für `index.php` und leitete Seiten nie um (nur Dateien), und `/.well-known/kantsites-origin` antwortete unter Apache mit 403, womit auch „Adresse speichern“ dort scheiterte. Der Git-Updater behält eingeschaltete Zeilen jetzt wie das Archiv-Update. + +**Geprüft:** `tests/HttpsRedirectTest.php` (8 Tests: Probe davor/danach, Schleife zurückgenommen, Server ignoriert Regel, HSTS nur nach Umleitung, Update behält, Git-Updater zählt die Datei nicht als Änderung). Auf Apache 2.4 (XAMPP) mit der alten und neuen `.htaccess`: alt `/` und `/leistungen` 200, neu 301, mit `X-Forwarded-Proto: https` 200; `.well-known`-Probe 200, `.env`/`.htaccess`/`.htpasswd` 403; die http-Probe der Klasse erkennt 301, 200 und „keine Verbindung“. Im Browser (Hüfner lokal): Anzeige gesperrt ohne https-Adresse, eingeschalteter Zustand mit HSTS-Hinweis, „Umleitung ausschalten“ stellt die Datei byte-gleich wieder her. Nicht geprüft: der Erfolgsweg gegen ein echtes Zertifikat (lokal kein HTTPS) – das zeigt sich beim ersten Einschalten live. + +**Live Hüfner:** nach dem Update auf 1.33.0 unter System → HTTPS „HTTPS erzwingen“ drücken. + ## Fassung 1.32.1: Menü schließt nach einer Wahl (12.10.2026) **Anlass:** Umut: Ein Link auf ein Sprungziel (#kontakt) im Mobilmenü ließ das Menü offen. diff --git a/public/.htaccess b/public/.htaccess index f80abb1..343d683 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -9,18 +9,25 @@ Options -Indexes -MultiViews # Absicherung steht in der .htaccess von WordPress. RewriteEngine On RewriteBase / -RewriteRule ^index\.php$ - [L] -RewriteCond %{REQUEST_FILENAME} !-f -RewriteRule ^ index.php [L] # HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat -# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Vor dem Livegang mit -# Zertifikat: die drei Zeilen einschalten - und erst danach die HSTS-Zeile weiter unten, -# denn HSTS laesst sich in einem Browser ein halbes Jahr lang nicht zuruecknehmen. +# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Einschalten am besten in +# der Verwaltung unter System -> "HTTPS erzwingen": dort wird vorher und nachher geprueft und +# bei einer Schleife zurueckgenommen (ADR 120). Von Hand: die drei Zeilen einschalten - und erst +# danach die HSTS-Zeile weiter unten, denn HSTS laesst sich in einem Browser ein Jahr lang nicht +# zuruecknehmen. +# +# Die Zeilen stehen vor den Regeln fuer index.php, nicht dahinter: Nach dem Umschreiben auf +# index.php beginnt die Runde neu, und "^index\.php$ - [L]" beendet sie, bevor eine spaetere +# Regel an die Reihe kommt. Weiter unten griff die Umleitung nur fuer echte Dateien. #RewriteCond %{HTTPS} !=on #RewriteCond %{HTTP:X-Forwarded-Proto} !=https #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] +RewriteRule ^index\.php$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^ index.php [L] + # Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der # Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin. # Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen. @@ -65,13 +72,17 @@ RewriteRule ^ index.php [L] # ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste. Header always unset X-Powered-By Header always unset X-AspNet-Version - # Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben). + # Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben; System -> HSTS). #Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" # Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich # vollstaendig in den oeffentlichen Ordner legt statt nur public/. - +# .well-known ausgenommen: Unter /.well-known/kantsites-origin prueft die Verwaltung, ob die +# Website unter einer Adresse hier ankommt (Adresse speichern, HTTPS erzwingen), und Apache +# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel - vorher antwortete die +# Probe mit 403. Zertifikatsanbieter (acme-challenge) brauchen den Ordner ebenso. + Require all denied # Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON. diff --git a/public/assets/admin.css b/public/assets/admin.css index f0d61cf..3affcea 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -697,6 +697,8 @@ details.card > summary:hover { background: #fafbf8 } .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 } +.https-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px } +.card-body .https-actions .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 } diff --git a/src/Command/HttpsCommand.php b/src/Command/HttpsCommand.php new file mode 100644 index 0000000..4b1c3ee --- /dev/null +++ b/src/Command/HttpsCommand.php @@ -0,0 +1,47 @@ +addArgument('aktion', InputArgument::OPTIONAL, 'an, aus, hsts-an oder hsts-aus; ohne Angabe der Stand.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $action = (string) $input->getArgument('aktion'); + if ('' === $action) { + $state = $this->https->state(); + $io->writeln(sprintf('Umleitung auf HTTPS: %s · HSTS: %s%s', $state['redirect'] ? 'an' : 'aus', $state['hsts'] ? 'an' : 'aus', $state['found'] ? '' : ' · Zeilen fehlen in public/.htaccess')); + + return Command::SUCCESS; + } + $result = match ($action) { + 'an' => $this->https->enableRedirect($this->siteOrigin), + 'aus' => $this->https->disableRedirect(), + 'hsts-an' => $this->https->switchHsts(true, $this->siteOrigin), + 'hsts-aus' => $this->https->switchHsts(false, $this->siteOrigin), + default => ['ok' => false, 'message' => 'Erlaubt sind an, aus, hsts-an und hsts-aus.'], + }; + $result['ok'] ? $io->success($result['message']) : $io->error($result['message']); + + return $result['ok'] ? Command::SUCCESS : Command::FAILURE; + } +} diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index 61db131..753df6c 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -9,6 +9,7 @@ use App\Domain\InvalidPage; use App\Ops\ArchiveUpdate; use App\Ops\Backup; use App\Ops\Environment; +use App\Ops\HttpsRedirect; use App\Ops\Installation; use App\Ops\OriginCheck; use App\Ops\SitePackage; @@ -29,7 +30,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, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages) + 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 HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https) { } @@ -67,6 +68,9 @@ final class SystemController extends AbstractController // 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(), ]); } @@ -494,6 +498,28 @@ final class SystemController extends AbstractController ), '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 diff --git a/src/Ops/ArchiveUpdate.php b/src/Ops/ArchiveUpdate.php index 0b3698d..7d86a32 100644 --- a/src/Ops/ArchiveUpdate.php +++ b/src/Ops/ArchiveUpdate.php @@ -566,15 +566,7 @@ final class ArchiveUpdate */ private function mergeHtaccess(string $neu, string $alt): void { - $vorher = (string) file_get_contents($alt); - $inhalt = (string) file_get_contents($neu); - foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) { - $aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher); - if ($aktiv) { - $inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt); - } - } - file_put_contents($alt, $inhalt); + file_put_contents($alt, HttpsRedirect::keepEnabled((string) file_get_contents($neu), (string) file_get_contents($alt))); } /** diff --git a/src/Ops/HttpsRedirect.php b/src/Ops/HttpsRedirect.php new file mode 100644 index 0000000..d0de50b --- /dev/null +++ b/src/Ops/HttpsRedirect.php @@ -0,0 +1,259 @@ + mit gültigem Zertifikat? Sonst nichts. + * 2. Zeilen einschalten. + * 3. Kommt https:// weiterhin hier an? Sonst ist es eine Schleife (der Server meldet + * HTTPS nicht, kein Proxy-Kopf) - die Datei wird sofort zurückgeschrieben. + * 4. Leitet http:// mit 301 auf https um? Wenn nicht, liest der Server die Regel nicht + * (kein mod_rewrite, AllowOverride) - die Zeilen bleiben, schaden nicht, und die Meldung sagt es. + * + * HSTS ist ein eigener Schritt: Ein Browser merkt sich den Kopf ein Jahr lang, ein Rückweg auf + * http ist dann für jeden Besucher zu, der die Seite einmal gesehen hat. + */ +class HttpsRedirect +{ + public const REDIRECT_LINES = [ + 'RewriteCond %{HTTPS} !=on', + 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', + 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', + ]; + public const HSTS_LINE = 'Header always set Strict-Transport-Security'; + + /** @var \Closure(string): array{status: int, location: string, error: string} */ + private \Closure $httpProbe; + + /** @param \Closure(string): array{status: int, location: string, error: string}|null $httpProbe */ + public function __construct(private string $projectDir, private OriginCheck $originCheck, ?\Closure $httpProbe = null) + { + $this->httpProbe = $httpProbe ?? self::probeHttp(...); + } + + public function file(): string + { + return $this->projectDir.'/public/.htaccess'; + } + + /** @return array{redirect: bool, hsts: bool, found: bool, writable: bool} */ + public function state(): array + { + $content = is_file($this->file()) ? (string) file_get_contents($this->file()) : ''; + + return [ + 'redirect' => self::active($content, self::REDIRECT_LINES), + 'hsts' => self::active($content, [self::HSTS_LINE]), + 'found' => self::present($content, self::REDIRECT_LINES) && self::present($content, [self::HSTS_LINE]), + 'writable' => is_file($this->file()) && is_writable($this->file()), + ]; + } + + /** + * Schaltet die Umleitung ein. Gibt Erfolg und eine Meldung in einem Satz oder zwei zurück; + * bei Misserfolg ist die Datei so, wie sie vorher war. + * + * @return array{ok: bool, message: string} + */ + public function enableRedirect(string $origin): array + { + $origin = rtrim(trim($origin), '/'); + if (!preg_match('#^https://[^/?\#]+$#', $origin)) { + return ['ok' => false, 'message' => 'Erst die Adresse der Website mit https:// eintragen – die Umleitung braucht ein Ziel.']; + } + if (null !== $problem = $this->originCheck->problem($origin)) { + return ['ok' => false, 'message' => $problem.' Ohne funktionierendes HTTPS bleibt die Umleitung aus.']; + } + $before = $this->read(); + if (null === $before) { + return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.']; + } + if (!self::present($before, self::REDIRECT_LINES)) { + return ['ok' => false, 'message' => 'Die Zeilen für HTTPS stehen nicht in public/.htaccess (geändert oder sehr alte Fassung). Das nächste Update bringt sie zurück.']; + } + if (self::active($before, self::REDIRECT_LINES)) { + return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist schon eingeschaltet.']; + } + if (!$this->write(self::switchLines($before, self::REDIRECT_LINES, true))) { + return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben. Die drei Zeilen unter „HTTPS erzwingen“ dort von Hand einschalten (das # davor entfernen).']; + } + if (null !== $problem = $this->originCheck->problem($origin)) { + $this->write($before); + + return ['ok' => false, 'message' => 'Mit eingeschalteter Umleitung kam '.$origin.' nicht mehr an: '.rtrim($problem, '.').'. Vermutlich meldet der Server HTTPS nicht an die .htaccess (Proxy ohne X-Forwarded-Proto). Die Datei ist zurückgesetzt, nichts geändert.']; + } + $http = 'http://'.substr($origin, 8).OriginCheck::PATH; + $answer = ($this->httpProbe)($http); + $target = $origin.OriginCheck::PATH; + if (in_array($answer['status'], [301, 308], true) && $answer['location'] === $target) { + return ['ok' => true, 'message' => 'HTTPS wird jetzt erzwungen: http:// leitet mit 301 auf '.$origin.' um. HSTS erst einschalten, wenn das ein paar Tage gut läuft.']; + } + if (0 === $answer['status']) { + return ['ok' => true, 'message' => 'Die Umleitung ist eingeschaltet, und HTTPS antwortet weiter. Ob http:// umleitet, ließ sich vom Server aus nicht prüfen ('.$answer['error'].') – einmal im Browser http:// aufrufen.']; + } + + return ['ok' => true, 'message' => sprintf('Die Zeilen sind eingeschaltet, aber http:// antwortet mit %d%s statt mit 301 auf HTTPS. Der Server liest die Umleitung offenbar nicht (mod_rewrite, AllowOverride) – beim Hoster die HTTPS-Weiterleitung einschalten.', $answer['status'], '' !== $answer['location'] ? ' nach '.$answer['location'] : '')]; + } + + /** + * Schaltet die Umleitung aus - und HSTS mit, denn HSTS ohne Umleitung verspricht etwas, + * das der Server nicht mehr einhält. + * + * @return array{ok: bool, message: string} + */ + public function disableRedirect(): array + { + $before = $this->read(); + if (null === $before) { + return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.']; + } + $hsts = self::active($before, [self::HSTS_LINE]); + $after = self::switchLines(self::switchLines($before, self::REDIRECT_LINES, false), [self::HSTS_LINE], false); + if ($after !== $before && !$this->write($after)) { + return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.']; + } + + return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist ausgeschaltet.'.($hsts ? ' HSTS ebenfalls – Browser, die den Kopf schon kennen, bleiben aber bis zu einem Jahr bei HTTPS.' : '')]; + } + + /** @return array{ok: bool, message: string} */ + public function switchHsts(bool $on, string $origin): array + { + $before = $this->read(); + if (null === $before || !self::present($before, [self::HSTS_LINE])) { + return ['ok' => false, 'message' => 'Die HSTS-Zeile steht nicht in public/.htaccess.']; + } + if ($on) { + if (!self::active($before, self::REDIRECT_LINES)) { + return ['ok' => false, 'message' => 'HSTS erst, wenn die Umleitung auf HTTPS eingeschaltet ist.']; + } + if (null !== $problem = $this->originCheck->problem(rtrim(trim($origin), '/'))) { + return ['ok' => false, 'message' => $problem.' HSTS bleibt aus.']; + } + } + if (!$this->write(self::switchLines($before, [self::HSTS_LINE], $on))) { + return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.']; + } + + return ['ok' => true, 'message' => $on + ? 'HSTS ist eingeschaltet: Browser rufen diese Domain ein Jahr lang nur noch über HTTPS auf, Subdomains eingeschlossen.' + : 'HSTS ist ausgeschaltet. Browser, die den Kopf schon kennen, bleiben bis zu einem Jahr bei HTTPS.']; + } + + /** + * Die neue .htaccess eines Updates, mit den Zeilen eingeschaltet, die in der alten + * eingeschaltet waren. Archiv- und Git-Update rufen beide hierher. + */ + public static function keepEnabled(string $new, string $old): string + { + if (self::active($old, self::REDIRECT_LINES)) { + $new = self::switchLines($new, self::REDIRECT_LINES, true); + } + if (self::active($old, [self::HSTS_LINE])) { + $new = self::switchLines($new, [self::HSTS_LINE], true); + } + + return $new; + } + + /** @param list $lines */ + public static function active(string $content, array $lines): bool + { + foreach ($lines as $line) { + if (1 !== preg_match('/^[ \t]*'.preg_quote($line, '/').'/m', $content)) { + return false; + } + } + + return true; + } + + /** @param list $lines */ + private static function present(string $content, array $lines): bool + { + foreach ($lines as $line) { + if (1 !== preg_match('/^[ \t]*(#[ \t]*)?'.preg_quote($line, '/').'/m', $content)) { + return false; + } + } + + return true; + } + + /** @param list $lines */ + private static function switchLines(string $content, array $lines, bool $on): string + { + foreach ($lines as $line) { + $quoted = preg_quote($line, '/'); + $content = $on + ? (string) preg_replace('/^([ \t]*)#[ \t]*('.$quoted.')/m', '$1$2', $content) + : (string) preg_replace('/^([ \t]*)('.$quoted.')/m', '$1#$2', $content); + } + + return $content; + } + + private function read(): ?string + { + $content = is_file($this->file()) ? @file_get_contents($this->file()) : false; + + return false === $content ? null : $content; + } + + private function write(string $content): bool + { + return is_writable($this->file()) && false !== @file_put_contents($this->file(), $content, LOCK_EX); + } + + /** + * Eine Anfrage über http, ohne Weiterleitungen zu folgen: gefragt ist nur, wohin sie zeigt. + * + * @return array{status: int, location: string, error: string} + */ + private static function probeHttp(string $url): array + { + if (function_exists('curl_init')) { + $curl = curl_init($url); + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_NOBODY => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_USERAGENT => 'KantSites-HTTPS-Check', + ]); + $raw = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + $location = is_string($raw) && preg_match('/^Location:[ \t]*(\S+)/mi', $raw, $match) ? $match[1] : ''; + + return ['status' => $status, 'location' => $location, 'error' => '' !== $error ? $error : 'keine Antwort']; + } + $context = stream_context_create(['http' => ['follow_location' => 0, 'ignore_errors' => true, 'timeout' => 10, 'user_agent' => 'KantSites-HTTPS-Check']]); + $http_response_header = []; + $body = @file_get_contents($url, false, $context); + $headers = $http_response_header; + if (false === $body && [] === $headers) { + return ['status' => 0, 'location' => '', 'error' => 'keine Verbindung über http']; + } + $status = preg_match('#^HTTP/\S+\s+(\d{3})#', (string) ($headers[0] ?? ''), $match) ? (int) $match[1] : 0; + $location = ''; + foreach ($headers as $header) { + if (preg_match('/^Location:[ \t]*(\S+)/i', $header, $match)) { + $location = $match[1]; + } + } + + return ['status' => $status, 'location' => $location, 'error' => 'keine Antwort']; + } +} diff --git a/src/Ops/Updater.php b/src/Ops/Updater.php index 812fa65..2b5846a 100644 --- a/src/Ops/Updater.php +++ b/src/Ops/Updater.php @@ -96,7 +96,7 @@ final class Updater 'git' => $git, // Der Weg ueber Git steht nur einer Arbeitskopie offen. 'worktree' => $arbeitskopie, - 'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']), + 'clean' => null === $status ? null : ([] === self::dirty($status['output']) && $status['ok']), 'log' => $this->log(), ]; } @@ -138,10 +138,10 @@ final class Updater } $status = $this->git(['status', '--porcelain']); - $dirty = array_values(array_filter(explode("\n", $status['output']))); + $dirty = self::dirty($status['output']); $add($checks, 'Keine lokalen Änderungen', [] === $dirty, [] === $dirty ? 'Die Arbeitskopie ist unverändert.' - : sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim(explode("\n", $status['output'])[0]))); + : sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim($dirty[0]))); $remote = $this->git(['remote', 'get-url', 'origin']); $add($checks, 'Herkunft', $remote['ok'], $remote['ok'] @@ -299,8 +299,13 @@ final class Updater throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.'); } } + $htaccess = $this->root.'/public/.htaccess'; + $htaccessBefore = is_file($htaccess) ? (string) file_get_contents($htaccess) : null; $checkout = $this->git(['checkout', '--force', $target, '--']); $this->restoreEnvironment(); + if (null !== $htaccessBefore && is_file($htaccess)) { + @file_put_contents($htaccess, HttpsRedirect::keepEnabled((string) file_get_contents($htaccess), $htaccessBefore), LOCK_EX); + } if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) { throw new \RuntimeException('checkout'); } @@ -444,6 +449,18 @@ final class Updater } /** Legacy tags tracked .env; preserve the installation even when checking out those tags. */ + /** + * Die geänderten Dateien laut `git status --porcelain` - ohne public/.htaccess: Eine dort + * eingeschaltete HTTPS-Umleitung nimmt apply() in die neue Fassung mit (ADR 120), sie ist + * kein Grund, das Update zu verweigern. + * + * @return list + */ + private static function dirty(string $porcelain): array + { + return array_values(array_filter(explode("\n", $porcelain), static fn (string $line): bool => '' !== trim($line) && 1 !== preg_match('#^\s*M{1,2}\s+public/\.htaccess$#', rtrim($line)))); + } + private function restoreEnvironment(): void { foreach ($this->environmentBefore as $file => $content) { diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index bf9650b..d843087 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -64,6 +64,35 @@
Eingestellt in der Server-Umgebung; sie hat Vorrang vor .env und lässt sich nur beim Hoster ändern. {% endif %} + {# HTTPS erzwingen (ADR 120): schaltet die Zeilen in public/.htaccess, mit Probe davor + und danach. HSTS erst als zweiter Schritt. #} +
HTTPS
+
+ {% if https.redirect %} + Wird erzwungen. – http:// leitet mit 301 auf HTTPS um. + {% elseif origin.adresse starts with 'https://' %} + Nicht erzwungen. – die Website ist auch über http:// erreichbar, ohne Umleitung. + {% else %} + Nicht erzwungen. – erst die Adresse mit https:// eintragen. + {% endif %} + {% if https.redirect %}
HSTS: {% if https.hsts %}eingeschaltet – Browser bleiben ein Jahr lang bei HTTPS, Subdomains eingeschlossen.{% else %}aus. Erst einschalten, wenn HTTPS ein paar Tage gut läuft: Ein Browser merkt sich HSTS ein Jahr lang, das lässt sich nicht zurücknehmen.{% endif %}{% endif %} + {% if not https.found %} +
Die Zeilen dafür stehen nicht in public/.htaccess – geändert oder eine alte Fassung. Das nächste Update bringt sie zurück. + {% elseif not https.writable %} +
PHP darf public/.htaccess nicht schreiben. Von Hand: das # vor den drei Zeilen unter „HTTPS erzwingen“ entfernen. + {% else %} +
+ + {% if not https.redirect %} + + {% else %} + {% if not https.hsts %}{% else %}{% endif %} + + {% endif %} +
+ {% if not https.redirect and origin.adresse starts with 'https://' %}

Vorher wird geprüft, ob {{ origin.adresse }} mit gültigem Zertifikat hier ankommt, danach, ob sie es weiterhin tut. Klappt das nicht, bleibt alles, wie es war.

{% endif %} + {% endif %} +
{% if state.worktree %}
Stand
{{ state.describe }}{% if state.commit != 'unbekannt' %} · Commit {{ state.commit }}{% endif %}
{% else %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 781c961..5990cee 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -1621,7 +1621,10 @@ final class CmsTest extends TestCase $htaccess = (string) file_get_contents(__DIR__.'/../public/.htaccess'); self::assertStringContainsString('Header always unset X-Powered-By', $htaccess); // Ohne diese Zeile schreibt IONOS index.php endlos auf sich selbst um: weißer 500er (ADR 114). - self::assertStringContainsString("RewriteBase /\nRewriteRule ^index\\.php$ - [L]\nRewriteCond %{REQUEST_FILENAME} !-f", $htaccess); + self::assertStringContainsString("RewriteRule ^index\\.php$ - [L]\nRewriteCond %{REQUEST_FILENAME} !-f", $htaccess); + // Die HTTPS-Umleitung steht davor, sonst erreicht sie keine Seite (ADR 120). + self::assertLessThan(strpos($htaccess, 'RewriteRule ^index\\.php$ - [L]'), strpos($htaccess, 'RewriteRule ^ https://')); + self::assertGreaterThan(strpos($htaccess, 'RewriteBase /'), strpos($htaccess, 'RewriteRule ^ https://')); } public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void diff --git a/tests/HttpsRedirectTest.php b/tests/HttpsRedirectTest.php new file mode 100644 index 0000000..90dbadc --- /dev/null +++ b/tests/HttpsRedirectTest.php @@ -0,0 +1,167 @@ + */ + private array $answers = []; + /** @var array{status: int, location: string, error: string} */ + private array $http = ['status' => 301, 'location' => 'https://www.example.de/.well-known/kantsites-origin', 'error' => '']; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir().'/kant-https-'.bin2hex(random_bytes(4)); + mkdir($this->root.'/public', 0o777, true); + $this->shipped = (string) file_get_contents(dirname(__DIR__).'/public/.htaccess'); + file_put_contents($this->root.'/public/.htaccess', $this->shipped); + } + + protected function tearDown(): void + { + @unlink($this->root.'/public/.htaccess'); + @rmdir($this->root.'/public'); + @rmdir($this->root); + } + + public function testTheShippedFileHasTheRedirectBeforeTheFrontController(): void + { + // Hinter "RewriteRule ^index\.php$ - [L]" kam die Umleitung für Seiten nie an die Reihe: + // Nach dem Umschreiben auf index.php beginnt die Runde neu und endet an dieser Regel. + $redirect = strpos($this->shipped, '#'.HttpsRedirect::REDIRECT_LINES[0]); + $front = strpos($this->shipped, 'RewriteRule ^index\.php$ - [L]'); + self::assertNotFalse($redirect); + self::assertNotFalse($front); + self::assertLessThan($front, $redirect); + self::assertSame(['redirect' => false, 'hsts' => false, 'found' => true, 'writable' => true], $this->redirect()->state()); + } + + public function testEnablingProbesBeforeAndAfterAndSwitchesOnlyTheThreeLines(): void + { + $this->answers = ['ok', 'ok']; + $result = $this->redirect()->enableRedirect('https://www.example.de/'); + self::assertTrue($result['ok'], $result['message']); + self::assertStringContainsString('leitet mit 301', $result['message']); + $now = (string) file_get_contents($this->root.'/public/.htaccess'); + foreach (HttpsRedirect::REDIRECT_LINES as $line) { + self::assertMatchesRegularExpression('/^'.preg_quote($line, '/').'$/m', $now); + } + self::assertStringContainsString('#'.HttpsRedirect::HSTS_LINE, $now, 'HSTS bleibt ein eigener Schritt.'); + self::assertSame([], $this->answers, 'Vorher und nachher geprüft.'); + + $off = $this->redirect()->disableRedirect(); + self::assertTrue($off['ok']); + self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'), 'Aus heißt: wieder wie ausgeliefert.'); + } + + public function testNothingChangesWithoutWorkingHttps(): void + { + $this->answers = [null]; + $result = $this->redirect()->enableRedirect('https://www.example.de'); + self::assertFalse($result['ok']); + self::assertStringContainsString('bleibt die Umleitung aus', $result['message']); + self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess')); + + $result = $this->redirect()->enableRedirect('http://www.example.de'); + self::assertFalse($result['ok']); + self::assertStringContainsString('https://', $result['message']); + } + + public function testALoopIsTakenBackAtOnce(): void + { + // Der Server meldet HTTPS nicht an die .htaccess: mit den Zeilen leitet auch https:// + // auf sich selbst um, die Probe danach kommt nicht mehr an. + $this->answers = ['ok', null]; + $result = $this->redirect()->enableRedirect('https://www.example.de'); + self::assertFalse($result['ok']); + self::assertStringContainsString('zurückgesetzt', $result['message']); + self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess')); + } + + public function testAServerThatIgnoresTheRuleIsNamed(): void + { + $this->answers = ['ok', 'ok']; + $this->http = ['status' => 200, 'location' => '', 'error' => '']; + $result = $this->redirect()->enableRedirect('https://www.example.de'); + self::assertTrue($result['ok']); + self::assertStringContainsString('antwortet mit 200', $result['message']); + } + + public function testHstsOnlyAfterTheRedirect(): void + { + $this->answers = ['ok']; + $result = $this->redirect()->switchHsts(true, 'https://www.example.de'); + self::assertFalse($result['ok']); + self::assertStringContainsString('erst, wenn die Umleitung', $result['message']); + + $this->answers = ['ok', 'ok', 'ok']; + $this->redirect()->enableRedirect('https://www.example.de'); + self::assertTrue($this->redirect()->switchHsts(true, 'https://www.example.de')['ok']); + self::assertSame(['redirect' => true, 'hsts' => true, 'found' => true, 'writable' => true], $this->redirect()->state()); + + // Die Umleitung aus nimmt HSTS mit. + $this->redirect()->disableRedirect(); + self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess')); + } + + public function testAnUpdateKeepsWhatWasSwitchedOn(): void + { + $this->answers = ['ok', 'ok', 'ok']; + $this->redirect()->enableRedirect('https://www.example.de'); + $this->redirect()->switchHsts(true, 'https://www.example.de'); + $merged = HttpsRedirect::keepEnabled($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess')); + self::assertTrue(HttpsRedirect::active($merged, HttpsRedirect::REDIRECT_LINES)); + self::assertTrue(HttpsRedirect::active($merged, [HttpsRedirect::HSTS_LINE])); + self::assertSame($this->shipped, HttpsRedirect::keepEnabled($this->shipped, $this->shipped)); + } + + public function testTheGitUpdaterDoesNotCallTheSwitchedFileALocalChange(): void + { + $dirty = new \ReflectionMethod(\App\Ops\Updater::class, 'dirty'); + self::assertSame([], $dirty->invoke(null, " M public/.htaccess\n")); + self::assertSame([], $dirty->invoke(null, 'M public/.htaccess'), 'Auch wenn die Ausgabe vorn gekürzt ist.'); + self::assertSame([' M src/Kernel.php'], $dirty->invoke(null, " M public/.htaccess\n M src/Kernel.php\n")); + self::assertSame(['?? public/.htaccess.bak'], $dirty->invoke(null, '?? public/.htaccess.bak')); + } + + private function redirect(): HttpsRedirect + { + $test = $this; + $download = new class($test) extends Download { + public function __construct(private HttpsRedirectTest $test) + { + parent::__construct(); + } + + public function get(string $url): ?string + { + return $this->test->nextAnswer(); + } + }; + $check = new OriginCheck('geheim', $download); + $this->token = $check->token(); + + return new HttpsRedirect($this->root, $check, fn (string $url): array => $this->http); + } + + private string $token = ''; + + public function nextAnswer(): ?string + { + self::assertNotSame([], $this->answers, 'Mehr Proben als erwartet.'); + $answer = array_shift($this->answers); + + return null === $answer ? null : $this->token; + } +}