Freie Gestaltung statt Tokenlisten (Dokumentschema v10)
Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a40c2bb27e
commit
abf0bd2c6b
24 files changed
+1591
-21
No files matched your search
@@ -7,6 +7,9 @@ use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -20,7 +23,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/admin')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private CsrfTokenManagerInterface $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -66,6 +69,23 @@ final class EditorController extends AbstractController
|
||||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||||
}
|
||||
|
||||
/** The editor reads the style vocabulary and the palette from here, so both stay in one place. */
|
||||
#[Route('/api/design', name: 'admin_design', methods: ['GET'])]
|
||||
public function design(): JsonResponse
|
||||
{
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->json([
|
||||
'properties' => StyleSchema::properties(),
|
||||
'devices' => StyleSchema::DEVICES,
|
||||
'sides' => StyleSchema::sides(),
|
||||
'corners' => StyleSchema::corners(),
|
||||
'hoverable' => StyleSchema::hoverable(),
|
||||
'colors' => $theme['colors'],
|
||||
'fonts' => $theme['fonts'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])]
|
||||
public function metadata(Request $request, string $id): Response
|
||||
{
|
||||
@@ -180,7 +200,7 @@ final class EditorController extends AbstractController
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false]);
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false, 'pageStyles' => $this->theme->css($this->theme->load()).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($this->theme->load())]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
|
||||
@@ -138,6 +138,8 @@ final class FormController extends AbstractController
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'pageStyles' => '',
|
||||
'googleFonts' => null,
|
||||
'forms' => $states,
|
||||
], $response);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -15,7 +17,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 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 Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -32,6 +34,14 @@ final class PageController extends AbstractController
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
if (null !== $page) {
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']);
|
||||
$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);
|
||||
@@ -50,25 +60,42 @@ final class PageController extends AbstractController
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'));
|
||||
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'), $styles, null !== $googleFonts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every public response carries the same headers. Scripting stays forbidden unless the page
|
||||
* actually contains a tab group: only then may the browser load the one enhancement script,
|
||||
* and only from this origin. Inline script is never allowed.
|
||||
*
|
||||
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
|
||||
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
|
||||
}
|
||||
$fontSources = ["'self'"];
|
||||
if ($googleFonts) {
|
||||
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
|
||||
$styleSources[] = 'https://fonts.googleapis.com';
|
||||
$fontSources[] = 'https://fonts.gstatic.com';
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Content-Security-Policy', sprintf(
|
||||
"default-src 'self'; script-src %s; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
$allowScript ? "'self'" : "'none'",
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
));
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ThemeController extends AbstractController
|
||||
{
|
||||
public function __construct(private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
'theme' => $this->theme->load(),
|
||||
'fallbacks' => Theme::FALLBACKS,
|
||||
'weights' => Theme::WEIGHTS,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
$fonts = [];
|
||||
foreach ($request->request->all('fonts') as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$row['weights'] = array_map('intval', (array) ($row['weights'] ?? []));
|
||||
$fonts[] = $row;
|
||||
}
|
||||
try {
|
||||
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'));
|
||||
} catch (InvalidPage $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
$this->addFlash('success', 'Das Design wurde gespeichert. Es gilt sofort auf allen Seiten.');
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user