Files
KantSites/src/Content/RedirectRepository.php
T
kantarciandClaude Opus 5 269fb223af Messen statt vermuten: 18 Abfragen auf 8, Ladebudget, Blaettern ueberall
Arbeitspaket M sagte "Abfragen je Seite zaehlen. Messen, dann zusammenfassen."
Messen braucht ein Messgeraet, also gibt es jetzt eines: ein Zaehler als
Doctrine-Middleware, dessen Zahl ausserhalb der Produktion im Kopf
X-Kant-Queries steht. Keine Profiler-Maschinerie, eine Zahl.

Die erste Messung: die Startseite brauchte 18 Abfragen. Nicht wegen einer
komplizierten Seite, sondern weil SiteSettings::load() und Theme::load() je
Aufrufer dieselbe Zeile erneut holten - Renderer, strukturierte Daten,
Einwilligungsschalter, Vorlagen. Beide merken sich ihren Stand jetzt fuer die
Dauer einer Anfrage: 18 auf 8.

Dabei ist mir die klassische Falle des Merkens selbst passiert, und ein
bestehender Test hat sie sofort gezeigt: ich leerte den Merker am Anfang des
Speicherns. Das Speichern liest den Stand aber noch einmal und fuellte ihn
damit erneut - der Rest der Anfrage bekam den alten Stand. Geleert wird jetzt
nach dem Schreiben.

Das Ladebudget wird gemessen statt behauptet: die Browserpruefung liest je
Seite HTML-Groesse, CSS-Groesse, Anzahl Anfragen, Anzahl Skripte und
Abfragezahl und prueft Obergrenzen. Startseite: 7,4 KB HTML, 22,7 KB CSS, 2
Anfragen, 8 Abfragen, 0 Skripte. Die Null ist die wichtigste Zahl - sie haelt
als Test fest, was bisher nur Absicht war.

Das erste Bild einer Seite ist nicht mehr lazy. Es ist meistens das groesste
Ding ueber der Falz, und es zu verzoegern verzoegert genau das Pixel, auf das
jemand wartet. Der Renderer bestimmt es in Renderreihenfolge, Kopfbereich
zuerst.

Eine feste Obergrenze ist kein Blaettern: der Fassungsverlauf hoerte bei
dreissig auf, die Einsendungen bei fuenfzig, die Weiterleitungen zeigten bis
zu zweitausend auf einmal. Das Erste sieht aus wie "es gibt dreissig" und ist
das Einzige, was eine Historie nicht tun darf. Alle drei blaettern jetzt, mit
derselben Rechnung und demselben Pager wie Beitragsarchiv und Mediathek.

347 PHP-Tests / 1422 Assertions unter PHP 8.2.12 und 8.4.22.

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

103 lines
4.4 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)
{
}
/**
* One page of the redirects. There can be up to MAX of them, and a list of two thousand rows
* is not a list anybody reads.
*
* @return array{items: list<array{id: string, from: string, to: ?string, status: int}>, page: int, pages: int, total: int}
*/
public function all(int $number = 1, int $perPage = 50): array
{
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect WHERE website_id = ?', ['default']), $perPage);
$items = 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 LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
['default'],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
/** @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.
}
}
}