php bin/console cms:admin:path <neu> legt die Verwaltung auf eine andere Adresse. Geprueft wird, dass der Name frei ist und keine Seite verdeckt; der Wert steht in .env.local und fuellt einen Parameter, der als Praefix in jeder Verwaltungsroute steht, in der Firewall, in den Schutzkopfzeilen und in der Statistik. Die robots.txt nennt einen geaenderten Namen nicht - sie ist oeffentlich und waere sonst ein Wegweiser. Beim Standardnamen bleibt die Zeile, weil diese Adresse ohnehin jeder kennt. Die Auffangroute fuer Seiten steht jetzt zuletzt: sie kann einen frei gewaehlten Namen nicht per Regex ausschliessen, und die Reihenfolge ist ohnehin die richtige. Der Befehl raeumt den Zwischenspeicher auf, aber nicht den, in dem er selbst laeuft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
6.0 KiB
PHP
138 lines
6.0 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 ORDER BY p.path",
|
|
['default'],
|
|
) 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 = ? ORDER BY p.path",
|
|
['default', $type],
|
|
);
|
|
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;
|
|
}
|
|
}
|