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>
147 lines
6.0 KiB
PHP
147 lines
6.0 KiB
PHP
<?php
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Content\Consent;
|
||
use App\Content\DemoContent;
|
||
use App\Content\PageRepository;
|
||
use App\Content\RedirectRepository;
|
||
use App\Content\SiteSettings;
|
||
use App\Domain\InvalidPage;
|
||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
use Symfony\Component\Routing\Attribute\Route;
|
||
|
||
final class SettingsController extends AbstractController
|
||
{
|
||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages)
|
||
{
|
||
}
|
||
|
||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
|
||
public function index(Request $request): Response
|
||
{
|
||
$choosable = [];
|
||
foreach ($this->pages->listForEditor() as $page) {
|
||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
|
||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||
$choosable[] = ['path' => $page['path'], 'title' => $document['title'] ?? $page['path']];
|
||
}
|
||
}
|
||
|
||
return $this->render('admin/settings.html.twig', [
|
||
'settings' => $this->settings->load(),
|
||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||
'choosableHome' => $choosable,
|
||
'consentGroups' => Consent::GROUPS,
|
||
'missingLegal' => $this->settings->missingLegalPages(),
|
||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||
]);
|
||
}
|
||
|
||
#[Route('/admin/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||
public function save(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
try {
|
||
$this->settings->save($request->request->all());
|
||
} catch (InvalidPage $e) {
|
||
return $this->back($e->getMessage());
|
||
}
|
||
|
||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
||
}
|
||
|
||
#[Route('/admin/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||
public function saveRedirect(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
$remove = $request->request->getString('remove');
|
||
if ('' !== $remove) {
|
||
$this->redirects->delete($remove);
|
||
|
||
return $this->back('Die Weiterleitung wurde entfernt.', 'success');
|
||
}
|
||
try {
|
||
$this->redirects->save(
|
||
$request->request->getString('from'),
|
||
$request->request->getString('to'),
|
||
$request->request->getInt('status', 301),
|
||
);
|
||
} catch (InvalidPage $e) {
|
||
return $this->back($e->getMessage());
|
||
}
|
||
|
||
return $this->back('Die Weiterleitung wurde gespeichert.', 'success');
|
||
}
|
||
|
||
/**
|
||
* Creates the privacy statement and the imprint, with a skeleton whose gaps are marked.
|
||
*
|
||
* The pages are mandatory, the text is not something a CMS can write: what gets created is the
|
||
* structure with every required item named and marked as still to be filled in.
|
||
*/
|
||
#[Route('/admin/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||
public function legalPages(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
$settings = $this->settings->load();
|
||
$created = [];
|
||
try {
|
||
foreach (DemoContent::LEGAL as $slug => $label) {
|
||
$key = 'datenschutz' === $slug ? 'privacyPath' : 'imprintPath';
|
||
// An existing page is never overwritten - it may already carry the real text.
|
||
$existing = null !== $this->pages->findPublished($slug) || $this->pageExists($slug);
|
||
if (!$existing) {
|
||
$id = $this->pages->createPage($slug, null, 'page', DemoContent::legalPage($slug));
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
$created[] = $label;
|
||
}
|
||
$settings[$key] = $slug;
|
||
}
|
||
$this->settings->save([...$settings, 'consentGroups' => $settings['consentGroups'], 'sameAs' => implode('
|
||
', $settings['sameAs'])]);
|
||
} catch (InvalidPage $e) {
|
||
return $this->back($e->getMessage());
|
||
}
|
||
|
||
return $this->back(
|
||
[] === $created
|
||
? 'Die Seiten waren schon vorhanden und sind jetzt verknüpft. Der Inhalt bleibt unverändert.'
|
||
: sprintf('%s angelegt und verknüpft. Beide Seiten sind veröffentlicht und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.', implode(' und ', $created)),
|
||
'success',
|
||
);
|
||
}
|
||
|
||
/** Also finds a page that exists but is not published yet. */
|
||
private function pageExists(string $path): bool
|
||
{
|
||
foreach ($this->pages->listForEditor() as $page) {
|
||
if ($path === $page['path']) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function back(string $message, string $type = 'error'): Response
|
||
{
|
||
$this->addFlash($type, $message);
|
||
|
||
return $this->redirectToRoute('admin_settings');
|
||
}
|
||
}
|