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

+5 -9
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
* ankommt, kostet Rechenzeit und Logzeilen. Heißt die Verwaltung anders, findet sie niemand, der
* sie nicht kennt. Das ersetzt kein gutes Passwort - es nimmt der Maschinerie nur das Ziel.
*
* Der Wert steht in `.env.local` und nicht in der Datenbank: die Routen stehen fest, bevor die
* Der Wert steht in `.env` und nicht in der Datenbank: die Routen stehen fest, bevor die
* erste Datenbankabfrage läuft. Deshalb dieser Befehl - er schreibt die Datei, prüft vorher, dass
* die Adresse benutzbar ist, und räumt den Zwischenspeicher auf, damit die neue Adresse sofort
* gilt.
@@ -68,14 +68,10 @@ final class AdminPathCommand extends Command
return Command::FAILURE;
}
$datei = $this->projectDir.'/.env.local';
$inhalt = is_file($datei) ? (string) file_get_contents($datei) : '';
$zeile = sprintf('ADMIN_PATH=%s', $neu);
$inhalt = 1 === preg_match('/^ADMIN_PATH=.*$/m', $inhalt)
? (string) preg_replace('/^ADMIN_PATH=.*$/m', $zeile, $inhalt)
: rtrim($inhalt)."\n".$zeile."\n";
if (false === @file_put_contents($datei, ltrim($inhalt, "\n"))) {
$io->error('.env.local ist nicht beschreibbar. Trage dort von Hand ein: '.$zeile);
try {
\App\Ops\Environment::write($this->projectDir, ['ADMIN_PATH' => $neu]);
} catch (\RuntimeException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
+58
View File
@@ -420,6 +420,64 @@ final class PageRepository
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
}
/** Only the requested screenful is hydrated; filters and counts run in the database. */
public function editorPage(string $type, int $page = 1, int $limit = 20, string $query = '', string $status = 'all'): array
{
$limit = in_array($limit, [20, 50], true) ? $limit : 20;
$type = 'post' === $type ? 'post' : 'page';
$qb = $this->db->createQueryBuilder()->from('page', 'p')
->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id')
->where('p.website_id = :website')->andWhere('p.type = :type')
->setParameter('website', 'default')->setParameter('type', $type);
$qb->andWhere('trash' === $status ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL');
if ('draft' === $status) {
$qb->andWhere('p.published_revision_id IS NULL');
} elseif ('published' === $status) {
$qb->andWhere('p.published_revision_id IS NOT NULL');
} elseif ('changed' === $status) {
$qb->andWhere('p.published_revision_id IS NOT NULL AND p.published_revision_id <> p.draft_revision_id');
}
if ('' !== trim($query)) {
$qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')')
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
}
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
$pages = max(1, (int) ceil($total / $limit));
$page = max(1, min($pages, $page));
$items = $qb->select('p.*', 'r.document')->orderBy('post' === $type ? 'p.created_at' : 'p.path', 'post' === $type ? 'DESC' : 'ASC')
->addOrderBy('p.id')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
}
/** Bounded suggestions for menus and parent selectors, including a selected ID lookup. */
public function choices(string $query = '', string $type = '', bool $parentsOnly = false, ?string $id = null): array
{
$qb = $this->db->createQueryBuilder()->select('p.id', 'p.path', 'p.type', 'p.published_revision_id', 'r.document')
->from('page', 'p')->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id')
->where('p.website_id = :website')->andWhere('p.deleted_at IS NULL')->setParameter('website', 'default');
if (null !== $id) {
$qb->andWhere('p.id = :id')->setParameter('id', $id);
}
if ($parentsOnly || in_array($type, ['page', 'post'], true)) {
$qb->andWhere('p.type = :type')->setParameter('type', $parentsOnly ? 'page' : $type);
}
if ($parentsOnly) {
$qb->andWhere("LENGTH(p.path) - LENGTH(REPLACE(p.path, '/', '')) < ".(self::MAX_DEPTH - 1));
}
if ('' !== trim($query)) {
$qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')')
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
}
$rows = $qb->orderBy('p.path')->addOrderBy('p.id')->setMaxResults(20)->executeQuery()->fetchAllAssociative();
return array_map(static fn (array $row): array => [
'id' => $row['id'], 'path' => $row['path'], 'type' => $row['type'],
'title' => json_decode($row['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $row['path'],
'published' => null !== $row['published_revision_id'],
], $rows);
}
public function findForEditor(string $id): ?array
{
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, 'default']);
+51 -10
View File
@@ -20,6 +20,7 @@ use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -27,31 +28,53 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
{
}
#[Route('', name: 'admin_pages', methods: ['GET'])]
public function index(): Response
#[Route('/posts', name: 'admin_posts', methods: ['GET'])]
public function index(Request $request): Response
{
$pages = $this->pages->listForEditor();
$type = 'admin_posts' === $request->attributes->get('_route') ? 'post' : 'page';
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
$status = $request->query->getString('status', 'all');
$status = in_array($status, ['all', 'draft', 'published', 'changed', 'trash'], true) ? $status : 'all';
$result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status);
$pages = $result['items'];
$parentChoices = [];
foreach ($pages as &$page) {
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
$page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$page['title'] = $page['document']['title'] ?? $page['slug'];
$page['depth'] = substr_count((string) $page['path'], '/');
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) {
$parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null;
}
}
unset($page);
return $this->render('admin/pages.html.twig', [
'pages' => $pages,
'trash' => $this->pages->trash(),
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
'postLayouts' => PostLayouts::all(),
// Which header and footer areas a page can choose from.
'pages' => 'trash' === $status ? [] : $pages,
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
]);
}
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
public function contentChoices(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
return $this->json(['items' => $this->pages->choices(
mb_substr($request->query->getString('q'), 0, 160),
$request->query->getString('type'), $request->query->getBoolean('parents'),
)]);
}
#[Route('/pages', name: 'admin_create', methods: ['POST'])]
public function create(Request $request): Response
{
@@ -317,8 +340,26 @@ final class EditorController extends AbstractController
private function back(string $message, string $type = 'error'): Response
{
$this->addFlash($type, $message);
$request = $this->requests->getCurrentRequest();
$post = 'post' === $request?->request->getString('_list_type', $request?->request->getString('type', 'page'));
return $this->redirectToRoute('admin_pages');
if (!$request?->request->has('_list_type')) {
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages');
}
$params = [
'q' => $request?->request->getString('_list_query', ''),
'status' => $request?->request->getString('_list_status', 'all'),
'seite' => $request?->request->getInt('_list_page', 1),
'limit' => $request?->request->getInt('_list_limit', 20),
];
foreach (['q' => '', 'status' => 'all', 'seite' => 1, 'limit' => 20] as $key => $default) {
if ($params[$key] === $default) {
unset($params[$key]);
}
}
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages', $params);
}
#[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])]
+23 -19
View File
@@ -18,24 +18,24 @@ final class MenuController extends AbstractController
}
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
public function index(): Response
public function index(Request $request): Response
{
$menus = $this->menus->all();
$rows = [];
foreach (MenuRepository::SLOTS as $slot => $label) {
$rows[$slot] = $this->flatten($menus[$slot]['items'] ?? []);
}
$pages = [];
foreach ($this->pages->listForEditor() as $page) {
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$pages[] = ['id' => $page['id'], 'path' => $page['path'], 'title' => $document['title'] ?? $page['slug']];
$slot = $request->query->getString('slot', 'header');
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
$menu = $this->menus->all()[$slot] ?? null;
$rows = $this->flatten($menu['items'] ?? []);
$selected = [];
foreach ($rows as &$row) {
if ($row['pageId']) {
$selected[$row['pageId']] ??= $this->pages->choices(id: $row['pageId'])[0] ?? ['id' => $row['pageId'], 'title' => 'Nicht mehr verfügbar', 'path' => ''];
}
$row['page'] = $selected[$row['pageId']] ?? null;
}
unset($row);
return $this->render('admin/menus.html.twig', [
'slots' => MenuRepository::SLOTS,
'menus' => $menus,
'rows' => $rows,
'pages' => $pages,
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
'menu' => $menu, 'rows' => $rows,
]);
}
@@ -44,7 +44,7 @@ final class MenuController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.'], 403) : $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', slot: $slot);
}
try {
$this->menus->save(
@@ -54,10 +54,14 @@ final class MenuController extends AbstractController
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidMenu|StaleWrite $e) {
return $this->back($e->getMessage());
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => $e->getMessage()], $e instanceof StaleWrite ? 409 : 422) : $this->back($e->getMessage(), slot: $slot);
}
return $this->back('Das Menü wurde gespeichert.', 'success');
if ('json' === $request->getPreferredFormat()) {
return $this->json(['message' => 'Menü gespeichert.', 'version' => $this->menus->all()[$slot]['version']]);
}
return $this->back('Das Menü wurde gespeichert.', 'success', $slot);
}
/**
@@ -122,10 +126,10 @@ final class MenuController extends AbstractController
return $items;
}
private function back(string $message, string $type = 'error'): Response
private function back(string $message, string $type = 'error', string $slot = 'header'): Response
{
$this->addFlash($type, $message);
return $this->redirectToRoute('admin_menus');
return $this->redirectToRoute('admin_menus', 'header' === $slot ? [] : ['slot' => $slot]);
}
}
+15 -12
View File
@@ -50,10 +50,14 @@ final class PageController extends AbstractController
$localFonts = [];
// The page decides which header and footer it gets - including none.
$areas = $this->areas($page);
$needsSlider = $needsMotion = false;
if (null !== $page) {
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
@@ -88,7 +92,8 @@ final class PageController extends AbstractController
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $this->hasAutoplaySlider($page['document']),
'needsSlider' => $needsSlider,
'needsMotion' => $needsMotion,
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
@@ -124,7 +129,7 @@ final class PageController extends AbstractController
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
@@ -397,21 +402,19 @@ final class PageController extends AbstractController
return $gefunden;
}
/** True when a slider on this page asked to advance by itself. */
private function hasAutoplaySlider(array $document): bool
private function hasEntrance(array $document): bool
{
$found = false;
$walk = function (array $blocks) use (&$walk, &$found): void {
$walk = function (array $blocks) use (&$walk): bool {
foreach ($blocks as $block) {
if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) {
$found = true;
if ('none' !== ($block['layout']['effects']['entrance'] ?? 'none') || $walk($block['children'] ?? [])) {
return true;
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
return false;
};
return $walk($document['blocks'] ?? []);
}
private function hasType(array $document, string $type): bool
+37 -1
View File
@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SystemController extends AbstractController
{
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir)
{
}
@@ -48,6 +48,8 @@ final class SystemController extends AbstractController
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
// ist die einzige Stelle, an der das jemand merken kann.
'pendingMigrations' => $this->pendingMigrations(),
// Actual runtime connection, with credentials excluded from the screen.
'database' => $this->databaseFacts(),
]);
}
@@ -86,6 +88,40 @@ final class SystemController extends AbstractController
return $this->redirectToRoute('admin_system');
}
/**
* Die Datenbank, wie sie wirklich benutzt wird - samt der Datei, in der das steht.
*
* @return array{art: string, datei: ?string, bytes: ?int, schreibbar: bool, quelle: string, adresse: string}
*/
private function databaseFacts(): array
{
$url = $this->databaseUrl;
$datei = null;
if (1 === preg_match('#^sqlite:///(.+)$#', $url, $treffer)) {
$datei = str_replace(['%kernel.project_dir%', '\\'], [$this->projectDir, '/'], $treffer[1]);
}
return [
'art' => null !== $datei ? 'SQLite' : (str_starts_with($url, 'mysql') ? 'MySQL' : 'unbekannt'),
'datei' => $datei,
'bytes' => null !== $datei && is_file($datei) ? (int) filesize($datei) : null,
// Eine Datei, die PHP nicht beschreiben darf, ist der haeufigste Fehler nach einem
// Upload per FTP: Dort gehoert sie dem FTP-Benutzer, hier laeuft ein anderer.
'schreibbar' => null !== $datei ? (is_writable($datei) && is_writable(\dirname($datei))) : true,
'quelle' => $this->environmentSource('DATABASE_URL'),
// Bei MySQL steht das Passwort in der Adresse - es wird nicht angezeigt.
'adresse' => (string) preg_replace('#://([^:/@]+):[^@]*@#', '://$1:***@', $url),
];
}
/** Explicit server values take precedence over the single installation file. */
private function environmentSource(string $name): string
{
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
}
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
private function pendingMigrations(): array
{
+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)),