root.'/'.InstallSubscriber::MARKER)) { return true; } try { return (bool) $this->db->fetchOne('SELECT id FROM cms_user LIMIT 1'); } catch (TableNotFoundException) { // Keine Tabelle: die Datenbank ist da, aber leer - das ist der Installer-Fall. return false; } catch (\Throwable) { // Keine Verbindung. Vor dem Datenbankschritt ist das normal (es gibt noch keine // eingetragene Datenbank); danach ist es eine Stoerung, keine fehlende Installation. return $this->environmentWritten(); } } /** * 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. */ /** Ab dieser Fassung kennt SQLite die Tabellenfunktionen, mit denen Doctrine das Schema liest. */ public const MIN_SQLITE = '3.16.0'; /** Die Fassung der eingebauten SQLite-Bibliothek, oder null wenn es keine gibt. */ public function sqliteVersion(): ?string { if (!extension_loaded('pdo_sqlite')) { return null; } try { $version = (new \PDO('sqlite::memory:'))->query('SELECT sqlite_version()')?->fetchColumn(); } catch (\PDOException) { return null; } return is_string($version) ? $version : null; } 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)); // Die Fassung von SQLite, und zwar vor der Wahl der Datenbank. Doctrine liest das Schema // mit Tabellenfunktionen wie `pragma_table_info(...)`; die gibt es erst seit SQLite 3.16 // (Januar 2017). Auf einem Hosting mit aelterem SQLite bricht sonst mitten in der // Installation eine Abfrage mit `near "(": syntax error` ab - eine Meldung, die niemandem // sagt, was zu tun ist. Gefunden auf einem echten Hosting, nicht hier. $sqlite = $this->sqliteVersion(); if (null !== $sqlite) { $checks[] = $this->check( 'SQLite-Fassung', version_compare($sqlite, self::MIN_SQLITE, '>='), sprintf('Gefunden: %s. Für eine SQLite-Datenbank wird mindestens %s gebraucht. Diese Installation läuft trotzdem – aber mit MySQL statt SQLite; eine Datenbank legt man beim Hoster im Kundenmenü an.', $sqlite, self::MIN_SQLITE), false, $sqlite, ); } $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)', $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 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') && str_contains((string) file_get_contents($this->root.'/.env'), 'KANT_DATABASE_CONFIGURED="1"'); } 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]; } /** Save installation settings in the single persistent configuration file. */ public function writeEnvironment(array $values): void { if (isset($values['DATABASE_URL'])) { $values['KANT_DATABASE_CONFIGURED'] = '1'; } Environment::write($this->root, $values); } /** * 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); } }