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 $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 */ 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 */ 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 */ 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> */ 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 */ 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 */ 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 */ 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> */ private function navigation(string $currentPath): array { $links = []; foreach ($this->menus->all() as $slot => $menu) { $links[$slot] = $this->menus->resolve($menu['items'], $currentPath); } return $links; } }