feat: assign custom 404 pages with the website theme (1.8.0)

This commit is contained in:
kantarci committed 2026-09-14 12:18:33 +02:00
1 parent a3c89bb69d
commit 5960e3d1f2
17 files changed
+638 -92

No files matched your search

+6
View File
@@ -22,6 +22,12 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
## Eigene 404-Seite (ab 1.8.0)
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website-Design. Entwürfe bleiben privat; Umbenennen erhält die Zuordnung, nach dem Löschen greift wieder der Standard. Das Entfernen der Auswahl setzt die Zuordnung zurück und erhält den Seiteninhalt. Die Zuordnung benötigt Veröffentlichungsrechte. Unbekannte URLs bleiben unverändert und antworten mit HTTP 404; die Fehlerseite wird nicht in Sitemap/llms.txt aufgenommen. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:404`, Berichte und Screenshots unter `reports/not-found/`. Datenvertrag und Rückweg: ADR 082.
## Anmelden und bearbeiten
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
+1 -1
View File
@@ -1 +1 @@
1.7.4
1.8.0
+3
View File
@@ -40,6 +40,9 @@ services:
App\EventSubscriber\AdminHeadersSubscriber:
arguments:
$adminPath: '%kant.admin_path%'
App\EventSubscriber\PublicNotFoundSubscriber:
arguments:
$adminPath: '%kant.admin_path%'
App\Content\Statistics:
arguments:
$adminPath: '%kant.admin_path%'
+12
View File
@@ -2,6 +2,18 @@
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 082 – Zuweisbare 404-Seite im gemeinsamen Seitenrenderer (14.09.2026)
`website.settings.notFoundPageId` verweist auf eine normale Seite über deren stabile ID. Eine eigene Einstellung mit Livesuche weist sie zu oder entfernt die Zuordnung; sie benötigt Veröffentlichungsrechte und CSRF-Schutz. Der erwartete bisherige Wert schützt gegen ein veraltetes Formular. Die Änderung liest die aktuellen Einstellungen innerhalb einer Transaktion, unter MySQL/MariaDB mit Zeilensperre, und schreibt nur die geänderte Zuordnung in den erhaltenen JSON-Inhalt. Das normale Unternehmensformular erhält diesen Wert unabhängig von seinen eigenen Feldern. Startseite und 404-Seite dürfen nicht dieselbe Seite sein.
Ohne verwendbare veröffentlichte Zuordnung wird ein v10-Blockdokument im Speicher erzeugt. Öffentliche GETs legen keine Seiten an. „404-Vorlage anlegen“ erstellt eine bearbeitbare Kopie als Entwurf und weist sie zu; bis zur Veröffentlichung bleibt die Standardausgabe sichtbar. Vorhandene Adressen einschließlich Papierkorb werden erhalten; bei belegtem `/404` wird `/404-2` usw. gewählt. Wiederholtes Anlegen überschreibt keine Seite. Die erzeugte Vorlage erhält `noindex`. Erstellen und Zuweisen laufen in einer gemeinsamen Datenbanktransaktion.
Normale Seiten, zugewiesene Fehlerseiten und der Standard verwenden denselben Renderer mit Theme-CSS, Schriften, Standard-Header/-Footer, Menüs, verknüpften Sektionen und Einwilligungsanzeige. Auf der Fehlerausgabe haben die Standardbereiche Vorrang vor eventuellen Bereichsabweichungen der ausgewählten Seite. Nur veröffentlichte Revisionen sind öffentlich sichtbar; Umbenennen erhält die Zuordnung, Löschen/nicht veröffentlichte Seiten führen auf den Standard zurück. Die Originaladresse bleibt stehen, die Antwort trägt HTTP 404 und `noindex, follow` in HTML und Header, ohne Canonical oder strukturierte Daten einer normalen Seite. Auch die eigene Adresse der zugewiesenen Seite antwortet mit 404. Sitemap und llms.txt schließen die zugewiesene ID zusätzlich aus, unabhängig von deren normalen Metadaten.
Ein schmaler Exception-Subscriber übernimmt öffentliche HTML-GET/HEAD-Routingfehler außerhalb der Slug-Grammatik, etwa alte `.html`-Adressen oder tiefere Pfade. Fehler bestehender Controller, Verwaltung, Medien/Assets, Formulare, Installation, AJAX sowie maschinenlesbare Ressourcen bleiben ihren bisherigen Endpunkten überlassen. Normale Seiten- und Archivfehler rufen den Fehlerrenderer direkt auf. 301/410 werden vorher behandelt; 410 nutzt einen eigenen Standardtext und behält seinen Status.
**Migration und Rückweg:** Keine Datenbankmigration und kein neues Dokumentschema. Fehlender/null Settings-Inhalt erhält beim Lesen den leeren Standardwert. Die Einstellung wird erst beim Zuweisen gespeichert. Die Vorlage nutzt bestehende v10-Blöcke. Älterer Code ignoriert die neue Zuordnung und verwendet wieder seine bisherige 404-Ausgabe; die angelegte Seite und ihre Revisionen bleiben normaler Inhalt. Vor einem Rückweg die Website-Einstellungen sichern, da ältere Einstellungsformulare unbekannte JSON-Schlüssel entfernen können. Kein automatisches Veröffentlichen oder Überschreiben vorhandener Seiten.
## ADR 081 – Linkziel und sichtbare Medienauswahl (14.09.2026)
Schema v20 ergänzt optionales `props.newTab: boolean` an Button, Bild, Symbolkarte, Aufruf-Band und Symbol. Fehlend/false öffnet wie bisher im aktuellen Tab; true erzeugt im gemeinsamen Renderer `target="_blank" rel="noopener"`. Derselbe Wert ist als optionales Feld in Menüeinträgen verfügbar; die bestehende Menüversion schützt weiterhin vor veralteten Schreibzugriffen. Der Rich-Text-Filter erlaubt ausschließlich `_blank` als Ziel und setzt `noopener` selbst. Andere Targets und fremde Attribute werden entfernt. Textlinks bleiben Teil des bisherigen HTML-Vertrags. Bei Bildern entscheidet die gewählte Klickaktion; eine hinterlegte Adresse bleibt bei „Nichts tun“ gespeichert, erzeugt aber keinen Link mehr.
+14
View File
@@ -1,5 +1,19 @@
# Projektstatus
## 1.8.0 – Eigene 404-Seite mit Website-Design (14.09.2026)
**Umgesetzt:** Einstellungen → 404-Seite mit Livesuche, bestehende Seite zuweisen, Auswahl zurücksetzen, direktem Bearbeiten und öffentlicher 404-Vorschau. „404-Vorlage anlegen“ erstellt und verknüpft einen bearbeitbaren Entwurf, ohne vorhandene Seiten zu überschreiben. Bis zur Veröffentlichung erscheint eine automatische Standard-404. Normaler Seitenrenderer übernimmt Standard-Header/-Footer, Theme, Schriften, Menüs und verknüpfte Sektionen. Die ID-Verknüpfung übersteht Umbenennen; Löschen führt auf den Standard zurück. Nur veröffentlichte Revisionen werden öffentlich ausgeliefert.
**Antwortverhalten:** Echte 404-Antwort an der ursprünglichen URL, auch bei ungewöhnlichen/tiefen HTML-Adressen und beim direkten Aufruf der zugewiesenen Seite. Kein Canonical und keine strukturierten Daten einer normalen Seite; `noindex, follow` in HTML und Header. Ausschluss der zugewiesenen ID aus Sitemap und llms.txt. 301/410, Verwaltungs-/Medien-/Formularendpunkte und maschinenlesbare Ressourcen behalten ihre eigene Behandlung. Zuweisung mit Veröffentlichungsrechten, CSRF und Schutz gegen veraltete Formulare. Keine Datenbankmigration; optionaler Settings-Schlüssel und vorhandenes Dokumentschema v10 (ADR 082).
**Geprüft:** PHP-Vollsuite 477 Tests / 3817 Assertions erfolgreich, einschließlich neuer 404-Fälle zu Entwurf/Veröffentlichung, Umbenennen, Löschen/Fallback, bestehenden `/404`-Seiten, Schreibkonflikten, Berechtigungen, CSRF, HEAD, Bereichen, 301/410, Sitemap und llms.txt. 32 JavaScript-Tests erfolgreich. `npm run test:404` mit isolierter Kant-IT-Kopie: Vorlage anlegen, im Editor bearbeiten, veröffentlichen, tatsächliche Ausgabe, Livesuche/Tastaturwahl, Zuordnung nach Neuladen, Rücksetzen und veraltetes zweites Formular. Desktop 1600 und Mobil 390 ohne Überlauf/axe-Verstöße; öffentliche Startseite weiterhin 200 mit Canonical und ohne axe-Befund. Funktioniert auch ohne JavaScript; Header/Footer/Schrift mit dem Originalpiloten abgeglichen, Screenshots visuell geprüft (`reports/not-found/`). Originaldaten bleiben unverändert.
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Laden, Veröffentlichung, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit. Alle 47 Twig-Dateien und der Produktionscontainer gültig; PHP-Stilprüfung über 133 Dateien ohne Befund.
**Veröffentlichung:** Release-Paket 1.8.0 wird aus dem geprüften Commit gebaut und auf dem bestehenden Git bereitgestellt.
**Grenzen / nächster Schritt:** Keine neuen Lighthouse-Scores für eine absichtlich nicht indexierbare 404-Seite. Keine neuen Firefox-/Safari-/Screenreader- oder MySQL-Läufe; lokale Datenbankprüfungen verwenden SQLite. Nach Veröffentlichung über die Systemverwaltung aktualisieren; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
## 1.7.4 – URL-Feld bei Bild-Klickaktion zuverlässig einblenden (14.09.2026)
**Behoben:** „Zu einer Adresse führen“ blendet Linkziel und Tab-Häkchen unmittelbar unter dem Dropdown ein, auch bei Bildern ohne frühere Verlinkung. Inhalts-Auswahlfelder reagieren auf `input` und `change`, ohne doppelte Änderungen. Das Dropdown bleibt beim Umschalten im DOM; Linkfelder werden nur ein-/ausgeblendet, eingegebene Adressen bleiben erhalten. Jede Auswahl bleibt einzeln rückgängig zu machen. Keine neue Schema-/Datenbankmigration, kein geänderter öffentlicher Renderer oder öffentliches Paket. Cachekennung 1.7.4.
+2 -1
View File
@@ -12,7 +12,8 @@
"test:admin": "node scripts/admin-quality-check.mjs",
"test:search": "node scripts/live-search-check.mjs",
"test:image-size": "node scripts/image-size-check.mjs",
"test:link-media": "node scripts/link-media-check.mjs"
"test:link-media": "node scripts/link-media-check.mjs",
"test:404": "node scripts/not-found-check.mjs"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
+137
View File
@@ -0,0 +1,137 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/not-found'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const visitor = await context.newPage();
visitor.on('pageerror', error => errors.push(error.message));
await visitor.goto(origin);
const shell = await visitor.evaluate(() => ({
header: document.querySelector('.site-header').textContent.replace(/\s+/g, ' ').trim(),
footer: document.querySelector('.site-footer').textContent.replace(/\s+/g, ' ').trim(),
font: getComputedStyle(document.body).fontFamily,
}));
const checkError = async (target, heading = 'Seite nicht gefunden', targetPage = visitor) => {
const response = await targetPage.goto(origin + target);
assert.equal(response.status(), 404);
assert.ok(response.headers()['x-robots-tag'].includes('noindex'));
assert.ok(response.headers()['content-security-policy'].includes("'sha256-"));
assert.equal(await targetPage.locator('h1').innerText(), heading);
assert.equal((await targetPage.locator('.site-header').textContent()).replace(/\s+/g, ' ').trim(), shell.header);
assert.equal((await targetPage.locator('.site-footer').textContent()).replace(/\s+/g, ' ').trim(), shell.footer);
assert.equal(await targetPage.evaluate(() => getComputedStyle(document.body).fontFamily), shell.font);
assert.equal(await targetPage.locator('link[rel="canonical"]').count(), 0);
assert.equal(await targetPage.locator('meta[name="robots"]').getAttribute('content'), 'noindex, follow');
};
const audit = async targetPage => {
const result = await new AxeBuilder({ page: targetPage }).analyze();
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await targetPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
};
await checkError('/eine-unbekannte-seite');
await audit(visitor); await visitor.screenshot({ path: `${report}/default-desktop.png`, fullPage: true });
await checkError('/Alte/Seite.html'); await checkError('/eins/zwei/drei/vier/fuenf');
await visitor.setViewportSize({ width: 390, height: 900 });
await checkError('/__404-vorschau__'); await audit(visitor);
await visitor.screenshot({ path: `${report}/default-mobile.png`, fullPage: true });
checks.push('default 404, unusual/deep URLs, correct status/noindex, standard header/footer/fonts, desktop/mobile axe and layout');
await page.goto(origin + '/admin/settings#not-found');
await page.getByRole('button', { name: '404-Vorlage anlegen', exact: true }).click();
await page.waitForURL(/\/admin\/pages\/[a-f0-9]{32}$/);
const id = new URL(page.url()).pathname.split('/').pop();
const selectHeading = async () => {
await page.locator('#structure [data-block-id="error-heading"]').click();
await page.locator('[data-panel="content"]').click();
};
await selectHeading(); await page.getByLabel('Text', { exact: true }).fill('Hier bist du falsch abgebogen');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await checkError('/ein-fehlender-pfad');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
await checkError('/404', 'Hier bist du falsch abgebogen');
await audit(visitor); await visitor.screenshot({ path: `${report}/custom-mobile.png`, fullPage: true });
await visitor.setViewportSize({ width: 1600, height: 1050 });
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen'); await audit(visitor);
await visitor.screenshot({ path: `${report}/custom-desktop.png`, fullPage: true });
const sitemap = await (await context.request.get(origin + '/sitemap.xml')).text();
assert.ok(!sitemap.includes(origin + '/404'));
checks.push('create an assigned draft, edit in normal editor, keep draft private, publish immediately, assigned own URL remains 404 and out of sitemap');
await page.goto(origin + '/admin/settings#not-found');
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
const clear = () => page.getByRole('button', { name: '404-Seite suchen: Auswahl entfernen', exact: true });
const saveAssignment = async () => {
await page.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
await page.waitForURL(origin + '/admin/settings#not-found');
};
await clear().click(); await saveAssignment();
await page.getByText('Aktiv: Standard-404 im Website-Design.', { exact: true }).waitFor();
await checkError('/ein-fehlender-pfad');
const assignExisting = async () => {
await page.getByLabel('404-Seite suchen', { exact: true }).fill('Seite nicht gefunden');
await page.locator('#not-found-picker-results [role="option"]').first().waitFor();
await page.getByLabel('404-Seite suchen', { exact: true }).press('ArrowDown');
await page.getByLabel('404-Seite suchen', { exact: true }).press('Enter');
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
await saveAssignment();
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
};
await assignExisting(); await page.reload();
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
const stale = await context.newPage(); await stale.goto(origin + '/admin/settings#not-found');
await clear().click(); await saveAssignment();
await stale.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
await stale.getByText('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.', { exact: true }).waitFor();
await checkError('/ein-fehlender-pfad'); await stale.close();
await assignExisting();
for (const width of [1600, 390]) {
await page.setViewportSize({ width, height: 1100 }); await page.locator('#not-found').scrollIntoViewIfNeeded();
await audit(page); await page.locator('#not-found').screenshot({ path: `${report}/settings-${width}.png` });
}
checks.push('live search without submitting, keyboard assignment, reload persistence, reset to default and protection against stale settings forms');
const withoutScripts = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 900 } });
const plain = await withoutScripts.newPage();
await checkError('/Alte/Seite.html', 'Hier bist du falsch abgebogen', plain);
await plain.getByRole('link', { name: 'Zur Startseite', exact: true }).click();
await plain.waitForURL(origin + '/'); await withoutScripts.close();
const normal = await visitor.goto(origin); assert.equal(normal.status(), 200);
assert.equal(await visitor.locator('link[rel="canonical"]').getAttribute('href'), origin + '/');
await audit(visitor);
checks.push('public 404 and working home link without JavaScript; normal home remains 200 with canonical and passes axe');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Custom 404 checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/** A normal block page supplies the content; an error response always keeps the site shell. */
final class NotFoundPage
{
public function __construct(private SiteSettings $settings, private PageRepository $pages, private Connection $db)
{
}
public function selected(): ?array
{
$id = $this->settings->load()['notFoundPageId'];
return '' === $id ? null : ($this->pages->choices(id: $id)[0] ?? null);
}
public function page(int $status = 404): array
{
$id = $this->settings->load()['notFoundPageId'];
$published = 404 === $status && '' !== $id ? $this->pages->findPublishedById($id) : null;
return [
'id' => 'error-'.$status, 'path' => '', 'document' => self::document($status),
...($published ?? []),
'locale' => 'de', 'type' => 'page', 'robots' => 'noindex', 'seo_title' => '',
'header_area' => null, 'footer_area' => null,
];
}
public static function document(int $status = 404): array
{
$title = 410 === $status ? 'Diese Seite ist nicht mehr verfügbar.' : 'Seite nicht gefunden';
return [
'schemaVersion' => 10, 'title' => $title, 'description' => 'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.',
'blocks' => [[
'id' => 'error-section', 'type' => 'section',
'style' => ['desktop' => ['textAlign' => 'center', 'minHeight' => '45vh', 'padding' => ['top' => '80px', 'bottom' => '80px']]],
'children' => [
['id' => 'error-code', 'type' => 'text', 'props' => ['text' => (string) $status]],
['id' => 'error-heading', 'type' => 'heading', 'props' => ['text' => $title, 'level' => 1]],
['id' => 'error-message', 'type' => 'text', 'props' => ['text' => 'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.']],
['id' => 'error-home', 'type' => 'button', 'props' => ['text' => 'Zur Startseite', 'href' => '/']],
],
]],
];
}
public function create(string $expectedId): string
{
return $this->db->transactional(function () use ($expectedId): string {
if (null !== $this->selected()) {
throw new InvalidPage('Es ist bereits eine 404-Seite zugewiesen. Du kannst sie direkt bearbeiten.');
}
$slug = '404';
for ($suffix = 2; false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', 'de', $slug]); ++$suffix) {
$slug = '404-'.$suffix;
}
$id = $this->pages->createPage($slug, null, 'page', self::document());
$this->db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $id]);
$this->settings->assignNotFoundPage($id, $expectedId);
return $id;
});
}
}
+44 -1
View File
@@ -3,6 +3,7 @@
namespace App\Content;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\TableNotFoundException;
@@ -53,6 +54,7 @@ final class SiteSettings
'robotsExtra' => '',
// Which stored page answers at the site root. Everything else derives from this.
'homePath' => 'home',
'notFoundPageId' => '',
// The consent question. Off by default, because a site that loads nothing from third
// parties has nothing to ask about, and a banner without a reason is only in the way.
'consentBanner' => false,
@@ -96,6 +98,8 @@ final class SiteSettings
public function save(array $input): void
{
$values = self::DEFAULTS;
// Assigned separately, so saving company data cannot clear the error page.
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
}
@@ -194,9 +198,13 @@ final class SiteSettings
throw new InvalidPage('Die Startseite braucht eine gültige Adresse.');
}
// A root that points nowhere would answer every visit with 404, so it must exist.
if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) {
$homeId = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home]);
if (false === $homeId) {
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
}
if ($homeId === $values['notFoundPageId']) {
throw new InvalidPage('Die 404-Seite kann nicht gleichzeitig die Startseite sein.');
}
$values['homePath'] = $home;
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
@@ -237,6 +245,41 @@ final class SiteSettings
]);
}
/** Change only the 404 assignment, preserving settings saved in other forms. */
public function assignNotFoundPage(string $id, string $expectedId): void
{
$this->db->transactional(function () use ($id, $expectedId): void {
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
$stored = is_array($stored) ? $stored : [];
$current = $stored['notFoundPageId'] ?? '';
if ($current !== $expectedId) {
throw new StaleWrite('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.');
}
if ('' !== $id) {
$page = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$id, 'default']);
if (false === $page || 'page' !== $page['type']) {
throw new InvalidPage('Bitte wähle eine vorhandene Seite als 404-Seite.');
}
if ($page['path'] === ($stored['homePath'] ?? self::DEFAULTS['homePath'])) {
throw new InvalidPage('Die Startseite kann nicht gleichzeitig die 404-Seite sein.');
}
}
if ($current === $id) {
return;
}
$stored['notFoundPageId'] = $id;
$changed = $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
]);
if (1 !== $changed) {
throw new StaleWrite('Die Einstellungen wurden inzwischen geändert. Lade die Seite neu.');
}
});
$this->loaded = null;
}
/**
* Which of the two mandatory pages are missing, by their settings key.
*
+98 -82
View File
@@ -8,6 +8,7 @@ use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
@@ -24,7 +25,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage)
{
}
@@ -45,96 +46,111 @@ final class PageController extends AbstractController
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($path);
$styles = '';
$googleFonts = null;
$localFonts = [];
// The page decides which header and footer it gets - including none.
$areas = $this->areas($page);
$needsSlider = $needsMotion = false;
if (null !== $page) {
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
// die Datei noch die IP-Adresse des Besuchers.
if ($this->fonts->hasLocal($theme)) {
$localFonts = $this->fonts->preload($theme);
} else {
$googleFonts = $this->theme->googleFontsUrl($theme);
}
}
if (null === $page) {
// An address that moved or was deliberately removed answers before the 404 does.
$redirect = $this->redirects->find($path);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
}
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
} else {
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
'hasPosts' => $this->posts->anyPublished(),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
// Baustein wirklich enthält.
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $needsSlider,
'needsMotion' => $needsMotion,
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
'breadcrumbs' => $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
'structuredData' => $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
// Rendered before the page content, so the header decides first.
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
]);
return $this->notFound($request, null !== $redirect ? 410 : 404);
}
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
return $this->notFound($request);
}
return $this->renderPage($request, $page, $path);
}
public function notFound(Request $request, int $status = 404): Response
{
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
}
/** Published pages and error content share theme, areas, block rendering and CSP. */
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
{
$styles = '';
$googleFonts = null;
$localFonts = [];
// The page decides which header and footer it gets - including none.
$areas = $this->areas($status >= 400 ? null : $page);
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
// die Datei noch die IP-Adresse des Besuchers.
if ($this->fonts->hasLocal($theme)) {
$localFonts = $this->fonts->preload($theme);
} else {
$googleFonts = $this->theme->googleFontsUrl($theme);
}
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
'hasPosts' => $this->posts->anyPublished(),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
// Baustein wirklich enthält.
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $needsSlider,
'needsMotion' => $needsMotion,
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
// Rendered before the page content, so the header decides first.
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
], new Response(status: $status));
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
$mitTurnstile = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
}
if ($status >= 400) {
$response->headers->set('X-Robots-Tag', 'noindex, follow');
}
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
}
@@ -441,7 +457,7 @@ final class PageController extends AbstractController
if (null !== $slug) {
$term = $this->posts->findTerm($kind, $slug);
if (null === $term) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
return $this->notFound($request);
}
}
$page = max(1, $request->query->getInt('seite', 1));
@@ -449,7 +465,7 @@ final class PageController extends AbstractController
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
if (0 === $result['total']) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
return $this->notFound($request);
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
@@ -468,10 +484,10 @@ final class PageController extends AbstractController
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
public function feed(): Response
public function feed(Request $request): Response
{
if (!$this->posts->anyPublished()) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
return $this->notFound($request);
}
$result = $this->posts->published(1, null, 20);
$response = $this->shell($this->render('feed.xml.twig', [
+4 -4
View File
@@ -34,8 +34,8 @@ final class SeoController extends AbstractController
// 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'],
"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),
@@ -99,8 +99,8 @@ final class SeoController extends AbstractController
}
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],
"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;
+32 -1
View File
@@ -5,11 +5,13 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\DemoContent;
use App\Content\MediaLibrary;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
{
}
@@ -35,6 +37,7 @@ final class SettingsController extends AbstractController
return $this->render('admin/settings.html.twig', [
'settings' => $this->settings->load(),
'notFoundSelection' => $this->notFoundPage->selected(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
'choosableHome' => $choosable,
'consentGroups' => Consent::GROUPS,
@@ -63,6 +66,34 @@ final class SettingsController extends AbstractController
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
}
#[Route('/%kant.admin_path%/settings/not-found', name: 'admin_not_found_save', methods: ['POST'])]
public function saveNotFound(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$expectedId = $request->request->getString('expectedId');
if ('create' === $request->request->getString('action')) {
$id = $this->notFoundPage->create($expectedId);
$this->addFlash('success', 'Die 404-Vorlage wurde als Entwurf angelegt und zugewiesen. Nach dem Veröffentlichen ersetzt sie die Standard-404.');
return $this->redirectToRoute('admin_editor', ['id' => $id]);
}
$id = $request->request->getString('notFoundPageId');
$this->settings->assignNotFoundPage($id, $expectedId);
$selection = $this->notFoundPage->selected();
$message = '' === $id ? 'Die Standard-404 im Website-Design ist wieder aktiv.'
: ($selection['published'] ? 'Die 404-Seite wurde zugewiesen.' : 'Die Seite wurde zugewiesen. Bis zu ihrer Veröffentlichung erscheint die Standard-404.');
$this->addFlash('success', $message);
} catch (InvalidPage|StaleWrite $error) {
$this->addFlash('error', $error->getMessage());
}
return $this->redirectToRoute('admin_settings', ['_fragment' => 'not-found']);
}
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
public function saveRedirect(Request $request): Response
{
@@ -0,0 +1,38 @@
<?php
namespace App\EventSubscriber;
use App\Controller\PageController;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
/** Also handle human-facing unknown URLs outside the content slug grammar. */
final class PublicNotFoundSubscriber implements EventSubscriberInterface
{
public function __construct(private PageController $pages, private string $adminPath)
{
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => ['onException', 10]];
}
public function onException(ExceptionEvent $event): void
{
$request = $event->getRequest();
if (!$event->isMainRequest() || !$event->getThrowable() instanceof NotFoundHttpException
|| $request->attributes->has('_route') || !in_array($request->getMethod(), ['GET', 'HEAD'], true)
|| $request->isXmlHttpRequest() || 'html' !== $request->getPreferredFormat('html')) {
return;
}
$path = $request->getPathInfo();
if (preg_match('#^/(?:'.preg_quote($this->adminPath, '#').'|media|assets|formular|install)(?:/|$)#', $path)
|| preg_match('/\.(?:css|js|mjs|json|xml|txt|png|jpe?g|webp|gif|svg|ico|woff2?|ttf|pdf)$/iD', $path)) {
return;
}
$event->setResponse($this->pages->notFound($request));
}
}
+31
View File
@@ -3,6 +3,7 @@
{% block title %}Einstellungen{% endblock %}
{% block heading %}Unternehmen und Adressen{% endblock %}
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %}
{% if missingLegal is not empty %}
@@ -20,6 +21,36 @@
</div>
</div>
{% endif %}
<section class="stack-section" id="not-found" aria-labelledby="not-found-title">
<div class="section-head"><h2 id="not-found-title">404-Seite</h2><a class="text-button" href="/__404-vorschau__" target="_blank" rel="noopener">404-Vorschau ↗</a></div>
<div class="card card-body">
<p>Bei unbekannten Adressen erscheint diese Seite mit dem Standard-Header, Standard-Footer und Design deiner Website.</p>
{% if notFoundSelection %}
<p class="notice">{{ notFoundSelection.published ? 'Aktiv:' : 'Zugewiesener Entwurf:' }} <strong>{{ notFoundSelection.title }}</strong>{% if not notFoundSelection.published %} · Bis zur Veröffentlichung erscheint die Standard-404.{% endif %}</p>
<p><a class="secondary" href="{{ path('admin_editor', {id: notFoundSelection.id}) }}">404-Seite bearbeiten</a></p>
{% else %}
<p class="notice">Aktiv: Standard-404 im Website-Design.</p>
{% endif %}
{% if is_granted('ROLE_PUBLISHER') %}
<form action="{{ path('admin_not_found_save') }}" method="post" id="not-found-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
{% include 'admin/content-picker.html.twig' with {id: 'not-found-picker', name: 'notFoundPageId', type: 'page', label: '404-Seite suchen', selected: notFoundSelection} only %}
<p class="field-help">Nur der veröffentlichte Stand wird angezeigt. Ohne Auswahl wird die Standard-404 verwendet.</p>
<button class="primary" type="submit">404-Zuordnung speichern</button>
</form>
{% if not notFoundSelection %}
<form action="{{ path('admin_not_found_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
<input type="hidden" name="action" value="create">
<p class="field-help">Noch keine passende Seite? Lege eine fertige Vorlage an und bearbeite sie im Editor.</p>
<button class="secondary" type="submit">404-Vorlage anlegen</button>
</form>
{% endif %}
{% else %}<p class="field-help">Die Zuordnung kann von einem Benutzer mit Veröffentlichungsrechten geändert werden.</p>{% endif %}
</div>
</section>
<section class="stack-section">
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
+2 -2
View File
@@ -7,12 +7,12 @@
{% set seoTitle = page.seo_title|default('') ?: page.document.title %}
<title>{{ seoTitle }}</title>
<meta name="description" content="{{ page.document.description }}">
<link rel="canonical" href="{{ canonical }}">
{% if canonical|default(null) %}<link rel="canonical" href="{{ canonical }}">{% endif %}
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
<meta property="og:title" content="{{ seoTitle }}">
<meta property="og:description" content="{{ page.document.description }}">
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
<meta property="og:url" content="{{ canonical }}">
{% if canonical|default(null) %}<meta property="og:url" content="{{ canonical }}">{% endif %}
{# In der Vorschau des Editors gibt es den Websitenamen nicht - dort fehlt das Feld einfach. #}
{% if page.website_name|default('') %}<meta property="og:site_name" content="{{ page.website_name }}">{% endif %}
<meta property="og:locale" content="{{ page.locale == 'de' ? 'de_DE' : page.locale }}">
+110
View File
@@ -10,6 +10,7 @@ use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostLayouts;
@@ -296,6 +297,115 @@ final class CmsTest extends TestCase
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
}
public function testDefault404UsesTheNormalAreasAndThemeForEveryPublicUrlShape(): void
{
foreach (['/missing', '/Missing/old.html', '/eins/zwei/drei/vier/fuenf', '/__404-vorschau__', '/kategorie/missing'] as $path) {
$response = $this->request($path);
self::assertSame(404, $response->getStatusCode(), $path);
self::assertSame('noindex, follow', $response->headers->get('X-Robots-Tag'));
self::assertFalse($response->headers->has('Location'));
$html = $response->getContent();
self::assertStringContainsString('Seite nicht gefunden', $html);
self::assertStringContainsString('class="site-header', $html);
self::assertStringContainsString('class="site-footer', $html);
self::assertStringContainsString('--k-', $html);
self::assertStringNotContainsString('rel="canonical"', $html);
self::assertStringNotContainsString('application/ld+json', $html);
}
$head = $this->request('/Missing/old.html', 'HEAD');
self::assertSame(404, $head->getStatusCode());
self::assertSame('', $head->getContent());
}
public function testCustom404KeepsDraftPrivateAndSurvivesRenamingAndFallsBackAfterDeletion(): void
{
$document = NotFoundPage::document();
$document['blocks'][0]['children'][1]['props']['text'] = 'Unser individueller Fehlerhinweis';
$id = $this->pages->createPage('fehlerseite', null, 'page', $document);
$this->site->assignNotFoundPage($id, '');
self::assertStringNotContainsString('Unser individueller Fehlerhinweis', $this->request('/unbekannt')->getContent());
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$this->pages->saveAreas($id, 'none', 'none', 2);
foreach (['/unbekannt', '/fehlerseite', '/alte.Datei.html'] as $url) {
$response = $this->request($url);
self::assertSame(404, $response->getStatusCode());
self::assertStringContainsString('Unser individueller Fehlerhinweis', $response->getContent());
self::assertStringContainsString('class="site-header', $response->getContent());
self::assertStringContainsString('class="site-footer', $response->getContent());
}
$this->site->save(['homePath' => 'home', 'llmsTxt' => true]);
self::assertSame($id, $this->site->load()['notFoundPageId']);
self::assertStringNotContainsString('/fehlerseite', $this->request('/sitemap.xml')->getContent());
self::assertStringNotContainsString('/fehlerseite', $this->request('/llms.txt')->getContent());
$this->pages->movePage($id, 'fehler-umbenannt', null, 3, $this->redirects);
self::assertStringContainsString('Unser individueller Fehlerhinweis', $this->request('/weiterhin-falsch')->getContent());
$document['blocks'][0]['children'][1]['props']['text'] = 'Noch geheimer Entwurf';
$page = $this->pages->findForEditor($id);
$revision = $this->pages->saveDraft($id, $document, $page['version']);
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $revision, $page['version']);
self::assertStringContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
$page = $this->pages->findForEditor($id);
$this->pages->trashPage($id, $page['version']);
self::assertStringContainsString('Seite nicht gefunden', $this->request('/weiterhin-falsch')->getContent());
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
}
public function test404CreationPreservesExistingPagesAndRejectsStaleAssignments(): void
{
$existing = $this->pages->createPage('404', null, 'page', DemoContent::starter('Vorhandene Seite'));
$service = new NotFoundPage($this->site, $this->pages, $this->db);
$id = $service->create('');
$created = $this->pages->findForEditor($id);
self::assertSame('404-2', $created['path']);
self::assertNull($created['published_revision_id']);
self::assertSame('noindex', $created['robots']);
self::assertSame('Vorhandene Seite', $this->pages->findForEditor($existing)['document']['title']);
$count = $this->db->fetchOne('SELECT COUNT(*) FROM page');
try {
$service->create('');
self::fail('Repeated creation must not create another page.');
} catch (InvalidPage) {
self::assertSame($count, $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
try {
$this->site->assignNotFoundPage('', '');
self::fail('A stale settings form must not reset a newer assignment.');
} catch (StaleWrite) {
self::assertSame($id, $this->site->load()['notFoundPageId']);
}
foreach (['page-1', 'missing', $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'))] as $invalid) {
try {
$this->site->assignNotFoundPage($invalid, $id);
self::fail('The home page, posts and missing pages cannot serve as the 404 page.');
} catch (InvalidPage) {
self::assertSame($id, $this->site->load()['notFoundPageId']);
}
}
$this->site->assignNotFoundPage('', $id);
self::assertSame('', $this->site->load()['notFoundPageId']);
}
public function testCustom404DoesNotReplaceRedirectsGoneResourcesOrAdminEndpoints(): void
{
$document = NotFoundPage::document();
$document['title'] = 'CUSTOM404MARKER';
$id = $this->pages->createPage('fehler', null, 'page', $document);
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$this->site->assignNotFoundPage($id, '');
$this->redirects->save('umgezogen', 'home', 301);
$this->redirects->save('entfernt', '', 410);
self::assertSame(301, $this->request('/umgezogen')->getStatusCode());
self::assertSame(410, $this->request('/entfernt')->getStatusCode());
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request('/entfernt')->getContent());
foreach (['/assets/missing.js', '/media/missing', '/admin/api/missing', '/missing.json', '/llms.txt'] as $url) {
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request($url)->getContent(), $url);
}
}
public function testHomeAliasRedirectsToCanonical(): void
{
$response = $this->request('/home');
+33
View File
@@ -82,6 +82,39 @@ final class EditorSecurityTest extends WebTestCase
return $client;
}
public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void
{
$client = $this->login();
$crawler = $client->request('GET', '/admin/settings');
self::assertSelectorExists('#not-found-picker[role="combobox"]');
$form = $crawler->selectButton('404-Vorlage anlegen')->form();
$values = $form->getPhpValues();
$client->request('POST', '/admin/settings/not-found', [...$values, '_csrf_token' => 'invalid']);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$client->submit($form);
self::assertResponseRedirects();
$id = json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId'];
self::assertResponseRedirects('/admin/pages/'.$id);
self::assertNull($this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]));
$client->request('POST', '/admin/settings/not-found', ['_csrf_token' => $values['_csrf_token'], 'notFoundPageId' => '', 'expectedId' => '']);
self::assertSame($id, json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
$client->followRedirect();
self::assertSelectorTextContains('body', 'inzwischen geändert');
$crawler = $client->request('GET', '/admin/settings');
$client->submit($crawler->selectButton('404-Zuordnung speichern')->form(['notFoundPageId' => '']));
self::assertSame('', json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
}
public function test404AssignmentRequiresPublicationRights(): void
{
$client = $this->login('editor');
$client->request('GET', '/admin/settings');
self::assertSelectorNotExists('#not-found-form');
$client->request('POST', '/admin/settings/not-found', ['action' => 'create']);
self::assertResponseStatusCodeSame(403);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
{
$client = $this->login();