/install fuehrt von einer leeren Installation zu einer Website: Systempruefung, Datenbank (SQLite oder MySQL, Verbindung sofort geprueft), Migration im Prozess ohne Konsole, Website mit Startseite, Bereichen, Menue sowie Impressum und Datenschutzgeruest, erstes Konto, Abschlussliste. Jeder Schritt schreibt sein Ergebnis sofort, damit ein Neuladen nichts verliert. Gesperrt wird ueber den Zustand der Datenbank - gibt es ein Konto, antwortet /install mit 404. Eine Datei koennte jemand loeschen. Solange nichts installiert ist, fuehrt jede Adresse zum Installer statt zu einem 500 aus einer fehlenden Tabelle. Und weil ohne APP_SECRET nicht einmal die Seite bootet, die das erklaeren wuerde, erzeugt der erste Start das Geheimnis selbst. scripts/install.php ist die einzelne Datei davor: Adresse und SHA-256 stehen darin (vom Buildskript eingetragen), ohne passende Pruefsumme wird nicht entpackt, jeder Archiveintrag wird gegen das Zielverzeichnis geprueft, und danach loescht sie sich selbst. Geprueft: 379 Tests unter PHP 8.2 und 8.4, darunter der ganze Ablauf durch die HTTP-Schicht, neun ZIP-Slip-Varianten, axe ohne Verstoss auf allen Installationsbildschirmen, und das entpackte Releasearchiv startet in den Installer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54 lines
1.9 KiB
PHP
54 lines
1.9 KiB
PHP
<?php
|
|
|
|
namespace App\EventSubscriber;
|
|
|
|
use App\Ops\Installation;
|
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
|
use Symfony\Component\HttpKernel\KernelEvents;
|
|
|
|
/**
|
|
* An installation that does not exist yet answers with the installer instead of an error.
|
|
*
|
|
* Without this, the first visit to a freshly unpacked website is a 500 from a missing table - the
|
|
* one moment where a person needs a sentence, not a stack trace.
|
|
*
|
|
* The cost of knowing is kept at zero for a running website: once an account exists, a marker file
|
|
* is written and no request asks the database again. The marker only ever silences this
|
|
* subscriber; the installer itself always asks the database, so deleting the file does not reopen
|
|
* anything.
|
|
*/
|
|
final class InstallSubscriber implements EventSubscriberInterface
|
|
{
|
|
public const MARKER = 'var/installed';
|
|
|
|
public function __construct(private Installation $installation, private string $projectDir)
|
|
{
|
|
}
|
|
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
// After maintenance (40), before the router: an unfinished installation has no routes
|
|
// worth matching except its own.
|
|
return [KernelEvents::REQUEST => ['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'));
|
|
}
|
|
}
|