Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings
SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber sends the other spelling there with 301, cms:site:origin writes the setting, the system screen shows it. The roles now match the role help (editor: drafts, publisher: live content, admin: design, settings, users, system). The installer no longer reopens on an unreachable database, the session cookie follows ADMIN_PATH, percent-encoded credentials no longer break the container, consent only accepts same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the editor, updater, download and public-output findings from the full review (ADR 099, ADR 100). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
45040b2ca6
commit
7eb462ac7b
53 files changed
+1023
-122
No files matched your search
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Environment;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Zeigt die öffentliche Adresse der Website oder ändert sie.
|
||||
*
|
||||
* Die Adresse (`SITE_ORIGIN`) steht in `.env`, weil sie zur Installation gehört und nicht zum
|
||||
* Inhalt: Der Installer schreibt sie einmal, danach gab es nur den Editor für die Datei. Dieser
|
||||
* Befehl prüft, was hineinkommt - mit Schema, ohne Pfad -, und schreibt es an dieselbe Stelle.
|
||||
*
|
||||
* Mit oder ohne www ist damit eine Entscheidung an einem Ort: Canonical, Sitemap, Feed und die
|
||||
* Umleitung der anderen Schreibweise folgen dieser Einstellung.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:site:origin', description: 'Zeigt die öffentliche Adresse der Website (SITE_ORIGIN) oder ändert sie.')]
|
||||
final class SiteOriginCommand extends Command
|
||||
{
|
||||
public function __construct(private string $projectDir, private string $siteOrigin)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('adresse', InputArgument::OPTIONAL, 'Die neue Adresse mit Schema, z. B. https://www.meine-firma.de. Ohne Angabe wird die aktuelle gezeigt.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$neu = trim((string) $input->getArgument('adresse'));
|
||||
if ('' === $neu) {
|
||||
$io->writeln(sprintf('Die Website ist eingetragen als <info>%s</info>.', '' === $this->siteOrigin ? '(leer)' : $this->siteOrigin));
|
||||
$io->writeln('Ändern: <comment>php bin/console cms:site:origin https://www.meine-firma.de</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$neu = self::normalize($neu);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||||
} catch (\RuntimeException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Die Website ist jetzt eingetragen als %s.', $neu));
|
||||
$io->writeln('Canonical, Sitemap, Feed und strukturierte Daten nennen diese Adresse; die Schreibweise '.(str_starts_with(parse_url($neu, PHP_URL_HOST) ?: '', 'www.') ? 'ohne' : 'mit').' www leitet dorthin um.');
|
||||
$io->writeln('Falls die Website den alten Wert noch kennt: <comment>php bin/console cache:clear --env=prod</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema und Host, kleingeschrieben, ohne Pfad, Abfrage oder abschließenden Schrägstrich.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public static function normalize(string $adresse): string
|
||||
{
|
||||
$adresse = rtrim(trim($adresse), '/');
|
||||
$teile = parse_url($adresse);
|
||||
$schema = strtolower((string) ($teile['scheme'] ?? ''));
|
||||
if (!\in_array($schema, ['http', 'https'], true)) {
|
||||
throw new \InvalidArgumentException('Die Adresse beginnt mit https:// – zum Beispiel https://www.meine-firma.de');
|
||||
}
|
||||
$host = strtolower((string) ($teile['host'] ?? ''));
|
||||
if ('' === $host || false === filter_var($schema.'://'.$host, FILTER_VALIDATE_URL) || isset($teile['user'], $teile['pass'])) {
|
||||
throw new \InvalidArgumentException('Diese Adresse ist keine gültige URL.');
|
||||
}
|
||||
if (isset($teile['path']) && '' !== $teile['path'] || isset($teile['query']) || isset($teile['fragment'])) {
|
||||
throw new \InvalidArgumentException('Nur Schema und Domain, ohne Pfad: https://www.meine-firma.de statt https://www.meine-firma.de/start');
|
||||
}
|
||||
|
||||
return $schema.'://'.$host.(isset($teile['port']) ? ':'.$teile['port'] : '');
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,8 @@ final class CustomCode
|
||||
// so "& .block-text" must stay "#id .block-text" instead of becoming
|
||||
// "#id.block-text", which would target the element itself.
|
||||
$suffix = preg_replace('/\s+/', ' ', rtrim($match[1][0])) ?? '';
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 ,.:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
// Kein Komma: `&,body{...}` wuerde zu `#id,body{...}` und verliesse das Element.
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
||||
}
|
||||
$rules[$suffix] = $this->declarations($match[2][0]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -36,7 +37,7 @@ final class PageRepository
|
||||
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
|
||||
$path = $slug;
|
||||
if (null !== $parentId) {
|
||||
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ?', [$parentId, 'default', $locale]);
|
||||
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $locale]);
|
||||
if (false === $parent) {
|
||||
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
|
||||
}
|
||||
@@ -244,13 +245,19 @@ final class PageRepository
|
||||
* overwritten in place: the restored state becomes a new revision, so the history stays whole.
|
||||
*/
|
||||
public function restoreRevision(string $pageId, string $revisionId, int $expectedVersion): string
|
||||
{
|
||||
return $this->saveDraft($pageId, $this->revisionDocument($pageId, $revisionId), $expectedVersion);
|
||||
}
|
||||
|
||||
/** Das Dokument einer Fassung dieser Seite - und nur dieser Seite. */
|
||||
public function revisionDocument(string $pageId, string $revisionId): array
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
||||
if (false === $raw) {
|
||||
throw new InvalidPage('Diese Fassung gehört nicht zu dieser Seite.');
|
||||
}
|
||||
|
||||
return $this->saveDraft($pageId, json_decode($raw, true, 512, JSON_THROW_ON_ERROR), $expectedVersion);
|
||||
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,7 +273,15 @@ final class PageRepository
|
||||
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
|
||||
['default', gmdate('Y-m-d H:i:s')],
|
||||
) as $row) {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
||||
try {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
||||
} catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) {
|
||||
// Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die
|
||||
// anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin.
|
||||
error_log(sprintf('Geplante Veröffentlichung von /%s zurückgestellt: %s', $row['path'], $e->getMessage()));
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
|
||||
$published[] = $row['path'];
|
||||
}
|
||||
|
||||
@@ -204,13 +204,13 @@ final class StyleCompiler
|
||||
if ('' !== $burger) {
|
||||
$rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($this->inheritShadow($values['hover'], $values), 'self', $device) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
}
|
||||
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
|
||||
// The change eases in, so a header does not snap from glass to solid.
|
||||
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($values['scrolled'], 'self', $device) : '';
|
||||
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : '';
|
||||
if ('' !== $scrolled) {
|
||||
$rules .= sprintf('#%s{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #%s{%s}', $id, $id, $scrolled);
|
||||
}
|
||||
@@ -327,6 +327,26 @@ final class StyleCompiler
|
||||
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Zustand, der nur die Schattenfarbe aendert, erbt Versatz, Weichzeichnung und Ausdehnung
|
||||
* vom Grundzustand - sonst hiesse er `box-shadow:0 0 0 0 <farbe>` und waere unsichtbar.
|
||||
*/
|
||||
private function inheritShadow(array $state, array $base): array
|
||||
{
|
||||
foreach (['shadow' => ['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowInset'], 'textShadow' => ['textShadowX', 'textShadowY', 'textShadowBlur']] as $colour => $parts) {
|
||||
if (!isset($state[$colour.'Color']) && [] === array_intersect_key($state, array_flip($parts))) {
|
||||
continue;
|
||||
}
|
||||
foreach ($parts as $part) {
|
||||
if (!isset($state[$part]) && isset($base[$part])) {
|
||||
$state[$part] = $base[$part];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function shadow(array $values): string
|
||||
{
|
||||
$parts = array_intersect_key($values, array_flip(['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowColor']));
|
||||
|
||||
@@ -34,11 +34,33 @@ final class ConsentController extends AbstractController
|
||||
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
|
||||
};
|
||||
$response = $this->redirect($this->backTo($request));
|
||||
$this->consent->remember($request, $response, $groups);
|
||||
// Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular,
|
||||
// das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und
|
||||
// damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage.
|
||||
if ($this->sameSite($request)) {
|
||||
$this->consent->remember($request, $response, $groups);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** Ob die Anfrage von dieser Website kommt - nach dem, was der Browser darueber sagt. */
|
||||
private function sameSite(Request $request): bool
|
||||
{
|
||||
$site = $request->headers->get('Sec-Fetch-Site');
|
||||
if (null !== $site) {
|
||||
return \in_array($site, ['same-origin', 'same-site', 'none'], true);
|
||||
}
|
||||
$origin = $request->headers->get('Origin') ?? $request->headers->get('Referer');
|
||||
if (null === $origin || '' === $origin) {
|
||||
// Aeltere Browser nennen keine Herkunft; ohne Anhaltspunkt bleibt die Frage beantwortbar.
|
||||
return true;
|
||||
}
|
||||
$host = parse_url($origin, PHP_URL_HOST);
|
||||
|
||||
return \is_string($host) && strtolower($host) === strtolower($request->getHost());
|
||||
}
|
||||
|
||||
#[Route('/einwilligung', name: 'consent_page', methods: ['GET'])]
|
||||
public function page(Request $request): Response
|
||||
{
|
||||
@@ -73,7 +95,9 @@ final class ConsentController extends AbstractController
|
||||
private function backTo(Request $request): string
|
||||
{
|
||||
$target = $request->request->getString('back');
|
||||
if ('' === $target || !str_starts_with($target, '/') || str_starts_with($target, '//')) {
|
||||
// Nur ein Pfad dieser Website: `//fremd.example` ist protokollrelativ, und `/\fremd.example`
|
||||
// lesen Browser genauso.
|
||||
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target)) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,11 @@ final class EditorController extends AbstractController
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
// Dieselbe Regel wie beim Speichern: Skripte aendert nur ein Administrator - auch nicht
|
||||
// ueber den Umweg einer alten Fassung.
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
}
|
||||
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
|
||||
@@ -21,6 +21,9 @@ final class FormAdminController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer
|
||||
// Rolle, die nicht einmal eine Seite veroeffentlichen darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
@@ -37,7 +40,7 @@ final class FormAdminController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
|
||||
public function save(Request $request, string $slug): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('forms', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ final class MenuController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Ein gespeichertes Menue steht sofort auf der Website - Sache des Veroeffentlichers.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$slot = $request->query->getString('slot', 'header');
|
||||
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
|
||||
$menu = $this->menus->all()[$slot] ?? null;
|
||||
@@ -43,7 +45,7 @@ final class MenuController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
|
||||
public function save(Request $request, string $slot): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ final class PageController extends AbstractController
|
||||
$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));
|
||||
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
|
||||
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
|
||||
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
|
||||
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
|
||||
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
@@ -111,7 +115,7 @@ final class PageController extends AbstractController
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
'needsTabs' => $needsTabs,
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
@@ -119,7 +123,7 @@ final class PageController extends AbstractController
|
||||
'needsScrolled' => $needsScrolled,
|
||||
// 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)),
|
||||
'needsZoom' => $needsZoom,
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
@@ -145,7 +149,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 = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $needsScrolled || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
|
||||
@@ -44,6 +44,9 @@ final class PartialController extends AbstractController
|
||||
#[Route('', name: 'admin_partials', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Ein Bereich kennt keinen Entwurf: Speichern ist Veroeffentlichen. Deshalb ist der ganze
|
||||
// Bildschirm Sache des Veroeffentlichers, nicht erst der Speicherknopf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$partials = [];
|
||||
foreach ($this->partials->all() as $partial) {
|
||||
$partial['usedBy'] = 'section' === $partial['kind'] ? $this->partials->usedBy($partial['slug']) : [];
|
||||
@@ -56,7 +59,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/new', name: 'admin_partial_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -81,7 +84,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}/delete', name: 'admin_partial_delete', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -97,12 +100,16 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}', name: 'admin_partial_editor', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function editor(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
|
||||
return $this->render('admin/editor.html.twig', ['partial' => $this->partial($id), 'kinds' => PartialRepository::KINDS]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/document', name: 'admin_partial_document', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function document(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
|
||||
return $this->json($this->payload($this->partial($id)));
|
||||
}
|
||||
|
||||
@@ -110,7 +117,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}/preview', name: 'admin_partial_preview', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ final class SeoController extends AbstractController
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
||||
'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10),
|
||||
// Das juengere der beiden Daten: eine spaeter bearbeitete und neu veroeffentlichte
|
||||
// Seite behielte sonst das Datum ihrer ersten Veroeffentlichung.
|
||||
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
||||
];
|
||||
}
|
||||
// Archives exist only while they list something.
|
||||
|
||||
@@ -27,6 +27,10 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Alles hier wirkt sofort auf die Website: 404-Seite und Weiterleitungen sind Sache des
|
||||
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
|
||||
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$choosable = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||||
@@ -57,7 +61,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -101,7 +105,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -133,7 +137,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||||
public function legalPages(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
|
||||
@@ -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, private string $databaseUrl, private string $mediaDirectory, private string $projectDir)
|
||||
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, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ final class SystemController extends AbstractController
|
||||
'pendingMigrations' => $this->pendingMigrations(),
|
||||
// Actual runtime connection, with credentials excluded from the screen.
|
||||
'database' => $this->databaseFacts(),
|
||||
// Die Adresse, die Sitemap, Feed und Canonical nennen - und ob die Verwaltung gerade
|
||||
// unter ihr laeuft. Die Schreibweise mit oder ohne www leitet die Anwendung selbst um;
|
||||
// ein ganz anderer Host (Vorschauadresse des Hosters, IP) bleibt erreichbar und
|
||||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||||
'origin' => $this->originFacts($request),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -114,6 +119,22 @@ final class SystemController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{adresse: string, quelle: string, host: string, passt: bool}
|
||||
*/
|
||||
private function originFacts(Request $request): array
|
||||
{
|
||||
$wanted = strtolower((string) parse_url(trim($this->siteOrigin), PHP_URL_HOST));
|
||||
$port = parse_url(trim($this->siteOrigin), PHP_URL_PORT);
|
||||
|
||||
return [
|
||||
'adresse' => $this->siteOrigin,
|
||||
'quelle' => $this->environmentSource('SITE_ORIGIN'),
|
||||
'host' => $request->getHttpHost(),
|
||||
'passt' => '' !== $wanted && strtolower($request->getHost()) === $wanted && (null === $port || $request->getPort() === $port),
|
||||
];
|
||||
}
|
||||
|
||||
/** Explicit server values take precedence over the single installation file. */
|
||||
private function environmentSource(string $name): string
|
||||
{
|
||||
|
||||
@@ -20,6 +20,9 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Das Design gilt sofort auf jeder Seite - das ist Administratorensache, wie die
|
||||
// Rollenhilfe es verspricht.
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
@@ -46,7 +49,7 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
|
||||
public function fonts(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
@@ -75,7 +78,7 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Eine Website, eine Adresse: mit oder ohne www entscheidet SITE_ORIGIN, nicht der Webserver.
|
||||
*
|
||||
* Bisher stand die Umleitung auskommentiert in public/.htaccess und musste von Hand zu SITE_ORIGIN
|
||||
* passen. Zwei Stellen fuer eine Entscheidung gehen auseinander, und dann zeigt jede Seite in
|
||||
* Canonical, Sitemap und Feed auf eine Adresse, die weiterleitet. Jetzt liest die Anwendung die
|
||||
* konfigurierte Adresse und schickt die andere Schreibweise dorthin - dauerhaft (301), mit Pfad
|
||||
* und Abfrage.
|
||||
*
|
||||
* Was sie bewusst nicht tut: einen fremden Host umleiten. Eine Vorschauadresse des Hosters, eine
|
||||
* IP-Adresse oder localhost bleiben erreichbar, auch wenn SITE_ORIGIN etwas anderes nennt - sonst
|
||||
* sperrt eine falsch eingetragene Adresse jeden aus, samt der Verwaltung. Umgeleitet wird nur das
|
||||
* Paar aus `example.de` und `www.example.de`. Und sie erzwingt kein HTTPS: hinter einem Proxy
|
||||
* sieht PHP oft nur http, und eine Schleife waere schlimmer als fehlendes HTTPS. Die drei Zeilen
|
||||
* dafuer stehen weiter in .htaccess, wo der Webserver die Verbindung wirklich kennt.
|
||||
*/
|
||||
final class CanonicalHostSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Vor Wartung (40) und Installer (36): auf dem falschen Host gibt es genau eine Antwort,
|
||||
// und fuer die braucht niemand die Datenbank.
|
||||
return [KernelEvents::REQUEST => ['onRequest', 48]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
// Nur lesende Anfragen: ein 301 macht aus einem POST ein GET und verwirft die Eingabe.
|
||||
// Ein Formular auf der anderen Schreibweise darf noch ankommen; die Seite dahinter
|
||||
// fuehrt dann ohnehin auf die richtige Adresse.
|
||||
if (!$event->isMainRequest() || !\in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
|
||||
return;
|
||||
}
|
||||
$target = self::target($this->siteOrigin, $request);
|
||||
if (null !== $target) {
|
||||
$event->setResponse(new RedirectResponse($target, 301, ['Cache-Control' => 'public, max-age=3600']));
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Adresse, auf die diese Anfrage gehoert - oder null, wenn sie schon dort ist. */
|
||||
public static function target(string $origin, Request $request): ?string
|
||||
{
|
||||
$parts = parse_url(trim($origin));
|
||||
$wanted = strtolower((string) ($parts['host'] ?? ''));
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
if ('' === $wanted || !\in_array($scheme, ['http', 'https'], true)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$host = strtolower($request->getHost());
|
||||
} catch (\UnexpectedValueException) {
|
||||
// Ein Host, den Symfony nicht annimmt, bekommt seine 400 vom Kernel - nicht von hier.
|
||||
return null;
|
||||
}
|
||||
if ($host === $wanted || !self::wwwTwins($host, $wanted)) {
|
||||
return null;
|
||||
}
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
|
||||
return $scheme.'://'.$wanted.$port.$request->getRequestUri();
|
||||
}
|
||||
|
||||
/** `example.de` und `www.example.de` - und sonst nichts. */
|
||||
private static function wwwTwins(string $a, string $b): bool
|
||||
{
|
||||
return 'www.'.$a === $b || $a === 'www.'.$b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
|
||||
|
||||
/**
|
||||
* `%env(projectdir:DATABASE_URL)%`: ersetzt `%kernel.project_dir%` in einer Umgebungsvariablen -
|
||||
* und sonst nichts.
|
||||
*
|
||||
* Symfonys `resolve:` tut mehr: es liest jedes `%name%` als Parameter. In einer MySQL-Adresse
|
||||
* stehen Benutzer und Passwort prozentkodiert (`p%40ss` fuer `p@ss`), und `%40ss%` ist dann ein
|
||||
* Parameter, den es nicht gibt - der Container bricht bei jeder Anfrage ab, nachdem der Installer
|
||||
* die Adresse geschrieben hat. Dieser Prozessor kennt nur den einen Platzhalter, den `.env.example`
|
||||
* und der Installer fuer SQLite verwenden.
|
||||
*/
|
||||
final class ProjectDirEnvProcessor implements EnvVarProcessorInterface
|
||||
{
|
||||
public function __construct(private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public function getEnv(string $prefix, string $name, \Closure $getEnv): string
|
||||
{
|
||||
$value = $getEnv($name);
|
||||
|
||||
return str_replace('%kernel.project_dir%', $this->projectDir, (string) $value);
|
||||
}
|
||||
|
||||
public static function getProvidedTypes(): array
|
||||
{
|
||||
return ['projectdir' => 'string'];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,66 @@ final class CommandRunner
|
||||
return !in_array('proc_open', $disabled, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was ein Kindprozess von der Umgebung sehen darf: Pfade, ein Zuhause fuer Composer und Git,
|
||||
* aber kein APP_ENV, das einen Konsolenbefehl gegen die falsche Umgebung laufen liesse.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function environment(): array
|
||||
{
|
||||
$environment = ['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir(), 'TMP' => sys_get_temp_dir()];
|
||||
// Composer verlangt HOME (Linux) oder APPDATA (Windows) - ohne sie bricht `composer
|
||||
// install` ab, waehrend `composer --version` noch laeuft. Git braucht HOME fuer .gitconfig.
|
||||
foreach (['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'COMPOSER_HOME', 'PATHEXT', 'LANG', 'LC_ALL'] as $name) {
|
||||
$value = getenv($name);
|
||||
if (is_string($value) && '' !== $value) {
|
||||
$environment[$name] = $value;
|
||||
}
|
||||
}
|
||||
if (!isset($environment['HOME']) && !isset($environment['USERPROFILE'])) {
|
||||
// Unter php-fpm ist HOME oft leer; ein Ordner in var/ tut es fuer Composer und Git.
|
||||
$environment['HOME'] = sys_get_temp_dir();
|
||||
}
|
||||
if (!isset($environment['COMPOSER_HOME'])) {
|
||||
$environment['COMPOSER_HOME'] = $environment['HOME'] ?? sys_get_temp_dir();
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die PHP-Kommandozeile, mit der Konsolenbefehle laufen.
|
||||
*
|
||||
* Aus einem Webprozess ist PHP_BINARY der Server selbst (php-fpm) oder leer (mod_php) - und
|
||||
* `php-fpm bin/console` startet keinen Befehl. Dann wird die Kommandozeile im Pfad gesucht.
|
||||
*/
|
||||
public function php(): string
|
||||
{
|
||||
if (isset($this->resolved['php'])) {
|
||||
return $this->resolved['php'][0];
|
||||
}
|
||||
$candidates = [];
|
||||
if ('cli' === \PHP_SAPI && '' !== \PHP_BINARY) {
|
||||
$candidates[] = \PHP_BINARY;
|
||||
}
|
||||
$suffix = '\\' === \DIRECTORY_SEPARATOR ? '.exe' : '';
|
||||
foreach ([\PHP_BINDIR.'/php'.$suffix, \PHP_BINDIR.'/php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, 'php'.$suffix, 'php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, \PHP_BINARY] as $candidate) {
|
||||
if ('' !== $candidate && !\in_array($candidate, $candidates, true)) {
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([$candidate, '-r', 'echo PHP_VERSION;'], sys_get_temp_dir(), 30)['ok']) {
|
||||
$this->resolved['php'] = [$candidate];
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0] ?? 'php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $command
|
||||
*
|
||||
@@ -43,7 +103,7 @@ final class CommandRunner
|
||||
$cwd,
|
||||
// A minimal, predictable environment: no inherited APP_ENV that would make a console
|
||||
// command run against the wrong environment.
|
||||
['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir()],
|
||||
$this->environment(),
|
||||
);
|
||||
if (!is_resource($process)) {
|
||||
return ['ok' => false, 'exit' => -1, 'output' => sprintf('%s konnte nicht gestartet werden.', $command[0])];
|
||||
@@ -109,7 +169,7 @@ final class CommandRunner
|
||||
}
|
||||
// A phar in the project is the way on hosting where nothing is installed globally.
|
||||
if (is_file($cwd.'/'.$executable.'.phar')) {
|
||||
$candidates[] = [PHP_BINARY, $cwd.'/'.$executable.'.phar'];
|
||||
$candidates[] = [$this->php(), $cwd.'/'.$executable.'.phar'];
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
|
||||
|
||||
+21
-6
@@ -63,6 +63,10 @@ class Download
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
// Auch eine Weiterleitung bleibt bei HTTPS: sonst pruefte die erste Zeile nur die
|
||||
// erste Adresse, und die Gegenstelle koennte auf http:// umlenken.
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
@@ -96,12 +100,23 @@ class Download
|
||||
|
||||
return null;
|
||||
}
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => $this->timeout, 'follow_location' => 1, 'max_redirects' => 3, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
if (is_string($antwort)) {
|
||||
// Der Dateizeiger folgt Weiterleitungen auch auf http://; deshalb hier von Hand, und
|
||||
// jede Station muss HTTPS sein.
|
||||
$antwort = false;
|
||||
for ($hop = 0; $hop <= 3 && str_starts_with($url, 'https://'); ++$hop) {
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => $this->timeout, 'follow_location' => 0, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
$kopf = implode("\n", $http_response_header ?? []);
|
||||
if (!is_string($antwort) || 1 !== preg_match('/^HTTP\/\S+ 3\d\d/m', $kopf) || 1 !== preg_match('/^Location:\s*(\S+)/mi', $kopf, $ziel)) {
|
||||
break;
|
||||
}
|
||||
$url = $ziel[1];
|
||||
$antwort = false;
|
||||
}
|
||||
if (is_string($antwort) && 1 === preg_match('/^HTTP\/\S+ 200/m', implode("\n", $http_response_header ?? []))) {
|
||||
$this->grund = '';
|
||||
|
||||
return $antwort;
|
||||
|
||||
@@ -9,8 +9,10 @@ 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;
|
||||
@@ -57,11 +59,22 @@ final class Installation
|
||||
*/
|
||||
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 (\Throwable) {
|
||||
// No database, no table, no installation - all three mean the same here.
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -29,7 +29,7 @@ final class Updater
|
||||
private string $databaseUrl,
|
||||
private CommandRunner $runner,
|
||||
private Connection $db,
|
||||
private string $php = PHP_BINARY,
|
||||
private ?string $php = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -288,7 +288,12 @@ final class Updater
|
||||
if (!$step($steps, 'Rauchtest', $health['ok'], $this->lastLine($health['output']))) {
|
||||
throw new \RuntimeException('health');
|
||||
}
|
||||
} catch (\RuntimeException) {
|
||||
} catch (\Throwable $error) {
|
||||
// Nicht nur die eigenen Abbrueche: jeder Fehler auf dem Weg nimmt den Rueckweg, statt
|
||||
// den Wartungszustand ohne ein Wort stehen zu lassen.
|
||||
if (!$error instanceof \RuntimeException) {
|
||||
$step($steps, 'Unerwarteter Fehler', false, $error->getMessage());
|
||||
}
|
||||
$this->rollback($steps, $from, $migrated ? $backup : null, $step);
|
||||
$this->write($steps, $target, false);
|
||||
|
||||
@@ -438,7 +443,7 @@ final class Updater
|
||||
/** @return array{ok: bool, exit: int, output: string} */
|
||||
private function console(array $arguments, int $timeout): array
|
||||
{
|
||||
return $this->runner->run([$this->php, ...$arguments], $this->root, $timeout);
|
||||
return $this->runner->run([$this->php ??= $this->runner->php(), ...$arguments], $this->root, $timeout);
|
||||
}
|
||||
|
||||
/** The SQLite file behind DATABASE_URL, or null for a server database. */
|
||||
|
||||
@@ -32,8 +32,8 @@ final class UserDirectory
|
||||
|
||||
public const ROLE_HELP = [
|
||||
'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, aber keine Benutzer verwalten und keine Skripte setzen.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe bearbeiten und speichern, aber nicht veröffentlichen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, dazu Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite – aber keine Benutzer, kein Design, keine Unternehmensdaten und keine Skripte.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen.',
|
||||
];
|
||||
|
||||
/** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */
|
||||
|
||||
Reference in new issue
Block a user