Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

This commit is contained in:
kantarci committed 2026-09-14 10:49:54 +02:00
1 parent b164c4edfe
commit 495b26896e
55 files changed
+1632 -423

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Ops;
use Symfony\Component\Dotenv\Dotenv;
/** One installation file, with an explicit, recoverable transition from older releases. */
final class Environment
{
public static function prepare(string $root): void
{
if (is_file($root.'/.env') && !is_file($root.'/.env.local')) {
return;
}
self::locked($root, static function () use ($root): void {
$file = $root.'/.env';
if (!is_file($file)) {
$template = is_file($root.'/.env.example') ? (string) file_get_contents($root.'/.env.example') : '';
self::replace($file, $template);
}
$legacy = $root.'/.env.local';
if (is_file($legacy)) {
// Preserve the previously effective configuration, not a release's default DB.
$base = (string) file_get_contents($file);
$old = (string) file_get_contents($legacy);
$values = (new Dotenv())->parse($base."\n".$old, $legacy);
if (preg_match('/^DATABASE_URL=/m', $old)) {
$values['KANT_DATABASE_CONFIGURED'] = '1';
}
$directory = $root.'/var/backups/environment-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
if (!@mkdir($directory, 0o700, true)) {
throw new \RuntimeException('Die bisherige Konfiguration konnte nicht gesichert werden. Bitte die Schreibrechte auf var/backups prüfen.');
}
self::replace($directory.'/.env', $base);
self::replace($directory.'/.env.local', $old);
self::replace($file, self::encode($values));
if (!@unlink($legacy)) {
throw new \RuntimeException('Die Einstellungen sind in .env gesichert, aber .env.local konnte nicht entfernt werden. Bitte deren Schreibrechte prüfen.');
}
}
});
}
public static function write(string $root, array $values): void
{
self::locked($root, static function () use ($root, $values): void {
$file = $root.'/.env';
$existing = is_file($file) ? (new Dotenv())->parse((string) file_get_contents($file), $file) : [];
self::replace($file, self::encode(array_replace($existing, $values)));
});
}
/** A file lock serializes concurrent installer requests and secret generation. */
private static function locked(string $root, callable $operation): void
{
$lock = @fopen($root.'/.env.lock', 'c');
if (false === $lock || !flock($lock, LOCK_EX)) {
if (is_resource($lock)) {
fclose($lock);
}
throw new \RuntimeException('.env ist nicht beschreibbar. Bitte die Schreibrechte im Projektstamm prüfen.');
}
try {
$operation();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
public static function ensureSecret(string $root): string
{
$secret = '';
self::locked($root, static function () use ($root, &$secret): void {
$file = $root.'/.env';
$values = (new Dotenv())->parse((string) file_get_contents($file), $file);
$secret = $values['APP_SECRET'] ?? '';
if ('' === $secret) {
$secret = bin2hex(random_bytes(32));
self::replace($file, self::encode([...$values, 'APP_SECRET' => $secret]));
}
});
return $secret;
}
private static function encode(array $values): string
{
$lines = ['# Konfiguration dieser Installation. Updates erhalten diese Datei.'];
foreach ($values as $key => $value) {
if (1 !== preg_match('/^[A-Z_][A-Z0-9_]*$/D', $key)) {
throw new \InvalidArgumentException('Ungültiger Name einer Konfigurationseinstellung.');
}
$lines[] = $key.'="'.str_replace(['\\', '"', '$', "\n", "\r"], ['\\\\', '\\"', '\\$', '\\n', '\\r'], (string) $value).'"';
}
return implode("\n", $lines)."\n";
}
private static function replace(string $file, string $content): void
{
$temporary = $file.'.'.bin2hex(random_bytes(6)).'.tmp';
if (false === @file_put_contents($temporary, $content)) {
throw new \RuntimeException('.env ließ sich nicht schreiben. Bitte die Schreibrechte im Projektstamm prüfen.');
}
@chmod($temporary, 0o600);
if (!@rename($temporary, $file)) {
@unlink($temporary);
throw new \RuntimeException('.env ließ sich nicht ersetzen. Die bisherige Konfiguration bleibt erhalten.');
}
}
}
+8 -27
View File
@@ -22,7 +22,7 @@ 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
* 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.
@@ -123,7 +123,7 @@ final class Installation
$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) {
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);
}
@@ -138,7 +138,7 @@ final class Installation
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.');
$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;
}
@@ -151,7 +151,7 @@ final class Installation
public function environmentWritten(): bool
{
return is_file($this->root.'/.env.local') && str_contains((string) file_get_contents($this->root.'/.env.local'), 'DATABASE_URL=');
return is_file($this->root.'/.env') && str_contains((string) file_get_contents($this->root.'/.env'), 'KANT_DATABASE_CONFIGURED="1"');
}
public function schemaReady(): bool
@@ -206,32 +206,13 @@ final class Installation
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.
*/
/** Save installation settings in the single persistent configuration file. */
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;
}
if (isset($values['DATABASE_URL'])) {
$values['KANT_DATABASE_CONFIGURED'] = '1';
}
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);
Environment::write($this->root, $values);
}
/**
+54 -5
View File
@@ -19,6 +19,8 @@ use Doctrine\DBAL\Connection;
*/
final class Updater
{
private array $environmentBefore = [];
private ?string $backupDirectory = null;
public const MAINTENANCE_FILE = 'var/maintenance';
public const LOG_FILE = 'var/updates.log';
@@ -231,8 +233,22 @@ final class Updater
$step($steps, 'Wartungszustand', true, 'Die Website antwortet mit 503, die Verwaltung bleibt erreichbar.');
$migrated = false;
foreach (['.env', '.env.local'] as $file) {
if (is_file($this->root.'/'.$file)) {
$this->environmentBefore[$file] = (string) file_get_contents($this->root.'/'.$file);
}
}
try {
// Older repositories tracked this file. Snapshot above, clear legacy index flags,
// then let Git replace its own copy; restore the installation before booting PHP.
if ($this->git(['ls-files', '--error-unmatch', '.env'])['ok']) {
$this->git(['update-index', '--no-assume-unchanged', '--no-skip-worktree', '.env']);
if (!$this->git(['checkout', '--', '.env'])['ok']) {
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
}
}
$checkout = $this->git(['checkout', '--force', $target]);
$this->restoreEnvironment();
if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) {
throw new \RuntimeException('checkout');
}
@@ -320,15 +336,27 @@ final class Updater
private function rollback(array &$steps, string $commit, ?string $backup, callable $step): void
{
$back = $this->git(['checkout', '--force', $commit]);
try {
$this->restoreEnvironment();
} catch (\RuntimeException $e) {
$step($steps, 'Konfiguration wiederherstellen', false, $e->getMessage());
return;
}
$step($steps, 'Zurück auf den alten Stand', $back['ok'], $back['ok'] ? 'Auf '.substr($commit, 0, 8).' zurückgesetzt.' : $back['output']);
if (null !== $backup && null !== $this->databaseFile()) {
$file = $this->databaseFile();
$copy = dirname($backup).'/'.basename((string) $file);
$copy = $this->backupDirectory.'/'.basename((string) $file);
$restored = is_readable($copy) && @copy($copy, (string) $file);
$step($steps, 'Datenbank zurückschreiben', $restored, $restored
? 'Die Sicherung von vor der Migration ist wieder eingespielt.'
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.dirname((string) $backup).'.');
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.$this->backupDirectory.'.');
if (!$restored) {
$step($steps, 'Wartungszustand bleibt', false, 'Die Datenbank konnte nicht wiederhergestellt werden. Der Betrieb bleibt angehalten.');
return;
}
}
$composer = $this->runner->resolve('composer', $this->root);
@@ -346,11 +374,27 @@ final class Updater
$step($steps, 'Wartungszustand bleibt', false, 'Auch der alte Stand antwortet nicht. Die Website bleibt im Wartungszustand, damit niemand eine kaputte Seite sieht. Ausgabe: '.$this->lastLine($health['output']));
}
/** Legacy tags tracked .env; preserve the installation even when checking out those tags. */
private function restoreEnvironment(): void
{
foreach ($this->environmentBefore as $file => $content) {
$path = $this->root.'/'.$file;
if (is_file($path) && file_get_contents($path) === $content) {
continue;
}
if (false === @file_put_contents($path, $content, LOCK_EX)) {
throw new \RuntimeException('Die Installationskonfiguration konnte nicht wiederhergestellt werden. Sie liegt in der Updatesicherung unter var/backups.');
}
@chmod($path, 0o600);
}
}
/** Copies the database and the environment files, and returns a sentence about it. */
private function backup(): ?string
{
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His');
if (!is_dir($directory) && !@mkdir($directory, 0o775, true)) {
$this->backupDirectory = null;
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
if (!@mkdir($directory, 0o700, true)) {
return null;
}
$file = $this->databaseFile();
@@ -361,10 +405,15 @@ final class Updater
}
foreach (['.env', '.env.local'] as $environment) {
if (is_readable($this->root.'/'.$environment)) {
@copy($this->root.'/'.$environment, $directory.'/'.$environment);
if (!@copy($this->root.'/'.$environment, $directory.'/'.$environment)) {
return null;
}
@chmod($directory.'/'.$environment, 0o600);
}
}
$this->backupDirectory = $directory;
return sprintf(
'%s (%s). Der Medienordner ist nicht dabei – er ist zu groß, und kein Update schreibt darin.',
str_replace('\\', '/', substr($directory, strlen($this->root) + 1)),