Files
KantSites/src/Controller/SeoController.php
T
kantarci 16da844934
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
feat: assign custom 404 pages with the website theme (1.8.0)
2026-09-14 12:18:33 +02:00

138 lines
6.1 KiB
PHP

<?php
namespace App\Controller;
use App\Content\PostRepository;
use App\Content\SiteSettings;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The machine readable surface of a site: sitemap, robots and the optional llms.txt. Everything
* here is derived from published content, never maintained separately, so it cannot drift apart
* from what a visitor actually sees.
*/
final class SeoController extends AbstractController
{
public function __construct(
private Connection $db,
private SiteSettings $settings,
private PostRepository $posts,
private string $siteOrigin,
private string $adminPath,
) {
}
#[Route('/sitemap.xml', name: 'sitemap', methods: ['GET'])]
public function sitemap(): Response
{
$origin = rtrim($this->siteOrigin, '/');
$home = $this->settings->homePath();
$entries = [];
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
// sitemap would ask a search engine to fetch something it must not list.
foreach ($this->db->fetchAllAssociative(
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY p.path",
['default', $this->settings->load()['notFoundPageId']],
) as $row) {
$entries[] = [
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10),
];
}
// Archives exist only while they list something.
if ([] !== $this->posts->published(1, null, 1)['posts']) {
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
}
foreach ($this->db->fetchAllAssociative(
'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL ORDER BY t.kind, t.slug',
) as $row) {
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
}
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
}
#[Route('/robots.txt', name: 'robots', methods: ['GET'])]
public function robots(): Response
{
$settings = $this->settings->load();
// Die Verwaltung wird hier nur genannt, solange sie „admin" heisst - diese Adresse kennt
// ohnehin jeder Scanner. Wer sie umbenannt hat, will sie nicht in einer oeffentlichen
// Datei wiederfinden: robots.txt ist das erste, was ein Angreifer liest. Vom Indexieren
// haelt die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
$lines = ['User-agent: *'];
if ('admin' === $this->adminPath) {
$lines[] = 'Disallow: /admin';
}
$lines[] = 'Disallow: /formular/';
if ('' !== $settings['robotsExtra']) {
$lines[] = $settings['robotsExtra'];
}
$lines[] = '';
$lines[] = 'Sitemap: '.rtrim($this->siteOrigin, '/').'/sitemap.xml';
return $this->text(implode("\n", $lines)."\n");
}
/**
* A machine readable summary of public content. Optional and switched off by default. It is
* no ranking factor and no requirement for any search product; it simply saves an agent the
* crawl. Only published, indexable pages appear.
*/
#[Route('/llms.txt', name: 'llms', methods: ['GET'])]
public function llms(): Response
{
$settings = $this->settings->load();
if (!$settings['llmsTxt']) {
throw $this->createNotFoundException('llms.txt ist für diese Website nicht aktiviert.');
}
$origin = rtrim($this->siteOrigin, '/');
$home = $settings['homePath'];
$name = (string) $this->db->fetchOne('SELECT name FROM website WHERE id = ?', ['default']);
$lines = ['# '.('' !== $settings['legalName'] ? $settings['legalName'] : $name), ''];
if ('' !== $settings['description']) {
$lines[] = '> '.$settings['description'];
$lines[] = '';
}
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
$rows = $this->db->fetchAllAssociative(
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? AND p.id <> ? ORDER BY p.path",
['default', $type, $settings['notFoundPageId']],
);
if ([] === $rows) {
continue;
}
$lines[] = $heading;
foreach ($rows as $row) {
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
$summary = trim((string) ($row['excerpt'] ?? $document['description'] ?? ''));
$lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.$this->settings->publicPath($row['path'], $home), '' === $summary ? '' : ': '.$summary);
}
$lines[] = '';
}
return $this->text(implode("\n", $lines));
}
private function text(string $body): Response
{
return $this->harden(new Response($body, 200, ['Content-Type' => 'text/plain; charset=UTF-8']));
}
private function xml(string $body): Response
{
return $this->harden(new Response($body, 200, ['Content-Type' => 'application/xml; charset=UTF-8']));
}
private function harden(Response $response): Response
{
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Cache-Control', 'no-store');
return $response;
}
}