Migration 20260912000800 ergänzt page.robots, page.og_media_id, website.settings und die Tabelle redirect. Alles Maschinenlesbare wird aus dem veröffentlichten Bestand abgeleitet, nichts separat gepflegt: die Sitemap listet nur veröffentlichte, indexierbare Seiten samt Beitragsübersicht und Begriffsarchiven, und dieselbe Einstellung setzt auch das noindex-Meta - beide Aussagen können nicht auseinanderlaufen. Strukturierte Daten entstehen nur aus gepflegten Werten: ohne Angaben wird kein leerer Organization-Block ausgegeben. JSON-LD ist ein Datenblock, kein Skript, daher bleibt script-src 'none' unberührt. Weiterleitungen greifen vor dem 404, aber nach der Seitensuche: eine vorhandene Seite gewinnt immer, sonst könnte ein alter Eintrag eine neue Seite unerreichbar machen. Ketten werden abgewiesen. robots.txt nimmt zusätzliche Zeilen nur nach festem Muster an, damit ein Eintrag die Datei nicht umdeuten kann. llms.txt ist standardmäßig aus. Geprüft: 128 PHP-Tests / 507 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container. Dazu eine reine Sichtprüfung im Browser auf einer lokalen Demoseite - weiterhin keine axe- oder Lighthouse-Messung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
3.8 KiB
PHP
92 lines
3.8 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidPage;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
/**
|
|
* Addresses that moved or are deliberately gone. Checked before a page is answered with 404, so a
|
|
* migrated site keeps every old link working.
|
|
*/
|
|
final class RedirectRepository
|
|
{
|
|
public const MAX = 2000;
|
|
|
|
public function __construct(private Connection $db)
|
|
{
|
|
}
|
|
|
|
/** @return list<array{id: string, from: string, to: ?string, status: int}> */
|
|
public function all(): array
|
|
{
|
|
return array_map(static fn (array $row): array => [
|
|
'id' => $row['id'], 'from' => $row['from_path'], 'to' => $row['to_path'], 'status' => (int) $row['status'],
|
|
], $this->db->fetchAllAssociative('SELECT * FROM redirect WHERE website_id = ? ORDER BY from_path', ['default']));
|
|
}
|
|
|
|
/** @return array{to: ?string, status: int}|null */
|
|
public function find(string $path): ?array
|
|
{
|
|
$row = $this->db->fetchAssociative('SELECT to_path, status FROM redirect WHERE website_id = ? AND from_path = ?', ['default', trim($path, '/')]);
|
|
|
|
return false === $row ? null : ['to' => $row['to_path'], 'status' => (int) $row['status']];
|
|
}
|
|
|
|
public function save(string $from, string $to, int $status): void
|
|
{
|
|
$from = trim(trim($from), '/');
|
|
$to = trim(trim($to), '/');
|
|
if (!in_array($status, [301, 410], true)) {
|
|
throw new InvalidPage('Erlaubt sind 301 für eine verschobene Adresse und 410 für eine entfernte.');
|
|
}
|
|
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $from) || strlen($from) > 400) {
|
|
throw new InvalidPage('Die alte Adresse darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
|
|
}
|
|
if (301 === $status) {
|
|
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $to) || strlen($to) > 400) {
|
|
throw new InvalidPage('Das Ziel darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
|
|
}
|
|
if ($from === $to) {
|
|
throw new InvalidPage('Eine Adresse kann nicht auf sich selbst weiterleiten.');
|
|
}
|
|
// One hop only: a chain would cost an extra round trip and can become a loop.
|
|
$next = $this->find($to);
|
|
if (null !== $next) {
|
|
throw new InvalidPage(sprintf('Das Ziel /%s leitet selbst weiter. Zeige direkt auf das Endziel.', $to));
|
|
}
|
|
}
|
|
if (self::MAX <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect WHERE website_id = ?', ['default'])
|
|
&& null === $this->find($from)) {
|
|
throw new InvalidPage(sprintf('Mehr als %d Weiterleitungen sind nicht vorgesehen.', self::MAX));
|
|
}
|
|
|
|
$this->db->transactional(function () use ($from, $to, $status): void {
|
|
$this->db->executeStatement('DELETE FROM redirect WHERE website_id = ? AND from_path = ?', ['default', $from]);
|
|
$this->db->insert('redirect', [
|
|
'id' => bin2hex(random_bytes(16)), 'website_id' => 'default',
|
|
'from_path' => $from, 'to_path' => 301 === $status ? $to : null,
|
|
'status' => $status, 'created_at' => gmdate('Y-m-d H:i:s'),
|
|
]);
|
|
});
|
|
}
|
|
|
|
public function delete(string $id): void
|
|
{
|
|
$this->db->executeStatement('DELETE FROM redirect WHERE id = ? AND website_id = ?', [$id, 'default']);
|
|
}
|
|
|
|
/** Records that an address moved, used when a page is renamed. Never overwrites an entry. */
|
|
public function remember(string $from, string $to): void
|
|
{
|
|
if ($from === $to || null !== $this->find($from)) {
|
|
return;
|
|
}
|
|
try {
|
|
$this->save($from, $to, 301);
|
|
} catch (InvalidPage) {
|
|
// A path the rules reject is simply not remembered; renaming must not fail for it.
|
|
}
|
|
}
|
|
}
|