Die Verwaltung kann umziehen: /admin ist frei waehlbar

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>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 20:51:55 +02:00
1 parent 9f1ac39251
commit aaea385422
26 files changed
+293 -45

No files matched your search

+3
View File
@@ -4,3 +4,6 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
SITE_ORIGIN=http://127.0.0.1:8080
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
# Die Adresse der Verwaltung. Aenderbar mit `php bin/console cms:admin:path <neu>`;
# der Befehl schreibt .env.local und prueft, dass die Adresse frei ist.
ADMIN_PATH=admin
+2
View File
@@ -88,6 +88,8 @@ npm.cmd run audit
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
+3 -3
View File
@@ -9,7 +9,7 @@ security:
ROLE_PUBLISHER: ROLE_EDITOR
firewalls:
admin:
pattern: ^/admin(?:/|$)
pattern: ^/%kant.admin_path%(?:/|$)
lazy: true
provider: cms_users
form_login:
@@ -25,5 +25,5 @@ security:
target: admin_login
enable_csrf: true
access_control:
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_EDITOR }
- { path: ^/%kant.admin_path%/login$, roles: PUBLIC_ACCESS }
- { path: ^/%kant.admin_path%, roles: ROLE_EDITOR }
+9 -1
View File
@@ -1,3 +1,11 @@
controllers:
# Erst alle benannten Routen, dann die Auffangroute fuer oeffentliche Seiten. Ohne diese
# Reihenfolge wuerde `/verwaltung/partials` als Seitenadresse gelesen, sobald die Verwaltung nicht
# mehr `/admin` heisst - die Auffangroute schliesst nur den Standardnamen aus.
verwaltung:
resource: ../src/Controller/
type: attribute
exclude: '../src/Controller/PageController.php'
seiten:
resource: ../src/Controller/PageController.php
type: attribute
+23
View File
@@ -1,4 +1,10 @@
parameters:
# Die Adresse der Verwaltung. Standard bleibt /admin; wer sie aendert, nimmt Scannern und
# Anmeldeversuchen das Ziel - erraten laesst sie sich nicht mehr. Sie steht in der Umgebung und
# nicht in der Datenbank, weil die Routen beim Start feststehen muessen. Geaendert wird sie
# ueber `php bin/console cms:admin:path <neu>`; der Befehl prueft und raeumt den Cache auf.
kant.admin_path: '%env(string:default:kant.admin_path_default:ADMIN_PATH)%'
kant.admin_path_default: 'admin'
# Wo Schriften und Stylesheets liegen. Im Normalfall im oeffentlichen Ordner; ueber
# FONT_DIRECTORY und ASSET_DIRECTORY umstellbar - die Tests nutzen das, damit sie nicht in
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
@@ -24,6 +30,13 @@ services:
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
App\EventSubscriber\AdminHeadersSubscriber:
arguments:
$adminPath: '%kant.admin_path%'
App\Content\Statistics:
arguments:
$adminPath: '%kant.admin_path%'
App\Content\FontLibrary:
arguments:
$directory: '%env(default:kant.fonts_dir:FONT_DIRECTORY)%'
@@ -37,9 +50,18 @@ services:
App\Controller\SeoController:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
$adminPath: '%kant.admin_path%'
App\Command\HealthCommand:
arguments:
$projectDir: '%kernel.project_dir%'
App\Controller\SystemController:
arguments:
$adminPath: '%kant.admin_path%'
App\Command\AdminPathCommand:
arguments:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
$environment: '%kernel.environment%'
App\Ops\Updater:
arguments:
$root: '%kernel.project_dir%'
@@ -50,6 +72,7 @@ services:
App\EventSubscriber\MaintenanceSubscriber:
arguments:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
App\Ops\Installation:
arguments:
$root: '%kernel.project_dir%'
+18
View File
@@ -466,6 +466,24 @@ Drei Entscheidungen, die nicht offensichtlich sind:
Der Preis steht in der Verwaltung: das Widget braucht JavaScript. Ohne Skript ist das Formular dann nicht absendbar – genau die Art von Verschlechterung, die dieses Projekt sonst vermeidet. Deshalb steht im Formular ein `<noscript>`-Hinweis mit dem Weg über E-Mail.
## ADR 065 – Die Verwaltung muss nicht /admin heißen (13.09.2026)
Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
`/admin` ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort ankommt, kostet Rechenzeit und Logzeilen. Ein anderer Name nimmt der Maschinerie das Ziel. **Er ersetzt kein gutes Passwort** – die Anmeldesperre nach fünf Versuchen bleibt die eigentliche Verteidigung –, aber er macht die Website für automatisierte Angriffe uninteressant.
**Der Name steht in der Umgebung, nicht in der Datenbank.** Routen stehen fest, bevor die erste Datenbankabfrage läuft; eine Adresse, die erst beim Rendern bekannt wird, käme zu spät. `ADMIN_PATH` füllt einen Parameter, der als Präfix in jeder Verwaltungsroute steht (`#[Route('/%kant.admin_path%/media')]`), in der Firewall, in den Schutzkopfzeilen und in der Statistik.
Drei Dinge, die dabei nicht offensichtlich sind:
**Die robots.txt nennt den Namen nicht mehr.** Sie ist öffentlich und das erste, was ein Angreifer liest – ein `Disallow: /stiller-eingang` wäre ein Wegweiser. Beim Standardnamen bleibt die Zeile, weil diese Adresse ohnehin jeder kennt; bei jedem anderen entfällt sie. Vom Indexieren hält die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
**Die Auffangroute für Seiten steht jetzt zuletzt.** Sie schließt per Regex `/admin` aus – einen frei wählbaren Namen kann sie nicht kennen. Statt die Regex dynamisch zu machen, lädt `routes.yaml` erst alle benannten Routen und dann die Auffangroute. Das ist ohnehin die richtige Reihenfolge.
**Der Befehl räumt den Zwischenspeicher auf, aber nicht den, in dem er selbst läuft.** Die Routen tragen den Namen einbetoniert; ohne Aufräumen gälte der alte weiter. Wer sich dabei den Boden unter den Füßen wegzieht, endet mit einem Fehler, obwohl alles geklappt hat – also bleibt die laufende Umgebung stehen, und der Befehl sagt, wie man sie leert.
`php bin/console cms:admin:path` zeigt die aktuelle Adresse, mit Argument ändert er sie – nach einer Prüfung, dass der Name frei ist und keine Seite verdeckt.
## ADR 064 – Ein Stylesheet, das nichts mehr tat (13.09.2026)
Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser lösen." Das war kein Gestaltungswunsch, sondern ein Fehlerbericht: Der **Einwilligungsdialog stand ungestaltet unter dem Fuß**, linksbündig, ohne Rand, ohne Hintergrund.
+4
View File
@@ -28,6 +28,10 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
## Optional: die Verwaltung umbenennen
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
## Optional: Spamschutz mit Cloudflare
Die Formulare haben drei eingebaute Prüfungen ohne Dritte. Kommt trotzdem Spam durch, gibt es unter
+1
View File
@@ -37,6 +37,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
- **Die Verwaltung kann umziehen** (ADR 065): `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse – geprüft wird, dass der Name frei ist und keine Seite verdeckt. Der Wert steht in `.env.local` und gilt für Routen, Firewall, Schutzkopfzeilen und Statistik. **In der `robots.txt` wird ein geänderter Name nicht genannt**; beim Standardnamen `admin` bleibt die Zeile, weil diese Adresse ohnehin bekannt ist. Die Systemseite zeigt die aktuelle Adresse an.
- **Dokumentschema v19**: Baustein **Rechtszeile** (Impressum, Datenschutz, „Einwilligung ändern") für den Fußbereich – gestaltbar und an der richtigen Stelle statt als heller Streifen unter einem dunklen Fuß; die eingebaute Leiste erscheint nur noch, wenn die Seite den Baustein nicht hat. Dazu **„Beim Klick"** je Bild (nichts / vergrößern / Adresse) und **„Bilder vergrößerbar"** je Galerie.
- **Bildbetrachter** (ADR 063): ohne JavaScript ein Verweis auf die große Bildfassung, mit JavaScript ein Betrachter über der Seite – `<dialog>` mit Blättern, Escape, Pfeiltasten und Zähler. Das Skript lädt nur auf Seiten, die ein vergrößerbares Bild enthalten.
- **Knopf „nach oben"** (ADR 062), abschaltbar und vollständig einstellbar: Seite, Form, Größe, Farben, Schwelle, Beschriftung. Ein Anker ohne Skript; eingeblendet über eine Scroll-Zeitachse, wo der Browser sie kennt.
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Command;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Ändert die Adresse der Verwaltung.
*
* `/admin` ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort
* ankommt, kostet Rechenzeit und Logzeilen. Heißt die Verwaltung anders, findet sie niemand, der
* sie nicht kennt. Das ersetzt kein gutes Passwort - es nimmt der Maschinerie nur das Ziel.
*
* Der Wert steht in `.env.local` und nicht in der Datenbank: die Routen stehen fest, bevor die
* erste Datenbankabfrage läuft. Deshalb dieser Befehl - er schreibt die Datei, prüft vorher, dass
* die Adresse benutzbar ist, und räumt den Zwischenspeicher auf, damit die neue Adresse sofort
* gilt.
*/
#[AsCommand(name: 'cms:admin:path', description: 'Zeigt die Adresse der Verwaltung oder ändert sie.')]
final class AdminPathCommand extends Command
{
/** Adressen, die schon vergeben sind oder es werden könnten. */
private const TABU = ['assets', 'media', 'install', 'einwilligung', 'formular', 'kategorie', 'schlagwort', 'beitraege', 'feed', 'sitemap', 'robots', 'llms', 'favicon'];
public function __construct(private Connection $db, private string $projectDir, private string $adminPath, private string $environment)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('pfad', InputArgument::OPTIONAL, 'Die neue Adresse, ohne Schrägstrich. Ohne Angabe wird die aktuelle gezeigt.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$neu = trim((string) $input->getArgument('pfad'), " \t/");
if ('' === $neu) {
$io->writeln(sprintf('Die Verwaltung liegt unter <info>/%s</info>.', $this->adminPath));
$io->writeln('Ändern: <comment>php bin/console cms:admin:path meine-verwaltung</comment>');
return Command::SUCCESS;
}
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $neu) || strlen($neu) > 40) {
$io->error('Erlaubt sind Kleinbuchstaben, Ziffern und Bindestriche, höchstens 40 Zeichen.');
return Command::FAILURE;
}
if (in_array($neu, self::TABU, true)) {
$io->error(sprintf('„%s" ist für die Website selbst reserviert.', $neu));
return Command::FAILURE;
}
// Eine Seite mit dieser Adresse wäre von der Verwaltung verdeckt - unauffindbar, ohne dass
// jemand merkt, warum.
$seite = $this->db->fetchOne('SELECT path FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $neu]);
if (is_string($seite)) {
$io->error(sprintf('Es gibt bereits eine Seite unter /%s. Die Verwaltung würde sie verdecken.', $neu));
return Command::FAILURE;
}
$datei = $this->projectDir.'/.env.local';
$inhalt = is_file($datei) ? (string) file_get_contents($datei) : '';
$zeile = sprintf('ADMIN_PATH=%s', $neu);
$inhalt = 1 === preg_match('/^ADMIN_PATH=.*$/m', $inhalt)
? (string) preg_replace('/^ADMIN_PATH=.*$/m', $zeile, $inhalt)
: rtrim($inhalt)."\n".$zeile."\n";
if (false === @file_put_contents($datei, ltrim($inhalt, "\n"))) {
$io->error('.env.local ist nicht beschreibbar. Trage dort von Hand ein: '.$zeile);
return Command::FAILURE;
}
// Ohne das Aufräumen gälte die alte Adresse weiter - und die neue wäre nicht erreichbar.
// Die Umgebung, in der dieser Befehl gerade läuft, bleibt dabei unangetastet: Symfony lädt
// Dienste nach, und wer sich den Boden unter den Füßen wegräumt, endet mit einem Fehler,
// obwohl alles geklappt hat. Der nächste Aufruf baut sie ohnehin neu.
$offen = [];
foreach (glob($this->projectDir.'/var/cache/*') ?: [] as $pfad) {
if (!is_dir($pfad)) {
continue;
}
if (basename($pfad) === $this->environment) {
$offen[] = basename($pfad);
continue;
}
$this->removeDirectory($pfad);
}
$io->success(sprintf('Die Verwaltung liegt jetzt unter /%s.', $neu));
if ([] !== $offen) {
$io->writeln(sprintf('Falls die Website noch die alte Adresse kennt: <comment>php bin/console cache:clear --env=%s</comment>', implode(' --env=', $offen)));
}
if ($this->adminPath !== $neu) {
$io->writeln('Merk dir die Adresse: <comment>/'.$this->adminPath.'</comment> antwortet ab sofort mit 404, wie jede andere unbekannte Adresse auch.');
}
$io->writeln('Zurück geht es mit: <comment>php bin/console cms:admin:path admin</comment>');
return Command::SUCCESS;
}
private function removeDirectory(string $pfad): void
{
if (!is_dir($pfad)) {
return;
}
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$voll = $pfad.'/'.$eintrag;
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
}
@rmdir($pfad);
}
}
+4 -2
View File
@@ -22,7 +22,7 @@ use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
final class Statistics
{
/** Everything that is not a page of this site, or not a visit at all. */
private const IGNORED = '#^/(admin|media/|assets/|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$|favicon)#';
private const IGNORED = '#^/(media/|assets/|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$|favicon)#';
/**
* A heuristic, and nothing more: user agents lie. It keeps the obvious crawlers and monitors
@@ -40,7 +40,7 @@ final class Statistics
/** After two months the hours of a day are merged into one row. */
public const COMPACT_AFTER_DAYS = 60;
public function __construct(private Connection $db)
public function __construct(private Connection $db, private string $adminPath = 'admin')
{
}
@@ -51,6 +51,8 @@ final class Statistics
&& in_array($status, [200, 404], true)
&& str_contains($contentType, 'text/html')
&& 1 !== preg_match(self::IGNORED, $path)
// Die Verwaltung ist kein Besuch - unabhaengig davon, wie sie heisst.
&& !str_starts_with($path, '/'.$this->adminPath)
&& 1 !== preg_match(self::MACHINES, $userAgent);
}
+1 -1
View File
@@ -24,7 +24,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/admin')]
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
+2 -2
View File
@@ -16,7 +16,7 @@ final class FormAdminController extends AbstractController
{
}
#[Route('/admin/forms', name: 'admin_forms', methods: ['GET'])]
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
public function index(Request $request): Response
{
return $this->render('admin/forms.html.twig', [
@@ -26,7 +26,7 @@ final class FormAdminController extends AbstractController
]);
}
#[Route('/admin/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
public function save(Request $request, string $slug): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+6 -6
View File
@@ -18,7 +18,7 @@ final class MediaController extends AbstractController
{
}
#[Route('/admin/media', name: 'admin_media', methods: ['GET'])]
#[Route('/%kant.admin_path%/media', name: 'admin_media', methods: ['GET'])]
public function index(Request $request): Response
{
// The screen renders the images itself, page by page. The dialog stays what it is: the way
@@ -31,7 +31,7 @@ final class MediaController extends AbstractController
]);
}
#[Route('/admin/media/upload', name: 'admin_media_upload', methods: ['POST'])]
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
public function formUpload(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -57,7 +57,7 @@ final class MediaController extends AbstractController
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
}
#[Route('/admin/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function formUpdate(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -95,7 +95,7 @@ final class MediaController extends AbstractController
return $this->redirectToRoute('admin_media', $query);
}
#[Route('/admin/api/media', methods: ['GET'])]
#[Route('/%kant.admin_path%/api/media', methods: ['GET'])]
public function list(): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -103,8 +103,8 @@ final class MediaController extends AbstractController
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
}
#[Route('/admin/api/media/upload', methods: ['POST'])]
#[Route('/admin/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/api/media/upload', methods: ['POST'])]
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function write(Request $request, ?string $id = null): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+2 -2
View File
@@ -17,7 +17,7 @@ final class MenuController extends AbstractController
{
}
#[Route('/admin/menus', name: 'admin_menus', methods: ['GET'])]
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
public function index(): Response
{
$menus = $this->menus->all();
@@ -39,7 +39,7 @@ final class MenuController extends AbstractController
]);
}
#[Route('/admin/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
#[Route('/%kant.admin_path%/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
public function save(Request $request, string $slot): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+1 -1
View File
@@ -25,7 +25,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
* against areas without a second implementation. Areas have no draft and no publication: they are
* shared, so a saved change is live. The editor hides the publish button for them.
*/
#[Route('/admin/partials')]
#[Route('/%kant.admin_path%/partials')]
final class PartialController extends AbstractController
{
public function __construct(
+2 -2
View File
@@ -9,7 +9,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
#[Route('/%kant.admin_path%/login', name: 'admin_login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $auth): Response
{
if ($this->getUser()) {
@@ -19,7 +19,7 @@ final class SecurityController extends AbstractController
return $this->render('admin/login.html.twig', ['error' => $auth->getLastAuthenticationError(), 'username' => $auth->getLastUsername()]);
}
#[Route('/admin/logout', name: 'admin_logout', methods: ['POST'])]
#[Route('/%kant.admin_path%/logout', name: 'admin_logout', methods: ['POST'])]
public function logout(): never
{
throw new \LogicException('Handled by Symfony Security.');
+10 -1
View File
@@ -21,6 +21,7 @@ final class SeoController extends AbstractController
private SiteSettings $settings,
private PostRepository $posts,
private string $siteOrigin,
private string $adminPath,
) {
}
@@ -58,7 +59,15 @@ final class SeoController extends AbstractController
public function robots(): Response
{
$settings = $this->settings->load();
$lines = ['User-agent: *', 'Disallow: /admin', 'Disallow: /formular/'];
// 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'];
}
+4 -4
View File
@@ -20,7 +20,7 @@ final class SettingsController extends AbstractController
{
}
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
public function index(Request $request): Response
{
$choosable = [];
@@ -44,7 +44,7 @@ final class SettingsController extends AbstractController
]);
}
#[Route('/admin/settings/company', name: 'admin_settings_save', methods: ['POST'])]
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -60,7 +60,7 @@ final class SettingsController extends AbstractController
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
}
#[Route('/admin/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
public function saveRedirect(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -92,7 +92,7 @@ final class SettingsController extends AbstractController
* 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'])]
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
public function legalPages(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+2 -2
View File
@@ -17,7 +17,7 @@ final class StatisticsController extends AbstractController
{
}
#[Route('/admin/statistik', name: 'admin_statistics', methods: ['GET'])]
#[Route('/%kant.admin_path%/statistik', name: 'admin_statistics', methods: ['GET'])]
public function index(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -32,7 +32,7 @@ final class StatisticsController extends AbstractController
]);
}
#[Route('/admin/statistik.csv', name: 'admin_statistics_csv', methods: ['GET'])]
#[Route('/%kant.admin_path%/statistik.csv', name: 'admin_statistics_csv', methods: ['GET'])]
public function csv(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+6 -5
View File
@@ -19,11 +19,11 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SystemController extends AbstractController
{
public function __construct(private Updater $updater, private Backup $backup)
public function __construct(private Updater $updater, private Backup $backup, private string $adminPath)
{
}
#[Route('/admin/system', name: 'admin_system', methods: ['GET'])]
#[Route('/%kant.admin_path%/system', name: 'admin_system', methods: ['GET'])]
public function index(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -38,10 +38,11 @@ final class SystemController extends AbstractController
'asked' => $request->query->getBoolean('pruefen'),
'steps' => $request->getSession()->remove('update_steps') ?: [],
'backups' => $this->backup->all(),
'adminPath' => $this->adminPath,
]);
}
#[Route('/admin/system/update', name: 'admin_system_update', methods: ['POST'])]
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
public function update(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -69,7 +70,7 @@ final class SystemController extends AbstractController
* `cms:restore` is the way, and the list says which backups exist and whether their checksum
* still matches.
*/
#[Route('/admin/system/sicherung', name: 'admin_system_backup', methods: ['POST'])]
#[Route('/%kant.admin_path%/system/sicherung', name: 'admin_system_backup', methods: ['POST'])]
public function backup(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -94,7 +95,7 @@ final class SystemController extends AbstractController
), 'success');
}
#[Route('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
#[Route('/%kant.admin_path%/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
public function maintenance(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
+3 -3
View File
@@ -16,7 +16,7 @@ final class ThemeController extends AbstractController
{
}
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
#[Route('/%kant.admin_path%/theme', name: 'admin_theme', methods: ['GET'])]
public function index(): Response
{
$theme = $this->theme->load();
@@ -36,7 +36,7 @@ final class ThemeController extends AbstractController
* Auf Zuruf, nicht bei einem Seitenaufruf: das dauert Sekunden und braucht eine Verbindung
* nach aussen, die ein Besucher nie abwarten soll.
*/
#[Route('/admin/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
#[Route('/%kant.admin_path%/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
public function fonts(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -65,7 +65,7 @@ final class ThemeController extends AbstractController
return $bytes < 1024 * 1024 ? round($bytes / 1024).' KB' : round($bytes / 1024 / 1024, 1).' MB';
}
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
#[Route('/%kant.admin_path%/theme/save', name: 'admin_theme_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+7 -7
View File
@@ -22,7 +22,7 @@ final class UserController extends AbstractController
{
}
#[Route('/admin/benutzer', name: 'admin_users', methods: ['GET'])]
#[Route('/%kant.admin_path%/benutzer', name: 'admin_users', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -37,7 +37,7 @@ final class UserController extends AbstractController
]);
}
#[Route('/admin/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
public function create(Request $request): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -56,7 +56,7 @@ final class UserController extends AbstractController
return $this->back('Das Konto wurde angelegt.', 'success');
}
#[Route('/admin/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
public function role(Request $request, string $id): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -71,7 +71,7 @@ final class UserController extends AbstractController
return $this->back('Die Rolle wurde geändert. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
}
#[Route('/admin/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
public function password(Request $request, string $id): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -86,7 +86,7 @@ final class UserController extends AbstractController
return $this->back('Das Passwort wurde gesetzt. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
}
#[Route('/admin/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
public function delete(Request $request, string $id): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -101,7 +101,7 @@ final class UserController extends AbstractController
return $this->back('Das Konto wurde gelöscht.', 'success');
}
#[Route('/admin/konto', name: 'admin_account', methods: ['GET'])]
#[Route('/%kant.admin_path%/konto', name: 'admin_account', methods: ['GET'])]
public function account(): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -122,7 +122,7 @@ final class UserController extends AbstractController
]);
}
#[Route('/admin/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
#[Route('/%kant.admin_path%/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
public function ownPassword(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -8,6 +8,10 @@ use Symfony\Component\HttpKernel\KernelEvents;
final class AdminHeadersSubscriber implements EventSubscriberInterface
{
public function __construct(private string $adminPath)
{
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => ['onResponse', -300]];
@@ -15,7 +19,7 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
public function onResponse(ResponseEvent $event): void
{
if (!preg_match('#^/admin(?:/|$)#', $event->getRequest()->getPathInfo())) {
if (!preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $event->getRequest()->getPathInfo())) {
return;
}
$headers = $event->getResponse()->headers;
@@ -19,7 +19,7 @@ use Twig\Environment;
*/
final class MaintenanceSubscriber implements EventSubscriberInterface
{
public function __construct(private Environment $twig, private string $projectDir)
public function __construct(private Environment $twig, private string $projectDir, private string $adminPath)
{
}
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
return;
}
$path = $event->getRequest()->getPathInfo();
if (preg_match('#^/(admin|assets/|media/|favicon)#', $path)) {
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) {
return;
}
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
+9
View File
@@ -13,6 +13,15 @@
<div class="card-body">
<dl class="facts">
<dt>Fassung</dt><dd><strong>{{ state.version }}</strong></dd>
<dt>Adresse der Verwaltung</dt>
<dd>
<code>/{{ adminPath }}</code>
{% if adminPath == 'admin' %}
<span class="muted">– die Adresse, die jeder Scanner zuerst probiert. Ändern mit <code>php bin/console cms:admin:path &lt;neu&gt;</code>; sie steht danach in <code>.env.local</code> und in keiner öffentlichen Datei.</span>
{% else %}
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
{% endif %}
</dd>
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
<dt>Verfügbar</dt>
<dd>
+38
View File
@@ -1009,6 +1009,44 @@ final class CmsTest extends TestCase
self::assertStringContainsString('Datenschutz', $mit);
}
public function testTheAdministrationCanLiveSomewhereElseAndIsNotAdvertised(): void
{
// Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
// /admin ist die erste Adresse, die ein Scanner probiert. Wer sie umbenennt, nimmt der
// Maschinerie das Ziel - aber nur, wenn der neue Name nirgends öffentlich steht.
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
// Beim Standardnamen bleibt die Zeile in der robots.txt: diese Adresse kennt ohnehin jeder.
self::assertStringContainsString('Disallow: /admin', $this->request('/robots.txt')->getContent());
// Bei geändertem Namen darf er dort nicht auftauchen.
$vorher = $_SERVER['ADMIN_PATH'] ?? null;
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang';
try {
$robots = $this->request('/robots.txt')->getContent();
self::assertStringNotContainsString('stiller-eingang', $robots, 'robots.txt ist öffentlich - der Name gehört dort nicht hinein.');
self::assertStringNotContainsString('Disallow: /admin', $robots);
self::assertStringContainsString('Disallow: /formular/', $robots, 'Der Rest der Datei bleibt, wie er war.');
} finally {
if (null === $vorher) {
unset($_SERVER['ADMIN_PATH'], $_ENV['ADMIN_PATH']);
} else {
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = $vorher;
}
}
// Und keine Route trägt die Adresse fest im Text: sonst zöge eine Umbenennung die Hälfte
// der Verwaltung nicht mit.
foreach (glob(__DIR__.'/../src/Controller/*.php') ?: [] as $datei) {
$inhalt = (string) file_get_contents($datei);
self::assertDoesNotMatchRegularExpression(
"#\#\[Route\('/admin#",
$inhalt,
basename($datei).' schreibt /admin fest, statt den eingestellten Pfad zu nehmen.',
);
}
}
public function testNoStylesheetHasADanglingSelector(): void
{
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim