Installer: sechs Bildschirme, und eine Datei davor
/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>
This commit is contained in:
1 parent
c3bff7d936
commit
e34b4dca67
25 files changed
+1937
-7
No files matched your search
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\Installation;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Six screens from an empty webspace to a website, without a console.
|
||||
*
|
||||
* Every screen answers only while the installation is unfinished, and every one of them writes its
|
||||
* result straight away, so a reload continues where it stopped instead of starting over. The order
|
||||
* is fixed and enforced here, not only suggested by links: a step whose requirement is missing
|
||||
* sends you back to the step that produces it.
|
||||
*
|
||||
* After the first account exists, every route in this file answers 404 - not because a file was
|
||||
* deleted, but because the database says there is an installation.
|
||||
*/
|
||||
final class InstallController extends AbstractController
|
||||
{
|
||||
public function __construct(private Installation $installation)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
|
||||
public function check(): Response
|
||||
{
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user