- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
11 KiB
PHP
274 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Domain\InvalidPage;
|
|
use App\Ops\Installation;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\Cookie;
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
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.
|
|
*
|
|
* Vorher öffnet sich kein Schritt ohne den Installationsschlüssel (ADR 116): Er steht auf der
|
|
* Abschlussseite von install.php bzw. in var/install.key. Einmal eingegeben, bleibt im Browser
|
|
* ein Cookie mit einem Nachweis darüber, nur für /install.
|
|
*/
|
|
final class InstallController extends AbstractController
|
|
{
|
|
public function __construct(private Installation $installation)
|
|
{
|
|
}
|
|
|
|
/** Der Name des Cookies, das den Installationsschlüssel nachweist. */
|
|
public const KEY_COOKIE = 'kant_install';
|
|
|
|
#[Route('/install/schluessel', name: 'install_key', methods: ['GET', 'POST'], priority: 11)]
|
|
public function key(Request $request): Response
|
|
{
|
|
if ($this->installation->isInstalled()) {
|
|
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
|
|
}
|
|
$error = null;
|
|
try {
|
|
$this->installation->installKey();
|
|
$given = $request->isMethod('POST') ? $request->request->getString('schluessel') : $request->query->getString('schluessel');
|
|
if ('' !== $given) {
|
|
if ($this->installation->acceptsKey($given)) {
|
|
// Weiter ohne den Schlüssel in der Adresse: er soll weder im Verlauf noch in
|
|
// einem Serverprotokoll als Verweis stehen bleiben.
|
|
$response = $this->redirectToRoute('install');
|
|
$response->headers->setCookie(Cookie::create(self::KEY_COOKIE, $this->installation->keyProof(), 0, '/install', null, $request->isSecure(), true, false, Cookie::SAMESITE_STRICT));
|
|
|
|
return $response;
|
|
}
|
|
$error = 'Dieser Schlüssel passt nicht.';
|
|
}
|
|
} catch (\RuntimeException $e) {
|
|
$error = $e->getMessage();
|
|
}
|
|
|
|
$response = $this->render('install/key.html.twig', ['step' => 1, 'error' => $error, 'keyFile' => Installation::KEY_FILE]);
|
|
$response->headers->set('Referrer-Policy', 'no-referrer');
|
|
|
|
return $response;
|
|
}
|
|
|
|
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
|
|
public function check(Request $request): Response
|
|
{
|
|
if ($blocked = $this->guard($request)) {
|
|
return $blocked;
|
|
}
|
|
$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
|
|
{
|
|
if ($blocked = $this->guard($request)) {
|
|
return $blocked;
|
|
}
|
|
$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 nur, wenn die Fassung die Tabellenfunktionen kennt, mit denen Doctrine das
|
|
// Schema liest - sonst scheitert erst der naechste Schritt, und zwar unverstaendlich.
|
|
'sqlite' => \extension_loaded('pdo_sqlite') && null !== $this->installation->sqliteVersion()
|
|
&& version_compare((string) $this->installation->sqliteVersion(), Installation::MIN_SQLITE, '>='),
|
|
'sqliteVersion' => $this->installation->sqliteVersion(),
|
|
'minSqlite' => Installation::MIN_SQLITE,
|
|
...$this->token('datenbank'),
|
|
]);
|
|
}
|
|
|
|
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
|
|
public function migration(Request $request): Response
|
|
{
|
|
if ($blocked = $this->guard($request)) {
|
|
return $blocked;
|
|
}
|
|
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
|
|
{
|
|
if ($blocked = $this->guard($request)) {
|
|
return $blocked;
|
|
}
|
|
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
|
|
{
|
|
if ($blocked = $this->guard($request)) {
|
|
return $blocked;
|
|
}
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
/** 404 nach der Installation; davor ohne Schlüsselnachweis zurück zur Eingabe des Schlüssels. */
|
|
private function guard(Request $request): ?Response
|
|
{
|
|
if ($this->installation->isInstalled()) {
|
|
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
|
|
}
|
|
try {
|
|
if ($this->installation->acceptsProof($request->cookies->getString(self::KEY_COOKIE))) {
|
|
return null;
|
|
}
|
|
} catch (\RuntimeException) {
|
|
// Kein Schlüssel lesbar oder schreibbar: die Eingabeseite sagt, woran es liegt.
|
|
}
|
|
|
|
return new RedirectResponse($this->generateUrl('install_key'));
|
|
}
|
|
|
|
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'));
|
|
}
|
|
}
|