- 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>
513 lines
24 KiB
PHP
513 lines
24 KiB
PHP
<?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);
|
||
}
|
||
}
|