Files
KantSites/src/Controller/PageController.php
T
kantarciandClaude Opus 5 af3589c95c
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Seitenbreite wirkt, vier Menueplaetze, Seiten ohne Kopfbereich
Drei Fragen, drei unterschiedliche Antworten.

Die globale Seitenbreite war einstellbar und wirkte nur in eine Richtung:
site.css schrieb width:min(1180px,...), also schluckte die 1180 jeden
groesseren Wert aus dem Designbereich. Schmaler ging, breiter nicht, ohne
jede Meldung. Die Zahl kommt jetzt aus dem Theme. Das ist der dritte Fehler
dieser Art an einem Tag - nach der Rahmenstaerke und der Bildrundung -, und
alle drei hatten dieselbe Ursache: CSS, das nie gegen eine Erwartung geprueft
wurde.

Menueplaetze und Menueart waren vermischt. Wo ein Menue steht, bestimmt der
Block und war immer frei. Welche Eintraege es zeigt, bestimmt der Platz -
davon gab es zwei, jetzt vier (Haupt, zweite, Fusszeile, Rechtliches). Wie es
aussieht, war gar nicht einstellbar: jetzt waagerecht, untereinander oder
aufklappbar als Burger. Der Burger ist ein details-Element, denn ein Menue,
das ein Skript zum Oeffnen braucht, geht auf einer Seite ohne Skript nicht auf.

"Seiten ohne Menue" war nicht moeglich - der Kopfbereich war ein Dokument fuer
alle Seiten. Jetzt waehlt jede Seite: Standard, ein anderer Bereich, oder
keiner. Dafuer musste die Regel "es gibt genau einen Kopf- und Fussbereich"
fallen; sie war richtig, solange niemand waehlen konnte, und wurde falsch,
sobald man waehlen kann. Ein bestehender Test, der diese Regel zusicherte, ist
deshalb umgeschrieben und prueft jetzt die neue.

Gespeichert wird die Wahl, nicht das Ergebnis: leer heisst Standard, none
heisst keiner, sonst die Adresse. Ein Verweis auf einen geloeschten Bereich
faellt auf den Standard zurueck, statt die Seite stillschweigend ohne
Navigation auszuliefern.

Neu im Ausbauplan: ein einzelnes install.php, das das Releasearchiv
herunterlaedt und entpackt, damit niemand tausende Dateien per FTP hochladen
muss. Mit den Regeln, die darueber entscheiden, ob das eine Hilfe oder eine
Einladung ist: Adresse und SHA-256 stehen in der Datei und nicht im Formular,
HTTPS mit Zertifikatspruefung, Schutz gegen ZIP-Slip, Selbstloeschung danach.

336 PHP-Tests / 1354 Assertions, 17 JS-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:15:03 +02:00

447 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controller;
use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\StyleCompiler;
use App\Content\Theme;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
{
}
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
public function page(Request $request, string $path = ''): Response
{
$home = $this->settings->homePath();
$path = '' === $path ? $home : $path;
// One canonical address for the root page: its own path redirects to "/".
if ($path === $home && '/' !== $request->getPathInfo()) {
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($path);
$styles = '';
$googleFonts = null;
// The page decides which header and footer it gets - including none.
$areas = $this->areas($page);
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);
$styles = $this->theme->css($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);
}
$googleFonts = $this->theme->googleFontsUrl($theme);
}
if (null === $page) {
// An address that moved or was deliberately removed answers before the 404 does.
$redirect = $this->redirects->find($path);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
}
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
} else {
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document']),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// 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',
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
'breadcrumbs' => $this->breadcrumbs($page),
'structuredData' => $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
]);
}
$wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
}
/**
* Every public response carries the same headers. Scripting stays forbidden unless the page
* actually contains a tab group: only then may the browser load the one enhancement script,
* and only from this origin. Inline script is never allowed.
*
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
*/
/**
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
* Nothing is added to the policy for an embed still waiting
* for consent.
*/
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false, array $frameHosts = []): Response
{
$styleSources = ["'self'"];
if ('' !== $styles) {
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
}
$fontSources = ["'self'"];
if ($googleFonts) {
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
$styleSources[] = 'https://fonts.googleapis.com';
$fontSources[] = 'https://fonts.gstatic.com';
}
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Content-Security-Policy', sprintf(
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
$allowScript ? "'self'" : "'none'",
implode(' ', $styleSources),
implode(' ', $fontSources),
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
));
return $response;
}
/**
* Prepares one state per form block: the definition, whether this request just sent it, and a
* signed timestamp the submission route uses to tell a person from a script.
*
* @return array<string, array>
*/
private function formStates(array $page, string $sentSlug): array
{
$issued = time();
$states = [];
$walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null)) {
$slug = $block['props']['form'] ?? '';
$states[$block['id']] = [
'form' => $this->forms->findBySlug($slug),
'sent' => '' !== $sentSlug && $sentSlug === $slug,
'errors' => [], 'values' => [],
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
];
}
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
return $states;
}
/**
* The trail from the home page down to this one, resolved from the stored hierarchy rather
* than from content, so it cannot claim a page that does not exist.
*
* @return list<array{name: string, href: string}>
*/
private function breadcrumbs(array $page): array
{
if ($page['path'] === $this->settings->homePath()) {
return [];
}
$trail = [];
$prefix = '';
foreach (explode('/', (string) $page['path']) as $segment) {
$prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
$ancestor = $this->pages->findPublished($prefix);
if (null !== $ancestor) {
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$prefix];
}
}
return $trail;
}
/**
* JSON-LD built from values that are visible on the page or maintained in the settings.
* Nothing is invented here; a field without a maintained value is left out.
*
* @return list<array>
*/
private function structuredData(array $page): array
{
$origin = rtrim($this->siteOrigin, '/');
$blocks = [];
if ($page['path'] === $this->settings->homePath()) {
$organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']);
if (null !== $organisation) {
$blocks[] = $organisation;
}
}
if ('post' === $page['type']) {
$article = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110),
'mainEntityOfPage' => $origin.'/'.$page['path'],
];
foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) {
if ('' !== (string) $value && null !== $value) {
$article[$key] = 'datePublished' === $key ? str_replace(' ', 'T', (string) $value).'Z' : (string) $value;
}
}
if (null !== ($page['author'] ?? null) && '' !== $page['author']) {
$article['author'] = ['@type' => 'Person', 'name' => $page['author']];
}
$blocks[] = $article;
}
$trail = $this->breadcrumbs($page);
if (count($trail) > 1) {
$blocks[] = [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => array_map(
static fn (int $index, array $item): array => ['@type' => 'ListItem', 'position' => $index + 1, 'name' => $item['name'], 'item' => $origin.$item['href']],
array_keys($trail),
$trail,
),
];
}
return $blocks;
}
/** True when the stored document contains at least one block of that type. */
private function hasType(array $document, string $type): bool
{
$found = false;
$walk = function (array $blocks) use (&$walk, &$found, $type): void {
foreach ($blocks as $block) {
$found = $found || $type === ($block['type'] ?? null);
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
}
#[Route('/beitraege', name: 'posts', methods: ['GET'])]
#[Route('/kategorie/{slug}', name: 'category', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
#[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
public function archive(Request $request, ?string $slug = null): Response
{
$kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag';
$term = null;
if (null !== $slug) {
$term = $this->posts->findTerm($kind, $slug);
if (null === $term) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
}
}
$page = max(1, $request->query->getInt('seite', 1));
$result = $this->posts->published($page, $term['id'] ?? null);
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$frames = $this->frameHosts($request, ...array_values($this->areas()));
return $this->shell($this->render('archive.html.twig', [
'areas' => $this->areas(),
'navigation' => $this->navigation(''),
'term' => $term,
'heading' => null === $term ? 'Beiträge' : $term['name'],
'result' => $result,
'base' => $base,
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
'navigation' => $this->navigation(''),
]), frameHosts: $frames);
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
public function feed(): Response
{
$result = $this->posts->published(1, null, 20);
$response = $this->shell($this->render('feed.xml.twig', [
'posts' => $result['posts'],
'origin' => rtrim($this->siteOrigin, '/'),
]));
$response->headers->set('Content-Type', 'application/xml; charset=UTF-8');
return $response;
}
/**
* Collects the posts each post list block needs. Stored content never carries the posts
* themselves, so a saved document cannot claim a post it is not allowed to show.
*
* @return array<string, list<array>>
*/
private function postLists(array $document): array
{
$lists = [];
$walk = function (array $blocks) use (&$walk, &$lists): void {
foreach ($blocks as $block) {
if ('postlist' === ($block['type'] ?? null)) {
$props = $block['props'] ?? [];
$term = null;
foreach (['category', 'tag'] as $kind) {
if ('' !== ($props[$kind] ?? '')) {
$term = $this->posts->findTerm($kind, $props[$kind]);
}
}
$lists[$block['id']] = $this->posts->published(1, $term['id'] ?? null, $props['count'])['posts'];
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $lists;
}
/**
* The own scripts of one published page, as a single file.
*
* A file instead of inline markup, so the policy stays hash based and every page without a
* script keeps `script-src 'none'`. Only published content is served: a draft script must not
* run for a visitor.
*/
#[Route('/assets/seite/{id}.js', name: 'page_script', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['GET'])]
public function pageScript(string $id): Response
{
$row = $this->pages->findPublishedById($id);
$script = null === $row ? '' : $this->code->compileScript($row['document']);
if ('' === $script) {
throw $this->createNotFoundException('Diese Seite hat kein eigenes Skript.');
}
$response = new Response($script, 200, ['Content-Type' => 'text/javascript; charset=UTF-8']);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Cache-Control', 'no-store');
return $response;
}
/**
* The hosts of embeds that may load on this request.
*
* An embed whose group is not granted contributes nothing: its host must not appear in the
* policy, because the frame is not rendered either.
*
* @return list<string>
*/
private function frameHosts(Request $request, array ...$documents): array
{
$hosts = [];
$walk = function (array $blocks) use (&$walk, &$hosts, $request): void {
foreach ($blocks as $block) {
if ('embed' === ($block['type'] ?? null) && $this->consent->granted($request, $block['props']['group'] ?? '')) {
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
if (is_string($host)) {
$hosts[$host] = true;
}
}
$walk($block['children'] ?? []);
}
};
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
return array_keys($hosts);
}
/**
* The header and footer documents. Absent areas simply render nothing, so a site without a
* header is a valid site rather than a broken one.
*
* @return array<string, array>
*/
private function areas(?array $page = null): array
{
$areas = [];
foreach (['header', 'footer'] as $kind) {
// A page may ask for another area or for none at all: a landing page without a
// navigation is a normal wish, and it must not need a second header document that
// happens to be empty.
$choice = $page[$kind.'_area'] ?? null;
if ('none' === $choice) {
continue;
}
$partial = $this->partials->findByKind($kind, is_string($choice) && '' !== $choice ? $choice : 'default');
// A page pointing at an area that no longer exists falls back to the standard one
// rather than losing its header without saying so.
$partial ??= $this->partials->findByKind($kind);
if (null !== $partial) {
$areas[$kind] = $partial['document'];
}
}
return $areas;
}
/**
* Resolves every linked section a page or one of its areas refers to, including sections that
* link further sections. A missing section is left out; the renderer shows a placeholder only
* in the editor, so a visitor never sees a gap explained.
*
* @return array<string, array>
*/
private function sections(array $document, array $areas): array
{
$resolved = [];
$pending = $this->partials->referencedSlugs($document);
foreach ($areas as $area) {
$pending = [...$pending, ...$this->partials->referencedSlugs($area)];
}
for ($depth = 0; $depth < PartialRepository::MAX_NESTING && [] !== $pending; ++$depth) {
$next = [];
foreach (array_unique($pending) as $slug) {
if (isset($resolved[$slug])) {
continue;
}
$section = $this->partials->resolveSection($slug);
if (null === $section) {
continue;
}
$resolved[$slug] = $section['document'];
$next = [...$next, ...$this->partials->referencedSlugs($section['document'])];
}
$pending = $next;
}
return $resolved;
}
/**
* @return array<string, list<array>>
*/
private function navigation(string $currentPath): array
{
$links = [];
foreach ($this->menus->all() as $slot => $menu) {
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath);
}
return $links;
}
}