Files
KantSites/src/Ops/Installation.php
T
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- 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>
2026-10-12 14:38:25 +02:00

513 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\EventSubscriber\InstallSubscriber;
use App\Infrastructure\ConnectionFactory;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\TableNotFoundException;
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`, 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;
/**
* Der Installationsschlüssel (ADR 116). Ohne ihn öffnet sich kein Schritt des Installers: wer
* eine frisch entpackte Installation zuerst aufruft, soll sie nicht übernehmen können.
* install.php legt ihn an und zeigt ihn nur dem Browser, der entpackt hat; wer per FTP
* entpackt, findet ihn in dieser Datei. Mit dem ersten Konto wird er gelöscht.
*/
public const KEY_FILE = 'var/install.key';
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
{
// Die Marke schreibt die Website, sobald ein Konto existiert. Sie zaehlt auch dann, wenn
// die Datenbank gerade nicht antwortet: ein gedrehtes Passwort oder eine Wartung beim
// Hoster darf den Installer nicht wieder oeffnen - dort koennte sonst jeder eine eigene
// Datenbank eintragen und sich zum Administrator machen.
if (is_file($this->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.');
}
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', ['ROLE_ADMIN']);
$hash = $this->hasher->hashPassword($user, $password);
// Prüfen und Anlegen in einer Transaktion: zwei gleichzeitige Absendungen sollen nicht zwei
// erste Konten ergeben.
$this->db->transactional(function () use ($user, $username, $hash): void {
if ($this->isInstalled()) {
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
}
$this->db->insert('cms_user', [
'id' => $user->id,
'username' => $username,
'password_hash' => $hash,
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
]);
});
// Der Schlüssel hat seine Arbeit getan; liegen bleiben soll er nicht.
@unlink($this->root.'/'.self::KEY_FILE);
}
/**
* Der Schlüssel dieser Installation; beim ersten Aufruf angelegt, wenn install.php es nicht
* schon getan hat. 128 Bit aus random_bytes - raten lohnt nicht, eine Sperre braucht es nicht.
*/
public function installKey(): string
{
$file = $this->root.'/'.self::KEY_FILE;
$key = is_file($file) ? trim((string) file_get_contents($file)) : '';
if (1 === preg_match('/^[a-f0-9]{32}$/D', $key)) {
return $key;
}
if (!is_dir(\dirname($file)) && !@mkdir(\dirname($file), 0o775, true) && !is_dir(\dirname($file))) {
throw new \RuntimeException('var/ ist nicht beschreibbar; ohne Installationsschlüssel geht es nicht weiter.');
}
$key = bin2hex(random_bytes(16));
// x: nur anlegen, wenn es die Datei noch nicht gibt - zwei gleichzeitige erste Aufrufe
// sollen sich nicht gegenseitig den Schlüssel überschreiben.
$handle = @fopen($file, 'x');
if (false === $handle) {
$existing = trim((string) @file_get_contents($file));
if (1 === preg_match('/^[a-f0-9]{32}$/D', $existing)) {
return $existing;
}
throw new \RuntimeException('Der Installationsschlüssel ließ sich nicht schreiben ('.self::KEY_FILE.').');
}
fwrite($handle, $key.'
');
fclose($handle);
@chmod($file, 0o600);
return $key;
}
/** Ob die Eingabe der Schlüssel ist - Leerzeichen und Großbuchstaben aus dem Abtippen zählen nicht. */
public function acceptsKey(string $given): bool
{
return hash_equals($this->installKey(), strtolower(preg_replace('/\s+/', '', $given) ?? ''));
}
/** Was als Cookie im Browser bleibt: nicht der Schlüssel selbst, sondern ein Nachweis über ihn. */
public function keyProof(): string
{
return hash_hmac('sha256', 'installer', $this->installKey());
}
public function acceptsProof(string $proof): bool
{
return '' !== $proof && hash_equals($this->keyProof(), $proof);
}
/**
* 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. Wer überhaupt an den Installer darf, entscheidet seit ADR 116 der
* Installationsschlüssel; die Unterschrift bindet nur das Formular an seinen Schritt.
*/
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);
}
}