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:
kantarciandClaude Fable 5.1 committed 2026-09-15 20:15:20 +02:00
1 parent 45040b2ca6
commit 7eb462ac7b
53 files changed
+1023 -122

No files matched your search

+92
View File
@@ -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'] : '');
}
}
+2 -1
View File
@@ -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]);
+18 -3
View File
@@ -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'];
}
+22 -2
View File
@@ -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']));
+26 -2
View File
@@ -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 '/';
}
+5
View File
@@ -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());
+4 -1
View File
@@ -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.');
}
+3 -1
View File
@@ -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);
}
+7 -3
View File
@@ -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';
+10 -3
View File
@@ -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);
}
+3 -1
View File
@@ -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.
+7 -3
View File
@@ -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.');
}
+22 -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, 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
{
+5 -2
View File
@@ -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'];
}
}
+62 -2
View File
@@ -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
View File
@@ -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;
+15 -2
View File
@@ -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
View File
@@ -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. */
+2 -2
View File
@@ -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. */