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
@@ -129,6 +129,41 @@ final class DemoContent
|
||||
*
|
||||
* @return array<string, array{name: string, document: 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 => [
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\Migrations\Configuration\Connection\ExistingConnection;
|
||||
use Doctrine\Migrations\Configuration\Migration\PhpFile;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
use Doctrine\Migrations\Metadata\MigrationPlanList;
|
||||
use Doctrine\Migrations\MigratorConfiguration;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Everything the browser installer does, without a browser.
|
||||
*
|
||||
* The order is the order of the screens, and every step writes its result immediately instead of
|
||||
* collecting answers in a session: after the database step there is a `.env.local`, after the
|
||||
* migration step there are tables, after the website step there is content. That way a reload, a
|
||||
* timeout or a closed tab never loses a half filled form - the installer simply sees what is
|
||||
* already there and continues at the next missing thing.
|
||||
*
|
||||
* The end of the installation is the first account: `isInstalled()` asks the database, not a file,
|
||||
* because a file can be deleted and would open the installer again on a running website.
|
||||
*/
|
||||
final class Installation
|
||||
{
|
||||
/** Longer than a person needs for a form, short enough that a stale tab stops working. */
|
||||
public const TOKEN_LIFETIME = 7200;
|
||||
|
||||
public function __construct(
|
||||
private Connection $db,
|
||||
private PageRepository $pages,
|
||||
private PartialRepository $partials,
|
||||
private MenuRepository $menus,
|
||||
private SiteSettings $settings,
|
||||
private UserPasswordHasherInterface $hasher,
|
||||
private string $root,
|
||||
private string $mediaDirectory,
|
||||
private string $secret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there an installation? An account is the criterion.
|
||||
*
|
||||
* Everything before that is repeatable: the environment file can be rewritten, migrations are
|
||||
* idempotent, the starter content is only created when it is missing. An account is the point
|
||||
* of no return, so it is also the point at which the installer stops answering.
|
||||
*/
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
try {
|
||||
return (bool) $this->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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user