diff --git a/README.md b/README.md index 268d5fd..1ed8f98 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0 Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage. -`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Es gibt keinen öffentlichen Installer und keine Standardzugangsdaten. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token. +`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht. + +**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token. ## Anmelden und bearbeiten diff --git a/VERSION b/VERSION index 26aaba0..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/config/bootstrap.php b/config/bootstrap.php index 9fd5e5c..20ef516 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -11,3 +11,19 @@ foreach (['APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', ' } } (new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); + +// A freshly unpacked installation has no secret, and without one nothing boots - not even the +// installer that would explain why. So the first request writes one into .env.local and never +// touches it again. If the project directory is not writable, the request still works, but only +// this one: the installer's system check says so in a sentence instead of leaving a white page. +if (!isset($_SERVER['APP_SECRET']) || '' === $_SERVER['APP_SECRET']) { + $secret = bin2hex(random_bytes(32)); + $file = dirname(__DIR__).'/.env.local'; + $existing = is_file($file) ? (string) file_get_contents($file) : ''; + if (false === @file_put_contents($file, $existing.(str_ends_with($existing, "\n") || '' === $existing ? '' : "\n").'APP_SECRET="'.$secret.'"'."\n")) { + $secret .= '-nicht-gespeichert'; + } else { + @chmod($file, 0o600); + } + $_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'] = $secret; +} diff --git a/config/services.yaml b/config/services.yaml index b182f91..04d3a9f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -29,9 +29,17 @@ services: arguments: $root: '%kernel.project_dir%' $databaseUrl: '%env(resolve:DATABASE_URL)%' + App\EventSubscriber\InstallSubscriber: + arguments: + $projectDir: '%kernel.project_dir%' App\EventSubscriber\MaintenanceSubscriber: arguments: $projectDir: '%kernel.project_dir%' + App\Ops\Installation: + arguments: + $root: '%kernel.project_dir%' + $mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%' + $secret: '%kernel.secret%' App\Ops\Backup: arguments: $root: '%kernel.project_dir%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e618bb..d1c7c10 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -440,6 +440,26 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam, **Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht. +## ADR 041 – Der Installer: sechs Bildschirme, und danach ist er weg (12.09.2026) + +Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist? + +**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link. + +**Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei.** Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter `/install` mit 404. + +**Kein Sitzungstoken, sondern eine Unterschrift.** Das Sitzungscookie dieser Anwendung gilt nur für `/admin` – eine bewusste Entscheidung, die öffentliche Seiten cachebar hält –, und für sechs Bildschirme wird sie nicht aufgeweicht. Jedes Formular trägt Schritt und Zeitpunkt, mit `APP_SECRET` unterschrieben, zwei Stunden gültig. Ehrlich dazugesagt: das ist kein CSRF-Schutz, denn wer den Installer erreicht, bekommt auch eine gültige Unterschrift. Der Schutz ist, dass es den Installer nach dem ersten Konto nicht mehr gibt – deshalb steht auf dem letzten Bildschirm, dass sofort nach dem Hochladen installiert werden soll. + +**Migrationen laufen im Prozess, nicht über die Konsole.** Auf geteiltem Hosting gibt es oft keine Shell, und `proc_open` ist ein häufiges Opfer einer gehärteten `php.ini`. Der Installer benutzt dieselbe `migrations.php` und dieselbe `DependencyFactory` wie das Konsolenkommando. Bricht der Browser ab, ist nichts kaputt: angewandte Migrationen sind vermerkt, ein erneuter Aufruf macht weiter. + +**Ein Huhn-Ei-Problem und seine Lösung:** ohne `APP_SECRET` bootet Symfony nicht – also nicht einmal die Seite, die erklären würde, warum. Deshalb erzeugt der erste Start das Geheimnis selbst und schreibt es nach `.env.local`. Geht das nicht, läuft die Anfrage trotzdem, und die Systemprüfung sagt in einem Satz, dass im Projektstamm das Schreibrecht fehlt. Eine weiße Seite wäre die Alternative gewesen. + +**Und eine Weiterleitung statt eines Fehlers:** solange keine Installation existiert, führt jede Adresse zu `/install`. Der erste Besuch auf einem frisch entpackten Webspace ist sonst ein 500 aus einer fehlenden Tabelle – genau der Moment, in dem jemand einen Satz braucht und keinen Stacktrace. Sobald ein Konto existiert, wird eine Marke unter `var/installed` geschrieben und keine Anfrage fragt die Datenbank noch einmal; die Marke schweigt nur diesen Subscriber, der Installer selbst fragt immer die Datenbank. + +**Die einzelne Datei davor** (`install.php`) lädt das Releasearchiv, prüft dessen SHA-256, entpackt es und löscht sich danach selbst. Adresse und Prüfsumme stehen **in der Datei**, eingetragen beim Bauen des Releases: ein Installer, der eine URL aus einem Formular entgegennimmt, ist eine Einladung, fremden Code auf den Server zu legen. Stimmt die Prüfsumme nicht, wird nicht entpackt – es gibt kein Häkchen daran vorbei. Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird; geprüft wird nicht durch Suchen nach „..“, sondern indem der zusammengesetzte Zielpfad unterhalb des Ziels liegen muss. In ein Verzeichnis, in dem schon eine Installation liegt, arbeitet sie gar nicht erst. + +**Was der Installer anlegt, ist nicht die Demoseite.** Eine Startseite mit dem Namen der Website, Kopf- und Fußbereich mit Menü – und Impressum und Datenschutzerklärung als Gerüst mit markierten Lücken. Das Letzte ist kein Entgegenkommen: eine deutsche Unternehmenswebsite ohne beides ist ab dem ersten Tag ein Problem, und die Installation ist der einzige Moment, in dem mit Sicherheit jemand hinsieht. + ## ADR 040 – Bildgrößen, und ein Release für Hosting ohne Composer (12.09.2026) Zwei Fragen von Umut in einem Satz: „werden Bilder in verschiedenen Größen angelegt und in WebP umgewandelt?“ und „auf einem Hostingpaket ist kein Composer, meistens, oder?“ – die erste war zur Hälfte schon beantwortet, die zweite ist berechtigt und hatte bis heute keine Antwort. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index 0c75e8a..61c5407 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -191,11 +191,11 @@ Umut: „alle inhalte prüfen immer pagination und minimal laden“. Als durchge Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft. **Beides erfüllt.** Offen bleibt aus diesem Paket: `srcset` ist für Bilder aus der Mediathek vorhanden, aber eine Seite mit vielen Bildern ist nie gegen das Budget gemessen worden, und die Benutzerliste blättert noch nicht (sie ist naturgemäß kurz). -### N – Installer (Nutzerauftrag vom 12.09.2026) +### N – Installer (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 041), außer dem Download aus einer veröffentlichten Releaseadresse -**Teilweise erledigt am 12.09.2026:** der *Updater* ist gebaut (ADR 035), der *Installer* nicht. Beide teilen die Systemprüfung, die jetzt existiert und dort wiederverwendet wird. +**Gebaut:** der Browserinstaller unter `/install` mit sechs Bildschirmen und die einzelne `install.php`, die ein Buildskript mit Adresse und Prüfsumme versieht. Der Schritt „Datenbank“ fragt SQLite oder MySQL ab und prüft die Verbindung sofort; damit brauchte er die Hostingentscheidung aus Arbeitspaket J nicht mehr abzuwarten. -*Braucht: die Hostingentscheidung aus Arbeitspaket J für den Schritt „Datenbank“.* +**Offen bleibt:** ein echter Download (dazu muss ein Release unter seiner Adresse liegen), MySQL gegen einen echten Server, und eine Installation auf echtem Hosting. Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ Heute braucht eine Installation `bin/setup.php` und Konsolenzugriff – auf geteiltem Hosting ist das genau die Hürde, an der es scheitert. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index c345c3f..0f783a6 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,15 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Installer, 12.09.2026 + +- **379 PHP-Tests / 1606 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. +- **Die Abnahme des Pakets ist ein Test, kein Bericht:** ein Test geht die sechs Bildschirme durch die echte HTTP-Schicht, mit einem Kernel in einem wegwerfbaren Projektverzeichnis – leere Adresse → Weiterleitung auf `/install`, Datenbank, Migration, Website, Konto, fertig. Danach antwortet `/install` mit **404** und `/` mit **200**, und auf der Startseite steht der Name, den das Formular bekommen hat. Die Migrationen darin sind die echten. +- **Von Hand im Browser nachgestellt**, gegen eine Kopie der Anwendung in einem leeren Verzeichnis mit leerer Datenbank: erste Anfrage 302 auf `/install`, Systemprüfung, Datenbank (SQLite), 13 Migrationen, Website „Tischlerei Berg“. Den letzten Schritt – das Konto – habe ich nicht im Browser ausgefüllt, sondern dem Test überlassen; ich tippe keine Passwörter in Formulare. +- **axe auf allen vier erreichbaren Installationsbildschirmen: null Verstöße** (wcag2a, wcag2aa, wcag21a, wcag21aa). +- **Das Releasearchiv wurde entpackt und gestartet**, nicht nur gebaut: `kantsites-1.3.0.zip` in ein leeres Verzeichnis, Server darauf, erste Anfrage → Installer. Damit ist belegt, dass `vendor/` vollständig ist und auf dem Zielsystem weder Composer noch eine Konsole gebraucht wird. +- Die einzelne `install.php` ist dort geprüft, wo sie gefährlich wäre: neun Archiveinträge, die aus dem Zielverzeichnis ausbrechen wollen (`../`, absolut, Windows-Laufwerk, Rückwärtsschrägstrich, Nullbyte), landen nirgends – und ein Archiv mit einem solchen Eintrag bricht ab, ohne die Datei außerhalb anzulegen. Der Eintrag im Testarchiv ist echt: ZIP speichert `kantsites-1.2.0/../entwischt.txt` unverändert. +- **Nicht geprüft:** der Download selbst. Solange das Archiv nicht unter seiner Adresse liegt, ist die Kette „Datei hochladen → lädt → entpackt“ nur bis zum Entpacken belegt. Ebenso ungeprüft: MySQL als Zieldatenbank im Installer (der Verbindungstest ist da, ein echter MySQL-Server war es nicht), echtes Hosting, und ein Screenreader. + ## Ergänzung: Bildgrößen und Releasearchiv, 12.09.2026 - **352 PHP-Tests / 1479 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. diff --git a/docs/STATUS.md b/docs/STATUS.md index e9624b5..9721a86 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -27,6 +27,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster. - **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte. - **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz. +- **Installer (Arbeitspaket N, ADR 041):** `/install` führt in sechs Schritten von einer leeren Installation zu einer Website – Systemprüfung, Datenbank (SQLite oder MySQL, Verbindung sofort geprüft), Migration im Prozess ohne Konsole, Website mit Startseite, Kopf-/Fußbereich, Menü sowie Impressum und Datenschutzgerüst, erstes Administratorkonto, Abschlussliste. Solange keine Installation existiert, führt jede Adresse dorthin; sobald ein Konto existiert, antwortet `/install` mit 404. Davor liegt `scripts/install.php`: eine einzelne Datei, die das Releasearchiv mit fester Adresse und fester SHA-256 lädt, gegen ZIP-Slip gesichert entpackt und sich danach selbst löscht. - **Fassungsverlauf, Einsendungen und Weiterleitungen blättern** statt bei 30, 50 beziehungsweise 2000 Einträgen stillschweigend aufzuhören. - **Sicherung und Wiederherstellung** (ADR 038, Arbeitspaket O): `cms:backup` schreibt Datenbank (über `VACUUM INTO`, nicht als Dateikopie) und Medienordner nach `var/backups/`, mit Manifest samt Prüfsumme, und rotiert die alten. In der Verwaltung gibt es die Übersicht mit Zustand je Sicherung und einen Knopf zum Anlegen. **Zurückspielen nur auf der Konsole** (`cms:restore`): Prüfsumme, Fassungsvergleich, Sicherheitskopie des aktuellen Stands, Wartungszustand, Datenbank ersetzen, Medien **ergänzen** statt ersetzen. Nicht enthalten: `.env`, `vendor/`, Quellcode – steht so im Manifest. - **Seiten ohne Menü, andere Köpfe je Seite** (ADR 037, Dokumentschema v16): jede Seite wählt Kopf- und Fußbereich – Standard, ein anderer Bereich oder **keiner**, für eine Landingpage ohne Navigation. Dafür sind mehrere Kopf- und Fußbereiche erlaubt, jeder mit eigener Adresse; der erste bleibt `default`. Ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück statt die Navigation stillschweigend zu verlieren. diff --git a/public/assets/admin.css b/public/assets/admin.css index f622a8a..30655b9 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -601,3 +601,52 @@ details.card > summary:hover { background: #fafbf8 } .log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px } .log-list code { font-size: 11px; overflow-wrap: anywhere } .shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) } + +/* ---- the installer: six screens on an otherwise empty server ---------------------------- */ + +.install-body { + min-height: 100dvh; + display: grid; + place-items: start center; + background: radial-gradient(ellipse at 20% 0%, #dfeab0, transparent 60%), var(--ground); + padding: 40px 20px 60px; +} +.install-shell { width: 100%; max-width: 640px } +.install-shell .card { margin-top: 18px } +.install-shell h1 { font-size: 24px } +.install-shell form { margin-top: 4px } +.install-shell .sheet-actions { margin-top: 24px } +.install-shell h2 { font-size: 15px; margin: 30px 0 0 } + +/* The step list is the only orientation there is: no navigation exists yet. */ +.install-steps { + list-style: none; + margin: 14px 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 12px; + color: var(--muted); +} +.install-steps li { display: flex; align-items: center; gap: 7px } +.install-steps .current { color: var(--ink); font-weight: 700 } +.install-step-number { + display: grid; + place-items: center; + width: 19px; height: 19px; + border-radius: 50%; + background: #e4e9df; + font-size: 11px; + font-weight: 700; +} +.install-steps .current .install-step-number { background: var(--accent); color: #fff } +.install-steps .done .install-step-number { background: #e7f0d6; color: #2c5340 } + +/* A finding that is worth saying but does not block anything. */ +.check-warn .check-mark { background: #f3f0e2; color: #6b6550 } + +.button-link { text-decoration: none } +.install-details { margin-top: 18px; border: 1px solid var(--line); border-radius: 10px; padding: 0 16px 16px } +.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer } +.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px } diff --git a/scripts/build-release.php b/scripts/build-release.php index dab73c1..2f1be29 100644 --- a/scripts/build-release.php +++ b/scripts/build-release.php @@ -15,7 +15,10 @@ * would mean an archive with PHPUnit in it. */ $root = dirname(__DIR__); -$options = getopt('', ['out::', 'keep-dev']); +$options = getopt('', ['out::', 'keep-dev', 'url::']); +// Wohin das Archiv beim Veröffentlichen gelegt wird. Steht fest in install.php, deshalb muss +// sie hier bekannt sein - eine Adresse, die erst im Browser abgefragt wird, wäre eine Lücke. +$releaseBase = rtrim($options['url'] ?? 'https://git.kant-it.de/kantarci/KantSites/releases/download', '/'); $outDirectory = $root.'/'.trim($options['out'] ?? 'var/releases', '/'); $version = trim((string) file_get_contents($root.'/VERSION')); if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) { @@ -87,6 +90,14 @@ foreach ($include as $entry) { $files += $copy($root.'/'.$entry, $staging.'/'.$entry); } } + +// Die .env des Entwicklungsrechners gehört nicht in ein Release; gebraucht wird trotzdem eine, +// denn ohne sie bootet die Anwendung nicht einmal so weit, dass der Installer erscheint. Also +// wird die Vorlage zur .env des Releases - mit Produktionswerten und ohne Geheimnis. Das +// Geheimnis erzeugt der erste Aufruf selbst, die Zugangsdaten schreibt der Installer nach +// .env.local. +copy($root.'/.env.example', $staging.'/.env'); +++$files; echo "Quelldateien: {$files}\n"; // The dependencies, without the development ones - that is the whole point of building here. @@ -137,4 +148,16 @@ file_put_contents($archive.'.sha256', $hash.' '.basename($archive)."\n"); printf("\nArchiv: %s\n", $archive); printf("Dateien: %d, Größe: %.1f MB\n", $entries, filesize($archive) / 1048576); printf("SHA-256: %s\n", $hash); -echo "\nDie Prüfsumme gehört in install.php, damit ein manipuliertes Archiv nicht entpackt wird.\n"; + +// Und der einzelne Dateidownload: dieselbe install.php, nur mit eingetragener Adresse und +// Prüfsumme. Ohne beides verweigert sie die Arbeit, deshalb entsteht sie erst hier. +$installer = strtr((string) file_get_contents($root.'/scripts/install.php'), [ + "const KANT_ARCHIVE_URL = '';" => sprintf("const KANT_ARCHIVE_URL = '%s';", $releaseBase.'/v'.$version.'/'.basename($archive)), + "const KANT_ARCHIVE_SHA256 = '';" => sprintf("const KANT_ARCHIVE_SHA256 = '%s';", $hash), + "const KANT_VERSION = '';" => sprintf("const KANT_VERSION = '%s';", $version), +]); +file_put_contents($outDirectory.'/install.php', $installer); + +printf("Installer: %s (lädt %s)\n", $outDirectory.'/install.php', $releaseBase.'/v'.$version.'/'.basename($archive)); +echo "\nBeides gehört auf die Releaseseite: das Archiv unter genau dieser Adresse, und install.php zum Herunterladen.\n"; +echo "Die Prüfsumme steckt jetzt in install.php, damit ein manipuliertes Archiv nicht entpackt wird.\n"; diff --git a/scripts/install.php b/scripts/install.php new file mode 100644 index 0000000..84b85f5 --- /dev/null +++ b/scripts/install.php @@ -0,0 +1,283 @@ + 1 && str_starts_with($parts[0], 'kantsites-')) { + array_shift($parts); + } + foreach ($parts as $part) { + if ('' === $part || '.' === $part || '..' === $part || str_contains($part, "\0") || str_contains($part, ':')) { + return null; + } + } + if ([] === $parts) { + return null; + } + $base = rtrim(str_replace('\\', '/', $target), '/'); + $path = $base.'/'.implode('/', $parts); + // Doppelt geprüft: der zusammengesetzte Pfad muss unterhalb des Ziels beginnen. + return str_starts_with($path, $base.'/') ? $path : null; +} + +/** Lädt eine Adresse über HTTPS mit Zertifikatsprüfung in eine Datei. Gibt eine Meldung oder null. */ +function kant_download(string $url, string $file): ?string +{ + if (!str_starts_with($url, 'https://')) { + return 'Die Adresse des Archivs ist keine HTTPS-Adresse.'; + } + $handle = fopen($file, 'wb'); + if (false === $handle) { + return 'Die heruntergeladene Datei ließ sich nicht anlegen: '.$file; + } + try { + if (function_exists('curl_init')) { + $curl = curl_init($url); + curl_setopt_array($curl, [ + CURLOPT_FILE => $handle, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_CONNECTTIMEOUT => 20, + CURLOPT_TIMEOUT => 900, + CURLOPT_USERAGENT => 'KantSites-Installer', + ]); + $ok = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if (!$ok) { + return 'Der Download ist gescheitert: '.$error; + } + if (200 !== $status) { + return 'Der Server hat mit Status '.$status.' geantwortet statt mit 200.'; + } + + return null; + } + if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) { + return 'Weder curl noch allow_url_fopen ist verfügbar. Ohne eines von beidem kann diese Datei nichts herunterladen; dann bleibt der Weg über FTP mit dem entpackten Archiv.'; + } + $context = stream_context_create(['http' => ['timeout' => 900, 'follow_location' => 1, 'max_redirects' => 3], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]); + $source = @fopen($url, 'rb', false, $context); + if (false === $source) { + return 'Das Archiv ließ sich nicht öffnen. Prüfe, ob dieser Server nach außen verbinden darf.'; + } + stream_copy_to_stream($source, $handle); + fclose($source); + + return null; + } finally { + if (is_resource($handle)) { + fclose($handle); + } + } +} + +/** Entpackt das Archiv; gibt die Anzahl der Dateien zurück oder wirft mit einer lesbaren Meldung. */ +function kant_unpack(string $archive, string $target): int +{ + if (!class_exists('ZipArchive')) { + throw new RuntimeException('Die PHP-Erweiterung zip fehlt. Ohne sie lässt sich das Archiv nicht entpacken.'); + } + $zip = new ZipArchive(); + if (true !== $zip->open($archive)) { + throw new RuntimeException('Das Archiv ließ sich nicht öffnen. Der Download ist wahrscheinlich unvollständig.'); + } + $written = 0; + try { + for ($index = 0; $index < $zip->numFiles; ++$index) { + $name = (string) $zip->getNameIndex($index); + $path = kant_target_path($name, $target); + if (null === $path) { + if (str_ends_with($name, '/')) { + continue; + } + throw new RuntimeException('Das Archiv enthält einen Eintrag, der außerhalb des Zielverzeichnisses landen würde: '.$name); + } + if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0o775, true) && !is_dir(dirname($path))) { + throw new RuntimeException('Verzeichnis ließ sich nicht anlegen: '.dirname($path)); + } + $stream = $zip->getStream($name); + if (false === $stream) { + throw new RuntimeException('Eintrag ließ sich nicht lesen: '.$name); + } + $out = fopen($path, 'wb'); + if (false === $out) { + fclose($stream); + throw new RuntimeException('Datei ließ sich nicht schreiben: '.$path); + } + stream_copy_to_stream($stream, $out); + fclose($stream); + fclose($out); + ++$written; + } + } finally { + $zip->close(); + } + + return $written; +} + +/** Die Vorprüfung: alles, was fehlschlagen kann, bevor irgendetwas geschrieben wird. */ +function kant_checks(string $target): array +{ + $checks = []; + $add = static function (string $label, bool $ok, string $hint) use (&$checks): void { + $checks[] = ['label' => $label, 'ok' => $ok, 'hint' => $hint]; + }; + $add('PHP-Fassung', PHP_VERSION_ID >= 80200, 'Gefunden: '.PHP_VERSION.'. Gebraucht wird mindestens 8.2 – beim Hoster umstellen.'); + $add('Schreibrecht im Zielverzeichnis', is_writable($target), $target.' ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen.'); + $add('ZIP-Erweiterung', class_exists('ZipArchive'), 'Ohne die Erweiterung zip lässt sich das Archiv nicht entpacken.'); + $add('Verbindung nach außen', function_exists('curl_init') || filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL), 'Weder curl noch allow_url_fopen ist verfügbar; dann bleibt nur der Weg über FTP.'); + $add('Adresse und Prüfsumme eingetragen', '' !== KANT_ARCHIVE_URL && 64 === strlen(KANT_ARCHIVE_SHA256), 'Diese Datei ist unfertig: sie stammt nicht aus einem Release. Lade install.php von der Releaseseite herunter.'); + $free = @disk_free_space($target); + $add('Freier Platz', false === $free || $free > 80 * 1024 * 1024, 'Es sind weniger als 80 MB frei. Das Archiv braucht Platz zum Herunterladen und zum Entpacken.'); + $add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.'); + + return $checks; +} + +/** Der Ablauf, wenn der Knopf gedrückt wurde. Gibt [Erfolg, Meldung] zurück. */ +function kant_run(string $target): array +{ + $archive = $target.'/kantsites-download-'.bin2hex(random_bytes(4)).'.zip'; + try { + $error = kant_download(KANT_ARCHIVE_URL, $archive); + if (null !== $error) { + return [false, $error]; + } + $hash = hash_file('sha256', $archive); + if (!hash_equals(KANT_ARCHIVE_SHA256, (string) $hash)) { + // Nicht entpacken, nicht behalten: eine Datei, die nicht das ist, was sie sein soll, + // hat auf dem Server nichts verloren. + return [false, 'Die Prüfsumme des heruntergeladenen Archivs stimmt nicht. Erwartet: '.KANT_ARCHIVE_SHA256.', erhalten: '.$hash.'. Es wurde nichts entpackt.']; + } + $files = kant_unpack($archive, $target); + + return [true, 'Entpackt: '.$files.' Dateien.']; + } catch (Throwable $e) { + return [false, $e->getMessage()]; + } finally { + @unlink($archive); + } +} + +// Im Test wird diese Datei eingebunden, um die Pfadprüfung und das Entpacken zu prüfen, ohne dass +// etwas heruntergeladen wird. +if ('cli' === PHP_SAPI) { + return; +} + +$target = __DIR__; +$checks = kant_checks($target); +$ready = [] === array_filter($checks, static fn (array $check): bool => !$check['ok']); +$done = false; +$message = null; +if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') && $ready) { + @set_time_limit(0); + [$done, $message] = kant_run($target); + if ($done) { + // Erst jetzt: die Datei hat ihre Arbeit getan und ist ab hier nur noch ein offener Weg, + // Code auf den Server zu legen. + @unlink(__FILE__); + } +} + +$e = static fn (?string $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +header('Content-Type: text/html; charset=UTF-8'); +header('X-Robots-Tag: noindex, nofollow'); +?> + + + + + + +KantSites installieren + + + +
+ +

Die Dateien liegen.

+

Diese Datei hat sich selbst gelöscht. Weiter geht es mit der Einrichtung: Datenbank, Konto, Website.

+

+

Zur Einrichtung →

+

Führt der Verweis ins Leere, zeigt die Domain nicht auf public/. Das muss beim Hoster eingestellt werden – ohne das wären auch .env und var/ über das Netz erreichbar.

+ +

KantSites installieren

+

Diese Datei lädt Fassung herunter, prüft die Prüfsumme und entpackt sie hierher:

+

+ +
+ +
+

Das dauert je nach Anbindung eine halbe bis mehrere Minuten. Adresse und Prüfsumme stehen fest in dieser Datei; ein Archiv mit abweichender Prüfsumme wird nicht entpackt.

+ +
+ + diff --git a/src/Content/DemoContent.php b/src/Content/DemoContent.php index a4edccd..f79ce96 100644 --- a/src/Content/DemoContent.php +++ b/src/Content/DemoContent.php @@ -129,6 +129,41 @@ final class DemoContent * * @return array */ + /** + * The start page of a fresh installation - not demo content. + * + * Short on purpose. Somebody who has just installed should find a page they can edit, not a + * brochure about the CMS they have to delete first. Exactly one H1, as every page requires. + */ + public static function starterPage(string $siteName): array + { + return [ + 'schemaVersion' => 16, + 'title' => $siteName, + 'description' => 'NOCH AUSZUFÜLLEN: ein Satz darüber, was es hier gibt. Er erscheint in Suchergebnissen.', + 'blocks' => [ + self::section('start-hero', [ + self::heading('start-heading', $siteName, 1), + self::text('start-intro', 'Diese Seite ist der Anfang. Melde dich an, öffne sie im Editor und ersetze diesen Text durch deinen eigenen.'), + ], 'mint'), + self::section('start-body', [ + self::heading('start-next-heading', 'Die nächsten Schritte', 2), + self::text('start-next', "Impressum und Datenschutzerklärung sind bereits angelegt und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.\n\nUnter „Design“ legst du Farben, Schriften und die Breite fest, unter „Menüs“ die Navigation, unter „Einstellungen“ den Namen und die Angaben zum Unternehmen."), + ], 'paper', 'narrow'), + ], + ]; + } + + /** Header and footer of a fresh installation, with the site name instead of a wordmark. */ + public static function starterAreas(string $siteName): array + { + $areas = self::areas(); + $areas['header']['document']['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = $siteName; + $areas['footer']['document']['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = '© '.date('Y').' '.$siteName; + + return $areas; + } + public static function areas(): array { $row = static fn (string $id, array $children): array => [ diff --git a/src/Controller/InstallController.php b/src/Controller/InstallController.php new file mode 100644 index 0000000..3247278 --- /dev/null +++ b/src/Controller/InstallController.php @@ -0,0 +1,208 @@ +guard(); + $checks = $this->installation->checks(); + + return $this->render('install/check.html.twig', [ + 'step' => 1, + 'checks' => $checks, + 'ready' => $this->installation->ready($checks), + ]); + } + + #[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)] + public function database(Request $request): Response + { + $this->guard(); + $error = null; + $driver = $request->request->getString('driver', 'sqlite'); + if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) { + $input = [ + 'host' => $request->request->getString('host', '127.0.0.1'), + 'port' => $request->request->getString('port', '3306'), + 'name' => $request->request->getString('name', 'cms'), + 'user' => $request->request->getString('user'), + 'password' => $request->request->getString('password'), + ]; + $result = $this->installation->testDatabase('mysql' === $driver ? 'mysql' : 'sqlite', $input); + $error = $result['error']; + if (null === $error) { + try { + $this->installation->writeEnvironment(['APP_ENV' => 'prod', 'APP_DEBUG' => '0', 'DATABASE_URL' => $result['url']]); + + // The new connection only exists in the next request, because the environment + // file is read while booting. That is also why this is a redirect and not a + // rendered page: the migration screen has to talk to the new database. + return $this->redirectToRoute('install_migration'); + } catch (\RuntimeException $e) { + $error = $e->getMessage(); + } + } + } + + return $this->render('install/database.html.twig', [ + 'step' => 2, + 'driver' => $driver, + 'error' => $error, + 'values' => $request->request->all(), + 'mysql' => \extension_loaded('pdo_mysql'), + 'sqlite' => \extension_loaded('pdo_sqlite'), + ...$this->token('datenbank'), + ]); + } + + #[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)] + public function migration(Request $request): Response + { + $this->guard(); + if (!$this->installation->environmentWritten()) { + return $this->redirectToRoute('install_database'); + } + $error = null; + $applied = null; + if ($request->isMethod('POST') && $this->signed($request, 'migration')) { + @set_time_limit(0); + try { + $applied = $this->installation->migrate(); + + return $this->redirectToRoute('install_website'); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + return $this->render('install/migration.html.twig', [ + 'step' => 3, + 'pending' => $error ? [] : $this->pending(), + 'applied' => $applied, + 'error' => $error, + ...$this->token('migration'), + ]); + } + + #[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)] + public function website(Request $request): Response + { + $this->guard(); + if (!$this->installation->schemaReady()) { + return $this->redirectToRoute('install_migration'); + } + $error = null; + if ($request->isMethod('POST') && $this->signed($request, 'website')) { + try { + $this->installation->createSite( + $request->request->getString('name'), + $request->request->getString('origin'), + $request->request->getString('locale', 'de'), + ); + + return $this->redirectToRoute('install_account'); + } catch (InvalidPage|\RuntimeException $e) { + $error = $e->getMessage(); + } + } + + return $this->render('install/website.html.twig', [ + 'step' => 4, + 'error' => $error, + 'values' => $request->request->all(), + // The address the browser is on is almost always the right one, so it is prefilled. + 'origin' => $request->getSchemeAndHttpHost(), + ...$this->token('website'), + ]); + } + + #[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)] + public function account(Request $request): Response + { + $this->guard(); + if (!$this->installation->siteReady()) { + return $this->redirectToRoute('install_website'); + } + $error = null; + if ($request->isMethod('POST') && $this->signed($request, 'konto')) { + try { + $this->installation->createAdmin( + $request->request->getString('username'), + $request->request->getString('password'), + $request->request->getString('repeat'), + ); + + // The last screen is rendered here, not after a redirect: one request later this + // route is gone, because the account it just created ends the installation. + return $this->render('install/done.html.twig', [ + 'step' => 6, + 'username' => strtolower(trim($request->request->getString('username'))), + ]); + } catch (InvalidPage $e) { + $error = $e->getMessage(); + } + } + + return $this->render('install/account.html.twig', [ + 'step' => 5, + 'error' => $error, + 'username' => $request->request->getString('username'), + ...$this->token('konto'), + ]); + } + + /** Reading the pending list must never be the reason the screen cannot be shown. */ + private function pending(): array + { + try { + return $this->installation->pendingMigrations(); + } catch (\Throwable) { + return []; + } + } + + private function guard(): void + { + if ($this->installation->isInstalled()) { + throw new NotFoundHttpException('Diese Installation ist abgeschlossen.'); + } + } + + private function token(string $step): array + { + $issued = time(); + + return ['issued' => $issued, 'signature' => $this->installation->sign($step, $issued)]; + } + + private function signed(Request $request, string $step): bool + { + return $this->installation->verify($step, $request->request->getInt('issued'), $request->request->getString('signature')); + } +} diff --git a/src/EventSubscriber/InstallSubscriber.php b/src/EventSubscriber/InstallSubscriber.php new file mode 100644 index 0000000..c8d09da --- /dev/null +++ b/src/EventSubscriber/InstallSubscriber.php @@ -0,0 +1,53 @@ + ['onRequest', 36]]; + } + + public function onRequest(RequestEvent $event): void + { + if (!$event->isMainRequest() || is_file($this->projectDir.'/'.self::MARKER)) { + return; + } + if ($this->installation->isInstalled()) { + @file_put_contents($this->projectDir.'/'.self::MARKER, gmdate('c')."\n"); + + return; + } + $path = $event->getRequest()->getPathInfo(); + if (str_starts_with($path, '/install') || preg_match('#^/(assets/|favicon)#', $path)) { + return; + } + $event->setResponse(new RedirectResponse('/install')); + } +} diff --git a/src/Ops/Installation.php b/src/Ops/Installation.php new file mode 100644 index 0000000..0eaf07e --- /dev/null +++ b/src/Ops/Installation.php @@ -0,0 +1,419 @@ +db->fetchOne('SELECT id FROM cms_user LIMIT 1'); + } catch (\Throwable) { + // No database, no table, no installation - all three mean the same here. + return false; + } + } + + /** + * The system check, as a list of findings rather than a yes or no. + * + * Every failing check names the file or path that has to change. A check that is merely + * advisable is marked as such, so a missing `proc_open` does not look like a reason to stop. + */ + public function checks(): array + { + $checks = []; + $required = PHP_VERSION_ID >= 80200; + $checks[] = $this->check('PHP-Fassung', $required, sprintf('Gefunden: %s. Gebraucht wird mindestens 8.2 – beim Hoster umstellen, meistens im Kundenmenü unter „PHP-Version“.', PHP_VERSION), true, PHP_VERSION); + + foreach (['pdo' => 'Datenbankzugriff', 'ctype' => 'Zeichenprüfungen', 'iconv' => 'Zeichensätze', 'mbstring' => 'Mehrbytetexte', 'json' => 'Dokumente', 'dom' => 'HTML-Verarbeitung', 'session' => 'Anmeldung'] as $extension => $purpose) { + $checks[] = $this->check('Erweiterung '.$extension, extension_loaded($extension), sprintf('%s braucht diese Erweiterung. Beim Hoster aktivieren oder in der php.ini eintragen.', $purpose)); + } + $drivers = array_values(array_intersect(['pdo_sqlite', 'pdo_mysql'], get_loaded_extensions())); + $checks[] = $this->check('Datenbanktreiber', [] !== $drivers, 'Weder pdo_sqlite noch pdo_mysql ist vorhanden. Ohne einen von beiden gibt es keine Datenbank.', true, implode(', ', $drivers)); + + $webp = extension_loaded('gd') && (gd_info()['WebP Support'] ?? false); + $checks[] = $this->check('GD mit WebP', $webp, 'Bilder werden beim Hochladen in WebP umgewandelt. Ohne GD mit WebP-Unterstützung schlägt jeder Upload fehl. Der Rest der Website funktioniert.', false); + $checks[] = $this->check('ZIP-Erweiterung', class_exists(\ZipArchive::class), 'Wird für Sicherungen als Archiv gebraucht. Ohne sie bleibt die Sicherung ein Verzeichnis.', false); + $checks[] = $this->check('proc_open', \function_exists('proc_open') && !\in_array('proc_open', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true), 'Ohne proc_open kann das Update im Browser nicht laufen. Ein Update über das Releasearchiv geht trotzdem.', false); + + foreach ([$this->root => 'Projektstamm (für .env.local)', $this->root.'/var' => 'var/ (Datenbank, Cache, Sicherungen)', $this->mediaDirectory => 'Medienordner'] as $path => $label) { + $checks[] = $this->check('Schreibrecht: '.$label, $this->writable($path), sprintf('%s ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen (oder 0755, wenn PHP als Eigentümer läuft).', $path), true, $path); + } + + // The one check that is about the web server rather than PHP: if the document root is the + // project directory, then var/ and .env are reachable over the network. + $documentRoot = realpath((string) ($_SERVER['DOCUMENT_ROOT'] ?? '')); + $public = realpath($this->root.'/public'); + $checks[] = $this->check( + 'Dokumentenstamm zeigt auf public/', + false !== $documentRoot && false !== $public && str_replace('\\', '/', $documentRoot) === str_replace('\\', '/', $public), + sprintf('Der Dokumentenstamm ist %s, erwartet wird %s. Sonst sind .env und var/ über das Netz erreichbar. Beim Hoster die Domain auf public/ zeigen lassen.', $documentRoot ?: 'unbekannt', $public ?: $this->root.'/public'), + false, + $documentRoot ?: null, + ); + $checks[] = $this->check('Geheimnis (APP_SECRET)', '' !== $this->secret && !str_contains($this->secret, 'nicht-gespeichert'), 'Das Geheimnis konnte nicht in .env.local geschrieben werden. Ohne Schreibrecht im Projektstamm bleibt jede Anmeldung nach einem Neustart ungültig.'); + + return $checks; + } + + /** True when every check that blocks an installation passed. */ + public function ready(array $checks): bool + { + return [] === array_filter($checks, static fn (array $check): bool => $check['required'] && !$check['ok']); + } + + public function environmentWritten(): bool + { + return is_file($this->root.'/.env.local') && str_contains((string) file_get_contents($this->root.'/.env.local'), 'DATABASE_URL='); + } + + public function schemaReady(): bool + { + try { + $this->db->executeQuery('SELECT 1 FROM cms_user LIMIT 1'); + + return true; + } catch (\Throwable) { + return false; + } + } + + public function siteReady(): bool + { + try { + return (bool) $this->db->fetchOne('SELECT id FROM website WHERE id = ?', ['default']); + } catch (\Throwable) { + return false; + } + } + + /** + * Builds a DSN from what the form asked and proves it works before it is written anywhere. + * + * Returns the error in plain German, or null when the connection stands. A wrong password has + * to show up here and not two screens later, when the file is already written. + */ + public function testDatabase(string $driver, array $input): array + { + $url = 'sqlite' === $driver + ? 'sqlite:///%kernel.project_dir%/var/'.$this->sqliteName($input['name'] ?? 'cms') + : sprintf( + 'mysql://%s:%s@%s:%d/%s?charset=utf8mb4', + rawurlencode(trim((string) ($input['user'] ?? ''))), + rawurlencode((string) ($input['password'] ?? '')), + trim((string) ($input['host'] ?? '127.0.0.1')), + (int) ($input['port'] ?? 3306) ?: 3306, + rawurlencode(trim((string) ($input['name'] ?? ''))), + ); + + try { + $connection = ConnectionFactory::create($this->resolve($url)); + $connection->executeQuery('SELECT 1'); + $connection->close(); + } catch (\Throwable $e) { + return ['url' => $url, 'error' => 'mysql' === $driver + ? sprintf('Die Datenbank antwortet nicht: %s. Zugangsdaten stehen im Kundenmenü des Hosters; oft ist der Host nicht localhost, sondern ein eigener Name.', $e->getMessage()) + : sprintf('Die SQLite-Datei ließ sich nicht anlegen: %s. Meistens fehlt das Schreibrecht auf var/.', $e->getMessage())]; + } + + return ['url' => $url, 'error' => null]; + } + + /** + * Writes `.env.local`, never `.env`. + * + * `.env` belongs to the release and is replaced by the next update; `.env.local` belongs to + * this installation and survives. Values that are already there stay unless they are given + * again, so the website step can add the address without losing the database. + */ + public function writeEnvironment(array $values): void + { + $file = $this->root.'/.env.local'; + $lines = []; + foreach (is_file($file) ? (preg_split('/\R/', (string) file_get_contents($file)) ?: []) : [] as $line) { + if ('' !== trim($line) && 1 === preg_match('/^([A-Z_]+)=/', $line, $match) && !\array_key_exists($match[1], $values)) { + $lines[$match[1]] = $line; + } + } + foreach ($values as $key => $value) { + $lines[$key] = $key.'="'.str_replace(['\\', '"', "\n"], ['\\\\', '\"', ''], (string) $value).'"'; + } + $content = "# Diese Datei gehört zu dieser Installation und wird von keinem Update überschrieben.\n".implode("\n", $lines)."\n"; + $temporary = $file.'.'.bin2hex(random_bytes(4)); + if (false === file_put_contents($temporary, $content) || !rename($temporary, $file)) { + @unlink($temporary); + throw new \RuntimeException('.env.local ließ sich nicht schreiben. Bitte das Schreibrecht im Projektstamm prüfen.'); + } + @chmod($file, 0o600); + } + + /** + * Runs the migrations in this process. + * + * Not over the console: on shared hosting there is often no shell, and `proc_open` is a common + * casualty of a hardened php.ini. The installer therefore does what the console command does, + * with the same configuration file, and returns the versions it applied. + */ + public function migrate(): array + { + $factory = DependencyFactory::fromConnection( + new PhpFile($this->root.'/migrations.php'), + new ExistingConnection($this->db), + ); + $factory->getMetadataStorage()->ensureInitialized(); + $version = $factory->getVersionAliasResolver()->resolveVersionAlias('latest'); + $plan = $factory->getMigrationPlanCalculator()->getPlanUntilVersion($version); + $configuration = (new MigratorConfiguration())->setAllOrNothing(false)->setTimeAllQueries(false); + $factory->getMigrator()->migrate($plan, $configuration); + + return $this->planNames($plan); + } + + /** Which migrations would run - the same list the migration screen shows before the button. */ + public function pendingMigrations(): array + { + $factory = DependencyFactory::fromConnection( + new PhpFile($this->root.'/migrations.php'), + new ExistingConnection($this->db), + ); + $factory->getMetadataStorage()->ensureInitialized(); + + return $this->planNames($factory->getMigrationPlanCalculator()->getPlanUntilVersion( + $factory->getVersionAliasResolver()->resolveVersionAlias('latest'), + )); + } + + /** + * The website: name, address, language - and the content a new site cannot do without. + * + * A start page, a header and a footer with a menu, and the two legal pages as a skeleton with + * every gap marked. The last part is not a courtesy: a German business website without an + * imprint and a privacy statement is a problem from its first day, and the installer is the + * only moment at which somebody is certainly looking. + */ + public function createSite(string $name, string $origin, string $locale): array + { + $name = trim($name); + if ('' === $name || mb_strlen($name) > 120) { + throw new InvalidPage('Bitte gib einen Namen für die Website an (höchstens 120 Zeichen).'); + } + $origin = rtrim(trim($origin), '/'); + if (!str_starts_with($origin, 'https://') && !str_starts_with($origin, 'http://')) { + throw new InvalidPage('Die Adresse beginnt mit https:// – zum Beispiel https://meine-firma.de'); + } + if (false === filter_var($origin, FILTER_VALIDATE_URL)) { + throw new InvalidPage('Diese Adresse ist keine gültige URL.'); + } + if (!\in_array($locale, ['de', 'en'], true)) { + throw new InvalidPage('Als Sprache stehen Deutsch und Englisch zur Wahl.'); + } + + $created = []; + $this->db->transactional(function () use ($name, $locale, &$created): void { + if (!$this->db->fetchOne('SELECT id FROM website WHERE id = ?', ['default'])) { + $this->db->insert('website', [ + 'id' => 'default', + 'name' => mb_substr($name, 0, 120), + 'default_locale' => $locale, + 'theme' => json_encode(['template' => 'kant-starter', 'version' => 1], JSON_THROW_ON_ERROR), + ]); + } else { + $this->db->update('website', ['name' => mb_substr($name, 0, 120), 'default_locale' => $locale], ['id' => 'default']); + } + + if (null === $this->pages->findPublished('home', $locale)) { + $id = $this->pages->createPage('home', null, 'page', DemoContent::starterPage($name), $locale); + $page = $this->pages->findForEditor($id); + $this->pages->publish($id, $page['draft_revision_id'], $page['version']); + $created[] = 'Startseite'; + } + foreach (DemoContent::LEGAL as $slug => $label) { + if (null === $this->pages->findPublished($slug, $locale)) { + $id = $this->pages->createPage($slug, null, 'page', DemoContent::legalPage($slug), $locale); + $page = $this->pages->findForEditor($id); + $this->pages->publish($id, $page['draft_revision_id'], $page['version']); + $created[] = $label; + } + } + foreach (DemoContent::starterAreas($name) as $kind => $area) { + if (null === $this->partials->findByKind($kind, 'default', $locale)) { + $this->partials->create($kind, 'default', $area['name'], $area['document'], $locale); + $created[] = $area['name']; + } + } + $this->createMenu($locale, $created); + }); + + $this->writeEnvironment(['SITE_ORIGIN' => $origin]); + $settings = $this->settings->load(); + $this->settings->save([ + ...$settings, + 'legalName' => $name, + 'privacyPath' => 'datenschutz', + 'imprintPath' => 'impressum', + 'sameAs' => '', + 'consentGroups' => $settings['consentGroups'], + ]); + + return $created; + } + + /** + * The first account, and with it the end of the installer. + * + * The rules are the ones the user administration uses, because an account created here is not + * a lesser account - it is the one that can do everything. + */ + public function createAdmin(string $username, string $password, string $repeated): void + { + $username = strtolower(trim($username)); + if (1 !== preg_match('/^[a-z0-9][a-z0-9._-]{2,99}$/D', $username)) { + throw new InvalidPage('Der Benutzername braucht 3–100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich oder Bindestrich.'); + } + if (\strlen($password) < 14 || \strlen($password) > 72 || str_contains($password, "\0")) { + throw new InvalidPage('Das Passwort braucht mindestens 14 Zeichen (höchstens 72 Bytes).'); + } + if (!hash_equals($password, $repeated)) { + throw new InvalidPage('Die beiden Passwörter stimmen nicht überein.'); + } + if ($this->isInstalled()) { + throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.'); + } + $user = new CmsUser(bin2hex(random_bytes(16)), $username, '', ['ROLE_ADMIN']); + $this->db->insert('cms_user', [ + 'id' => $user->id, + 'username' => $username, + 'password_hash' => $this->hasher->hashPassword($user, $password), + 'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR), + ]); + } + + /** + * A signature instead of a session token. + * + * The session cookie of this application is scoped to `/admin`, so the installer has no + * session - and it should not get one for six screens. The signature ties a form to a step and + * to a point in time. It is not protection against somebody who can reach the installer: that + * protection is that the installer stops existing after the first account, which is why the + * last screen says to install right after uploading. + */ + public function sign(string $step, int $issued): string + { + return hash_hmac('sha256', $step.'|'.$issued, $this->secret); + } + + public function verify(string $step, int $issued, string $signature): bool + { + return abs(time() - $issued) <= self::TOKEN_LIFETIME && hash_equals($this->sign($step, $issued), $signature); + } + + private function createMenu(string $locale, array &$created): void + { + if ([] !== $this->menus->all($locale)) { + return; + } + $items = []; + foreach ($this->db->fetchAllAssociative('SELECT id, path FROM page WHERE website_id = ? AND locale = ?', ['default', $locale]) as $page) { + if (\in_array($page['path'], ['home', 'datenschutz', 'impressum'], true)) { + $items['home' === $page['path'] ? 'header' : 'footer'][] = [ + 'label' => ['home' => 'Start', 'datenschutz' => 'Datenschutz', 'impressum' => 'Impressum'][$page['path']], + 'type' => 'page', + 'pageId' => $page['id'], + 'anchor' => '', + 'children' => [], + ]; + } + } + foreach (['header', 'footer'] as $slot) { + if (isset($items[$slot])) { + $this->menus->save($slot, MenuRepository::SLOTS[$slot], $items[$slot], 0, $locale); + $created[] = 'header' === $slot ? 'Hauptmenü' : 'Fußmenü'; + } + } + } + + private function planNames(MigrationPlanList $plan): array + { + $names = []; + foreach ($plan->getItems() as $item) { + $names[] = (string) $item->getVersion(); + } + + return $names; + } + + private function check(string $label, bool $ok, string $hint, bool $required = true, ?string $found = null): array + { + return ['label' => $label, 'ok' => $ok, 'required' => $required, 'hint' => $hint, 'found' => $found]; + } + + /** A directory that does not exist yet counts as writable when its parent is. */ + private function writable(string $path): bool + { + return is_dir($path) ? is_writable($path) : (is_dir(\dirname($path)) && is_writable(\dirname($path))); + } + + private function sqliteName(string $name): string + { + $name = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($name))) ?: 'cms'; + + return mb_substr($name, 0, 40).'.db'; + } + + private function resolve(string $url): string + { + return str_replace('%kernel.project_dir%', str_replace('\\', '/', $this->root), $url); + } +} diff --git a/templates/install/account.html.twig b/templates/install/account.html.twig new file mode 100644 index 0000000..5fa3502 --- /dev/null +++ b/templates/install/account.html.twig @@ -0,0 +1,25 @@ +{% extends 'install/base.html.twig' %} +{% block heading %}Dein Zugang{% endblock %} +{% block lead %} +

Das erste Konto ist ein Administratorkonto: es darf alles, auch weitere Konten anlegen. Mit ihm endet die Installation – danach antwortet /install nicht mehr.

+{% endblock %} +{% block content %} +
+ + + + + +

3–100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich.

+ + + + + +

Mindestens 14 Zeichen. Länge hilft mehr als Sonderzeichen – drei, vier zufällige Wörter sind ein gutes Passwort. Es wird nirgends angezeigt und lässt sich nicht wiederherstellen, nur ersetzen.

+ +
+ +
+
+{% endblock %} diff --git a/templates/install/base.html.twig b/templates/install/base.html.twig new file mode 100644 index 0000000..ed0a62f --- /dev/null +++ b/templates/install/base.html.twig @@ -0,0 +1,25 @@ +{% extends 'admin/base.html.twig' %} +{% set steps = ['Systemprüfung', 'Datenbank', 'Migration', 'Website', 'Konto', 'Fertig'] %} +{% block title %}{{ steps[step - 1] }} · Installation{% endblock %} +{% block body_class %}install-body{% endblock %} +{% block body %} +
+

KANTSITES INSTALLIEREN

+
    + {% for index, name in steps %} +
  1. + + {{ name }}{% if index + 1 == step %} – aktueller Schritt{% endif %} +
  2. + {% endfor %} +
+
+

{% block heading %}{% endblock %}

+
+ {% block lead %}{% endblock %} + {% if error is defined and error %}{% endif %} + {% block content %}{% endblock %} +
+
+
+{% endblock %} diff --git a/templates/install/check.html.twig b/templates/install/check.html.twig new file mode 100644 index 0000000..b9b356d --- /dev/null +++ b/templates/install/check.html.twig @@ -0,0 +1,28 @@ +{% extends 'install/base.html.twig' %} +{% block heading %}Passt dieser Server?{% endblock %} +{% block lead %} +

Geprüft wird, was diese Installation tatsächlich braucht. Jede fehlgeschlagene Prüfung sagt, was zu tun ist – und wo. Punkte ohne Sternchen sind Empfehlungen: ohne sie fehlt eine Funktion, nicht die Website.

+{% endblock %} +{% block content %} + + +
+ {% if ready %} + Weiter zur Datenbank + {% else %} + Erneut prüfen + {% endif %} +
+{% if not ready %}

Solange eine Pflichtprüfung fehlschlägt, geht es nicht weiter. Das ist Absicht: eine Installation, die auf halbem Weg scheitert, hinterlässt mehr Arbeit als eine, die gar nicht erst beginnt.

{% endif %} +{% endblock %} diff --git a/templates/install/database.html.twig b/templates/install/database.html.twig new file mode 100644 index 0000000..90549c4 --- /dev/null +++ b/templates/install/database.html.twig @@ -0,0 +1,43 @@ +{% extends 'install/base.html.twig' %} +{% block heading %}Wohin mit den Inhalten?{% endblock %} +{% block lead %} +

SQLite ist eine Datei auf diesem Server – nichts einzurichten, gut für eine Website mit überschaubarem Verkehr. MySQL brauchst du, wenn der Hoster es vorgibt oder mehrere Websites dieselbe Datenbank nutzen sollen. Die Verbindung wird sofort geprüft, nicht erst beim Speichern.

+{% endblock %} +{% block content %} +
+ + + +
+ Art der Datenbank + + +
+ + + + +
+ Zugangsdaten für MySQL + + + + + + + + +

Diese Angaben landen in .env.local im Projektstamm – nicht in der Datenbank und in keinem Protokoll. Die Datei liegt außerhalb von public/ und ist über das Netz nicht erreichbar.

+
+ +
+ +
+
+{% endblock %} diff --git a/templates/install/done.html.twig b/templates/install/done.html.twig new file mode 100644 index 0000000..627ad75 --- /dev/null +++ b/templates/install/done.html.twig @@ -0,0 +1,20 @@ +{% extends 'install/base.html.twig' %} +{% block heading %}Fertig.{% endblock %} +{% block lead %} +

Die Installation ist abgeschlossen und /install antwortet ab sofort mit 404. Melde dich als {{ username }} an.

+{% endblock %} +{% block content %} +
+ Zur Anmeldung + Website ansehen +
+ +

Was jetzt noch fehlt

+ +{% endblock %} diff --git a/templates/install/migration.html.twig b/templates/install/migration.html.twig new file mode 100644 index 0000000..bdb751b --- /dev/null +++ b/templates/install/migration.html.twig @@ -0,0 +1,26 @@ +{% extends 'install/base.html.twig' %} +{% block heading %}Die Tabellen anlegen{% endblock %} +{% block lead %} +

Die Verbindung steht. Jetzt werden die Migrationen ausgeführt – dieselben, die später ein Update einspielt, und in derselben Reihenfolge. Das läuft in diesem Prozess, ohne Konsole.

+{% endblock %} +{% block content %} +{% if pending is not empty %} +

{{ pending|length }} Migration{{ pending|length == 1 ? '' : 'en' }} stehen an:

+ +{% else %} +

Es steht nichts an – entweder ist die Datenbank schon eingerichtet, oder die Liste ließ sich nicht lesen. Der Knopf unten schadet in beiden Fällen nicht: eine bereits angewandte Migration wird nicht wiederholt.

+{% endif %} + +
+ + +
+ +
+
+

Dauert das zu lange und bricht der Browser ab, ist das kein Schaden: angewandte Migrationen sind vermerkt, ein erneuter Aufruf macht dort weiter.

+{% endblock %} diff --git a/templates/install/website.html.twig b/templates/install/website.html.twig new file mode 100644 index 0000000..89c42d7 --- /dev/null +++ b/templates/install/website.html.twig @@ -0,0 +1,28 @@ +{% extends 'install/base.html.twig' %} +{% block heading %}Deine Website{% endblock %} +{% block lead %} +

Damit entstehen eine Startseite, Kopf- und Fußbereich mit Menü sowie Impressum und Datenschutzerklärung als Gerüst. Die beiden rechtlichen Seiten sind keine Option – ihr Text ist eine Erklärung über dein Unternehmen, deshalb legt der Installer die Struktur an und markiert jede Stelle, die noch auszufüllen ist.

+{% endblock %} +{% block content %} +
+ + + + + + + + +

Vollständig, mit https://. Sie steht in Sitemap, Feed und strukturierten Daten – eine falsche Adresse dort fällt erst Wochen später auf.

+ + + + +
+ +
+
+{% endblock %} diff --git a/tests/InstallFlowTest.php b/tests/InstallFlowTest.php new file mode 100644 index 0000000..cb4bd70 --- /dev/null +++ b/tests/InstallFlowTest.php @@ -0,0 +1,219 @@ +root = sys_get_temp_dir().'/kant-flow-'.bin2hex(random_bytes(6)); + foreach (['config', 'templates', 'var', 'public'] as $directory) { + mkdir($this->root.'/'.$directory, 0o775, true); + } + // src/ is copied as well, because the service definitions scan that directory. The + // classes themselves still come from the autoloader, so this is only a list of files. + foreach (['config', 'templates', 'src'] as $directory) { + self::copyDirectory(\dirname(__DIR__).'/'.$directory, $this->root.'/'.$directory); + } + // The migrations are not copied but pointed at: a second copy of the same classes in one + // process is a fatal error, and a copied migration would age away from the original. + file_put_contents($this->root.'/migrations.php', sprintf( + " ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => %s], 'all_or_nothing' => false];", + var_export(\dirname(__DIR__).'/migrations', true), + )); + $this->database = $this->root.'/var/cms.db'; + } + + protected function tearDown(): void + { + gc_collect_cycles(); + self::removeDirectory($this->root); + } + + public function testAnEmptyInstallationBecomesAWebsiteInSixSteps(): void + { + // 0. Whatever is asked for, the answer is the installer. A missing table is not something + // to explain with a stack trace. + $response = $this->request('/'); + self::assertSame(302, $response->getStatusCode()); + self::assertSame('/install', $response->headers->get('Location')); + + // 1. The system check. + $check = $this->request('/install'); + self::assertSame(200, $check->getStatusCode()); + self::assertStringContainsString('Passt dieser Server?', $check->getContent()); + + // 2. The database. SQLite needs nothing but a writable var/. + $database = $this->request('/install/datenbank'); + $response = $this->submit('/install/datenbank', $database, ['driver' => 'sqlite', 'name' => 'cms']); + self::assertSame('/install/migration', $response->headers->get('Location')); + $environment = (string) file_get_contents($this->root.'/.env.local'); + self::assertStringContainsString('DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"', $environment); + self::assertStringContainsString('APP_ENV="prod"', $environment); + + // 3. The migrations, in this process, without a console. + $migration = $this->request('/install/migration'); + self::assertStringContainsString('Migration', $migration->getContent()); + $response = $this->submit('/install/migration', $migration, []); + self::assertSame('/install/website', $response->headers->get('Location')); + self::assertNotEmpty($this->connection()->fetchAllAssociative('SELECT version FROM migration_versions')); + + // 4. The website, and with it the content a site cannot go live without. + $website = $this->request('/install/website'); + $response = $this->submit('/install/website', $website, ['name' => 'Beispiel GmbH', 'origin' => 'https://beispiel.de', 'locale' => 'de']); + self::assertSame('/install/konto', $response->headers->get('Location')); + self::assertSame(3, (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM page'), 'Startseite, Impressum, Datenschutz.'); + + // 5. The first account - and the last screen, rendered straight away, because one request + // later this route no longer exists. + $account = $this->request('/install/konto'); + $done = $this->submit('/install/konto', $account, ['username' => 'chefin', 'password' => 'ein sehr langes passwort', 'repeat' => 'ein sehr langes passwort']); + self::assertSame(200, $done->getStatusCode()); + self::assertStringContainsString('Fertig.', $done->getContent()); + self::assertStringContainsString('Impressum und Datenschutzerklärung ausfüllen', $done->getContent()); + + // 6. The installer is over, and the website answers. + self::assertSame(404, $this->request('/install')->getStatusCode()); + self::assertSame(404, $this->request('/install/konto')->getStatusCode()); + $home = $this->request('/'); + self::assertSame(200, $home->getStatusCode()); + self::assertStringContainsString('Beispiel GmbH', $home->getContent()); + self::assertStringContainsString('Diese Seite ist der Anfang.', $home->getContent()); + // The public page is still a public page: no script, and the header carries the name. + self::assertStringContainsString("script-src 'none'", (string) $home->headers->get('Content-Security-Policy')); + self::assertSame(200, $this->request('/impressum')->getStatusCode()); + self::assertSame(200, $this->request('/admin/login')->getStatusCode()); + } + + public function testAStepWithoutItsRequirementSendsYouBack(): void + { + // Somebody who guesses the address of a later step does not get past the missing database. + self::assertSame('/install/datenbank', $this->request('/install/migration')->headers->get('Location')); + self::assertSame('/install/migration', $this->request('/install/website')->headers->get('Location')); + self::assertSame('/install/website', $this->request('/install/konto')->headers->get('Location')); + + // And a form without a valid signature changes nothing: the page comes back, unchanged. + $response = $this->request('/install/datenbank', 'POST', ['driver' => 'sqlite', 'name' => 'cms', 'issued' => time(), 'signature' => 'erfunden']); + self::assertSame(200, $response->getStatusCode()); + self::assertFileDoesNotExist($this->root.'/.env.local'); + } + + /** Posts a form with the signature the rendered page carries, the way a browser would. */ + private function submit(string $url, Response $page, array $fields): Response + { + preg_match('/name="issued" value="(\d+)"/', (string) $page->getContent(), $issued); + preg_match('/name="signature" value="([a-f0-9]{64})"/', (string) $page->getContent(), $signature); + self::assertNotEmpty($issued, 'Das Formular trägt einen Zeitpunkt.'); + self::assertNotEmpty($signature, 'Das Formular trägt eine Unterschrift.'); + + return $this->request($url, 'POST', [...$fields, 'issued' => $issued[1], 'signature' => $signature[1]]); + } + + private function request(string $url, string $method = 'GET', array $body = []): Response + { + $previous = []; + foreach ([ + 'APP_SECRET' => 'test-only', + 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->database), + 'SITE_ORIGIN' => 'https://beispiel.de', + 'MEDIA_DIRECTORY' => $this->root.'/var/media', + ] as $key => $value) { + $previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null]; + $_SERVER[$key] = $_ENV[$key] = $value; + } + $kernel = new InstallTestKernel($this->root); + try { + $request = Request::create($url, $method, $body); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + return $response; + } finally { + $kernel->shutdown(); + foreach ($previous as $key => [$server, $environment]) { + unset($_SERVER[$key], $_ENV[$key]); + if (null !== $server) { + $_SERVER[$key] = $server; + } + if (null !== $environment) { + $_ENV[$key] = $environment; + } + } + } + } + + private function connection(): Connection + { + return ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->database)); + } + + private static function copyDirectory(string $from, string $to): void + { + @mkdir($to, 0o775, true); + foreach (scandir($from) ?: [] as $entry) { + if ('.' === $entry || '..' === $entry) { + continue; + } + is_dir($from.'/'.$entry) + ? self::copyDirectory($from.'/'.$entry, $to.'/'.$entry) + : copy($from.'/'.$entry, $to.'/'.$entry); + } + } + + private static function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + foreach (scandir($path) ?: [] as $entry) { + if ('.' !== $entry && '..' !== $entry) { + is_dir($path.'/'.$entry) ? self::removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry); + } + } + @rmdir($path); + } +} + +/** A kernel that lives in a throwaway directory, so the installer writes there and not here. */ +final class InstallTestKernel extends Kernel +{ + public function __construct(private string $projectDir) + { + parent::__construct('test', false); + } + + public function getProjectDir(): string + { + return $this->projectDir; + } + + public function getCacheDir(): string + { + return $this->projectDir.'/var/cache'; + } + + public function getLogDir(): string + { + return $this->projectDir.'/var/log'; + } +} diff --git a/tests/InstallationTest.php b/tests/InstallationTest.php new file mode 100644 index 0000000..851715e --- /dev/null +++ b/tests/InstallationTest.php @@ -0,0 +1,249 @@ +root = sys_get_temp_dir().'/kant-install-'.bin2hex(random_bytes(6)); + mkdir($this->root.'/var', 0o775, true); + mkdir($this->root.'/public', 0o775, true); + // The same migration configuration a real installation has, pointing at the real + // migrations: a copy of the list would drift away from it within a week. + file_put_contents($this->root.'/migrations.php', sprintf( + " ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => %s], 'all_or_nothing' => false];", + var_export(\dirname(__DIR__).'/migrations', true), + )); + $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db'); + $this->installation = $this->make(); + } + + protected function tearDown(): void + { + $this->db->close(); + gc_collect_cycles(); + $this->removeDirectory($this->root); + } + + public function testAnEmptyInstallationIsNotInstalledAndSaysWhatIsMissing(): void + { + // No database, no tables, no account: the installer has to survive all three, because it + // is the only screen that exists at that moment. + self::assertFalse($this->installation->isInstalled()); + self::assertFalse($this->installation->schemaReady()); + self::assertFalse($this->installation->siteReady()); + self::assertFalse($this->installation->environmentWritten()); + + $checks = $this->installation->checks(); + $labels = array_column($checks, 'label'); + self::assertContains('PHP-Fassung', $labels); + self::assertContains('Datenbanktreiber', $labels); + foreach ($checks as $check) { + // A failing check without a sentence about what to do is a dead end. + self::assertNotSame('', $check['hint'], $check['label'].' braucht einen Hinweis.'); + } + // The document root of a test process is not public/, so this check has to fail - and it + // has to fail without blocking, because a wrong document root is the server's business. + $documentRoot = self::findCheck($checks, 'Dokumentenstamm zeigt auf public/'); + self::assertFalse($documentRoot['ok']); + self::assertFalse($documentRoot['required']); + } + + public function testTheDatabaseStepProvesTheConnectionBeforeItWritesAnything(): void + { + $good = $this->installation->testDatabase('sqlite', ['name' => 'cms']); + self::assertNull($good['error']); + self::assertSame('sqlite:///%kernel.project_dir%/var/cms.db', $good['url']); + + // A MySQL host that does not answer must fail here and not two screens later. + $bad = $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'z']); + self::assertNotNull($bad['error']); + self::assertStringNotContainsString('geheimespasswort', $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'geheimespasswort'])['error'], 'Das Passwort gehört in keine Fehlermeldung.'); + self::assertFalse(is_file($this->root.'/.env.local'), 'Eine gescheiterte Prüfung schreibt nichts.'); + } + + public function testTheEnvironmentFileKeepsWhatIsAlreadyThere(): void + { + $this->installation->writeEnvironment(['APP_SECRET' => 'geheim', 'DATABASE_URL' => 'sqlite:///x.db']); + self::assertTrue($this->installation->environmentWritten()); + + // The website step adds the address later and must not lose the database doing it. + $this->installation->writeEnvironment(['SITE_ORIGIN' => 'https://example.de']); + $content = (string) file_get_contents($this->root.'/.env.local'); + self::assertStringContainsString('APP_SECRET="geheim"', $content); + self::assertStringContainsString('DATABASE_URL="sqlite:///x.db"', $content); + self::assertStringContainsString('SITE_ORIGIN="https://example.de"', $content); + self::assertSame(1, substr_count($content, 'DATABASE_URL='), 'Kein Wert steht doppelt in der Datei.'); + } + + public function testTheWholeInstallationRunsThroughAndThenLocksItself(): void + { + $pending = $this->installation->pendingMigrations(); + self::assertNotEmpty($pending, 'Eine leere Datenbank hat ausstehende Migrationen.'); + + $applied = $this->installation->migrate(); + self::assertSame(\count($pending), \count($applied)); + self::assertTrue($this->installation->schemaReady()); + // Running again is allowed and does nothing: a browser that timed out is not a problem. + self::assertSame([], $this->installation->migrate()); + + $created = $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de/', 'de'); + self::assertTrue($this->installation->siteReady()); + self::assertContains('Startseite', $created); + self::assertContains('Impressum', $created); + self::assertContains('Datenschutzerklärung', $created); + + // The start page is published, not a draft: a fresh installation that answers 404 at its + // own address would look broken. + $home = $this->make()->isInstalled(); + self::assertFalse($home, 'Ohne Konto gilt die Installation als unfertig.'); + $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db)); + self::assertNotNull($pages->findPublished('home')); + self::assertNotNull($pages->findPublished('impressum')); + self::assertSame('https://beispiel.de', $this->settingsOrigin()); + + // The legal pages are linked, otherwise the consent gate and the footer stay empty. + $settings = (new SiteSettings($this->db))->load(); + self::assertSame('datenschutz', $settings['privacyPath']); + self::assertSame('impressum', $settings['imprintPath']); + self::assertSame('Beispiel GmbH', $settings['legalName']); + + // Running the website step twice creates nothing twice. + self::assertSame([], $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de', 'de')); + + $this->installation->createAdmin('chefin', 'ein sehr langes passwort', 'ein sehr langes passwort'); + self::assertTrue($this->installation->isInstalled(), 'Mit dem ersten Konto ist die Installation vorbei.'); + + $row = $this->db->fetchAssociative('SELECT username, password_hash, roles FROM cms_user'); + self::assertSame('chefin', $row['username']); + self::assertStringNotContainsString('passwort', $row['password_hash'], 'Das Passwort steht nirgends im Klartext.'); + self::assertSame(['ROLE_ADMIN'], json_decode($row['roles'], true)); + self::assertTrue( + (new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']]))) + ->isPasswordValid(new CmsUser('x', 'chefin', $row['password_hash'], ['ROLE_ADMIN']), 'ein sehr langes passwort'), + ); + + // And the second account is refused, because that is what "the installer is over" means. + $this->expectException(InvalidPage::class); + $this->installation->createAdmin('zweite', 'noch ein langes passwort', 'noch ein langes passwort'); + } + + #[DataProvider('badAccounts')] + public function testAnAccountWithWeakInputIsRefused(string $username, string $password, string $repeat): void + { + $this->installation->migrate(); + $this->expectException(InvalidPage::class); + $this->installation->createAdmin($username, $password, $repeat); + } + + public static function badAccounts(): iterable + { + yield 'zu kurzer Name' => ['ab', 'ein sehr langes passwort', 'ein sehr langes passwort']; + yield 'Leerzeichen im Namen' => ['die chefin', 'ein sehr langes passwort', 'ein sehr langes passwort']; + yield 'zu kurzes Passwort' => ['chefin', 'kurz', 'kurz']; + yield 'Tippfehler bei der Wiederholung' => ['chefin', 'ein sehr langes passwort', 'ein sehr langes passwortt']; + } + + #[DataProvider('badSites')] + public function testAWebsiteWithBadInputIsRefused(string $name, string $origin, string $locale): void + { + $this->installation->migrate(); + $this->expectException(InvalidPage::class); + $this->installation->createSite($name, $origin, $locale); + } + + public static function badSites(): iterable + { + yield 'ohne Namen' => ['', 'https://beispiel.de', 'de']; + yield 'Adresse ohne Schema' => ['Beispiel', 'beispiel.de', 'de']; + yield 'keine Adresse' => ['Beispiel', 'https://', 'de']; + yield 'unbekannte Sprache' => ['Beispiel', 'https://beispiel.de', 'kl']; + } + + public function testAFormFromAnotherStepOrAnotherDayDoesNotCount(): void + { + $issued = time(); + $signature = $this->installation->sign('website', $issued); + self::assertTrue($this->installation->verify('website', $issued, $signature)); + self::assertFalse($this->installation->verify('konto', $issued, $signature), 'Eine Unterschrift gilt für einen Schritt.'); + self::assertFalse($this->installation->verify('website', $issued - Installation::TOKEN_LIFETIME - 60, $signature)); + self::assertFalse($this->installation->verify('website', $issued, 'falsch')); + } + + private function make(): Installation + { + $settings = new SiteSettings($this->db); + $validator = new DocumentValidator(); + + return new Installation( + $this->db, + new PageRepository($this->db, $validator, $settings), + new PartialRepository($this->db, $validator), + new MenuRepository($this->db, $settings), + $settings, + new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])), + $this->root, + $this->root.'/var/media', + 'ein-test-geheimnis', + ); + } + + private function settingsOrigin(): string + { + preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env.local'), $match); + + return $match[1] ?? ''; + } + + private static function findCheck(array $checks, string $label): array + { + foreach ($checks as $check) { + if ($check['label'] === $label) { + return $check; + } + } + self::fail('Prüfung fehlt: '.$label); + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + foreach (scandir($path) ?: [] as $entry) { + if ('.' !== $entry && '..' !== $entry) { + is_dir($path.'/'.$entry) ? $this->removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry); + } + } + @rmdir($path); + } +} diff --git a/tests/SingleFileInstallerTest.php b/tests/SingleFileInstallerTest.php new file mode 100644 index 0000000..ab23686 --- /dev/null +++ b/tests/SingleFileInstallerTest.php @@ -0,0 +1,140 @@ +target = sys_get_temp_dir().'/kant-zip-'.bin2hex(random_bytes(6)); + mkdir($this->target, 0o775, true); + } + + protected function tearDown(): void + { + foreach (glob($this->target.'/*') ?: [] as $entry) { + is_dir($entry) ? self::removeDirectory($entry) : @unlink($entry); + } + @rmdir($this->target); + } + + public function testTheVersionFolderIsStrippedAndTheTargetIsRespected(): void + { + $target = str_replace('\\', '/', $this->target); + self::assertSame($target.'/public/index.php', kant_target_path('kantsites-1.2.0/public/index.php', $this->target)); + // Without the version folder the entry is taken as it is. + self::assertSame($target.'/bin/console', kant_target_path('bin/console', $this->target)); + } + + /** + * @param string $entry the archive entry a manipulated archive could contain + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dangerousEntries')] + public function testNothingLandsOutsideTheTargetDirectory(string $entry): void + { + self::assertNull(kant_target_path($entry, $this->target), $entry.' darf nirgends landen.'); + } + + public static function dangerousEntries(): iterable + { + yield 'zwei Ebenen nach oben' => ['kantsites-1.2.0/../../.env']; + yield 'direkt nach oben' => ['../evil.php']; + yield 'mitten im Pfad' => ['kantsites-1.2.0/src/../../../evil.php']; + yield 'absolut' => ['/etc/passwd']; + yield 'Windows-Laufwerk' => ['C:/Windows/evil.php']; + yield 'Rückwärtsschrägstrich' => ['kantsites-1.2.0\\..\\..\\evil.php']; + yield 'Nullbyte' => ["kantsites-1.2.0/src/evil\0.php"]; + yield 'nur das Verzeichnis' => ['kantsites-1.2.0/']; + yield 'leer' => ['']; + } + + public function testAnOrdinaryArchiveIsUnpackedAndAManipulatedOneIsNot(): void + { + $good = $this->archive(['kantsites-1.2.0/public/index.php' => ' 'x']); + self::assertSame(2, kant_unpack($good, $this->target)); + self::assertSame('target.'/public/index.php')); + self::assertFileExists($this->target.'/src/Kernel.php'); + + // An archive with an entry that points outside is refused - and the refusal happens while + // unpacking, so what matters is that the file outside never appears. + $evil = $this->archive(['kantsites-1.2.0/harmlos.txt' => 'ok', 'kantsites-1.2.0/../entwischt.txt' => 'böse']); + try { + kant_unpack($evil, $this->target); + self::fail('Ein Eintrag außerhalb des Ziels muss zum Abbruch führen.'); + } catch (\RuntimeException $e) { + self::assertStringContainsString('außerhalb', $e->getMessage()); + } + self::assertFileDoesNotExist(\dirname($this->target).'/entwischt.txt'); + } + + public function testThePreflightNamesWhatIsMissing(): void + { + $checks = kant_checks($this->target); + $labels = array_column($checks, 'label'); + self::assertContains('ZIP-Erweiterung', $labels); + self::assertContains('Schreibrecht im Zielverzeichnis', $labels); + + // This copy comes from the repository, not from a release: no address, no checksum, and + // it says so instead of downloading something. + $unfinished = self::find($checks, 'Adresse und Prüfsumme eingetragen'); + self::assertFalse($unfinished['ok']); + + // An existing installation is not overwritten, not even partially. + mkdir($this->target.'/public', 0o775, true); + file_put_contents($this->target.'/public/index.php', 'x'); + self::assertFalse(self::find(kant_checks($this->target), 'Verzeichnis ist leer genug')['ok']); + } + + private function archive(array $entries): string + { + $path = $this->target.'/../kant-archive-'.bin2hex(random_bytes(4)).'.zip'; + $zip = new \ZipArchive(); + $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + foreach ($entries as $name => $content) { + $zip->addFromString($name, $content); + } + $zip->close(); + self::assertFileExists($path); + register_shutdown_function(static fn () => @unlink($path)); + + return $path; + } + + private static function find(array $checks, string $label): array + { + foreach ($checks as $check) { + if ($check['label'] === $label) { + return $check; + } + } + self::fail('Prüfung fehlt: '.$label); + } + + private static function removeDirectory(string $path): void + { + foreach (scandir($path) ?: [] as $entry) { + if ('.' !== $entry && '..' !== $entry) { + is_dir($path.'/'.$entry) ? self::removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry); + } + } + @rmdir($path); + } +}