Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
This commit is contained in:
1 parent
b164c4edfe
commit
495b26896e
55 files changed
+1632
-423
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user