Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog
Forms gained steps, conditions, image choices, number fields with units and a summary step; the DEEG price finder uses all of it. Plugins are manifest plus Twig: blocks, settings, containers, routes, presets and theme packs, switched per installation. The review backlog is done: archive pages render through the normal page pipeline, forms and post lists work inside areas and sections, repeated sections get their own ids, breakpoints and colours come from the palette, backup and update work on MySQL and lock each other out. The task worker no longer shortens a process that has no time limit: set_time_limit() sets the limit instead of extending it, which turned an unlimited CLI run into eighty seconds and killed the full test suite far away from the scheduler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66ff68abf8
commit
c8a8fa7460
74 files changed
+5352
-243
No files matched your search
@@ -6,6 +6,7 @@ use App\Content\CustomCode;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
@@ -18,6 +19,7 @@ use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -29,7 +31,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[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 FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
|
||||
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 FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -419,7 +421,7 @@ final class EditorController extends AbstractController
|
||||
if (!is_array($document)) {
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
$this->validator->validate($document);
|
||||
$this->validator->validate($document, writing: true);
|
||||
|
||||
$theme = $this->theme->load();
|
||||
$areas = [];
|
||||
@@ -436,12 +438,38 @@ final class EditorController extends AbstractController
|
||||
$sections[$slug] = $section['document'];
|
||||
}
|
||||
}
|
||||
// Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen.
|
||||
$seen = [];
|
||||
$pristine = $sections;
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
if (isset($areas[$kind])) {
|
||||
$areas[$kind] = $this->partials->expandRepeatedLinks($areas[$kind], $sections, $seen, $pristine);
|
||||
}
|
||||
if ('header' === $kind) {
|
||||
$document = $this->partials->expandRepeatedLinks($document, $sections, $seen, $pristine);
|
||||
}
|
||||
}
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$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, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
|
||||
// Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem
|
||||
// CSS, Bereichs- und Sektionsstilen und der Formulare mit ihren Farben. Vorher
|
||||
// fehlte all das, und "sieht in der Vorschau anders aus" war immer wahr.
|
||||
$documents = [$document, ...array_values($areas), ...array_values($sections)];
|
||||
$styles = $this->theme->css($theme);
|
||||
foreach ($documents as $doc) {
|
||||
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
|
||||
}
|
||||
$forms = $this->previewForms($documents, $page);
|
||||
foreach ($forms as $state) {
|
||||
if (null !== $state['form']) {
|
||||
$styles .= $this->forms->css($state['form'], $theme);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
@@ -462,7 +490,9 @@ final class EditorController extends AbstractController
|
||||
$this->code->assertScriptsUnchanged($body['document'], $page['document']);
|
||||
}
|
||||
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
|
||||
$page['document'] = $body['document'];
|
||||
// Die gespeicherte Fassung, nicht die geschickte: Rich Text wird beim Speichern
|
||||
// bereinigt, und der Editor soll das sofort zeigen statt erst nach dem Neuladen.
|
||||
$page['document'] = $this->pages->findForEditor($id)['document'] ?? $body['document'];
|
||||
}
|
||||
$page['version'] = $body['expectedVersion'] + 1;
|
||||
|
||||
@@ -476,6 +506,33 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Formulare der Vorschau: Definition und Optionsbilder, ohne Zeitstempel - in der Vorschau
|
||||
* wird nichts abgeschickt.
|
||||
*
|
||||
* @param list<array> $documents
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function previewForms(array $documents, array $page): array
|
||||
{
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
$states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
private function page(string $id): array
|
||||
{
|
||||
return $this->pages->findForEditor($id) ?? throw $this->createNotFoundException('Seite nicht gefunden.');
|
||||
@@ -483,6 +540,6 @@ final class EditorController extends AbstractController
|
||||
|
||||
private function payload(array $page): array
|
||||
{
|
||||
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue()];
|
||||
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -14,7 +15,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms, private Theme $theme)
|
||||
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -30,6 +31,8 @@ final class FormAdminController extends AbstractController
|
||||
'forms' => $this->forms->all(),
|
||||
'types' => FormRepository::TYPES,
|
||||
'colors' => FormRepository::COLORS,
|
||||
// Die Bilder fuer eine Bildauswahl: die Mediathek als Liste, damit je Option gewaehlt statt getippt wird.
|
||||
'images' => array_map(static fn (array $item): array => ['id' => $item['id'], 'title' => $item['title'], 'src' => $item['variants'][0]['src'] ?? ''], $this->media->page(1, false, 96)['items']),
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
|
||||
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
|
||||
@@ -56,6 +59,14 @@ final class FormAdminController extends AbstractController
|
||||
'required' => '' !== (string) ($row['required'] ?? ''),
|
||||
'help' => (string) ($row['help'] ?? ''),
|
||||
'options' => array_map(trim(...), explode(',', (string) ($row['options'] ?? ''))),
|
||||
'images' => (string) ($row['images'] ?? ''),
|
||||
'min' => (string) ($row['min'] ?? ''),
|
||||
'max' => (string) ($row['max'] ?? ''),
|
||||
'unit' => (string) ($row['unit'] ?? ''),
|
||||
// Die Bedingung: technischer Name eines früheren Auswahlfeldes und die Werte, bei
|
||||
// denen dieses Feld gilt, als Kommaliste.
|
||||
'showIfField' => (string) ($row['showIfField'] ?? ''),
|
||||
'showIfValues' => (string) ($row['showIfValues'] ?? ''),
|
||||
];
|
||||
}
|
||||
try {
|
||||
@@ -70,6 +81,7 @@ final class FormAdminController extends AbstractController
|
||||
'success' => $request->request->getString('success'),
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
'buttonLabel' => $request->request->getString('buttonLabel'),
|
||||
'summary' => '' !== $request->request->getString('summary'),
|
||||
...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))),
|
||||
],
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
|
||||
@@ -88,6 +88,14 @@ final class FormController extends AbstractController
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($form['fields'] as $field) {
|
||||
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
|
||||
if ('step' === $field['type']) {
|
||||
$lines[] = ([] === $lines ? '' : "\n").'== '.$field['label'].' ==';
|
||||
continue;
|
||||
}
|
||||
if (null !== $field['showIf'] && '' === ($values[$field['name']] ?? '')) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = $field['label'].': '.($values[$field['name']] ?? '');
|
||||
}
|
||||
// Der Absender ist die eigene Adresse des Formulars, sonst der Empfaenger. Viele Hoster
|
||||
@@ -122,17 +130,10 @@ final class FormController extends AbstractController
|
||||
*/
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked): Response
|
||||
{
|
||||
$zustaende = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$zustaende, $form, $checked): void {
|
||||
foreach ($blocks as $block) {
|
||||
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben.
|
||||
if ('form' === ($block['type'] ?? null) && ($block['props']['form'] ?? '') === $form['slug']) {
|
||||
$zustaende[$block['id']] = ['errors' => $checked['errors'], 'values' => $checked['values'], 'sent' => false];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben - wo auch immer
|
||||
// es auf der Seite steht: im Inhalt, im Kopf, im Fuss oder in einer verknuepften Sektion.
|
||||
// Der Seitencontroller ordnet die Meldungen jedem Block mit dieser Adresse zu.
|
||||
$zustaende = ['slug' => $form['slug'], 'errors' => $checked['errors'], 'values' => $checked['values']];
|
||||
|
||||
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
|
||||
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
@@ -18,6 +19,7 @@ use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Turnstile;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -25,7 +27,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, private NotFoundPage $notFoundPage)
|
||||
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, private PluginRegistry $plugins)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,6 +49,11 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
if (null === $page) {
|
||||
// Eine Seite geht vor; eine eigene Adresse eines Plugins kommt vor Weiterleitung und 404.
|
||||
$route = $this->plugins->route('/'.$path);
|
||||
if (null !== $route) {
|
||||
return $this->pluginRoute($request, $route);
|
||||
}
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
@@ -61,6 +68,27 @@ final class PageController extends AbstractController
|
||||
return $this->renderPage($request, $page, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die
|
||||
* Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript.
|
||||
*/
|
||||
private function pluginRoute(Request $request, array $route): Response
|
||||
{
|
||||
$query = [];
|
||||
foreach ($request->query->all() as $key => $value) {
|
||||
if (is_string($value) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,40}$/D', (string) $key)) {
|
||||
$query[$key] = mb_substr($value, 0, 500);
|
||||
}
|
||||
}
|
||||
$response = $this->render($route['template'], ['settings' => $this->plugins->settingsOf($route['plugin']), 'query' => $query, 'route' => $route]);
|
||||
$response->headers->set('Content-Type', $route['contentType']);
|
||||
if ('html' !== $route['type']) {
|
||||
$response->headers->set('X-Robots-Tag', 'noindex');
|
||||
}
|
||||
|
||||
return $this->shell($response);
|
||||
}
|
||||
|
||||
public function notFound(Request $request, int $status = 404): Response
|
||||
{
|
||||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||||
@@ -75,12 +103,29 @@ final class PageController extends AbstractController
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
// Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen
|
||||
// Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß.
|
||||
$pristine = $sections;
|
||||
$seen = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
if ('header' === $kind && isset($areas['header'])) {
|
||||
$areas['header'] = $this->partials->expandRepeatedLinks($areas['header'], $sections, $seen, $pristine);
|
||||
}
|
||||
if ('header' === $kind) {
|
||||
$page['document'] = $this->partials->expandRepeatedLinks($page['document'], $sections, $seen, $pristine);
|
||||
}
|
||||
if ('footer' === $kind && isset($areas['footer'])) {
|
||||
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
|
||||
}
|
||||
}
|
||||
$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));
|
||||
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
|
||||
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
|
||||
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
|
||||
// Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt.
|
||||
$pluginAssets = $this->plugins->assetsFor($documents);
|
||||
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
|
||||
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
|
||||
// for the small script that marks the page as scrolled.
|
||||
@@ -91,7 +136,10 @@ final class PageController extends AbstractController
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern.
|
||||
$forms = $this->formStates($page, $request->query->getString('gesendet'), $request);
|
||||
$forms = $this->formStates($page, $documents, $request->query->getString('gesendet'), $request);
|
||||
// Schritte, Bedingungen und Zusammenfassung brauchen das eine Formularskript - nur dort,
|
||||
// wo ein solches Formular steht.
|
||||
$needsFormScript = (bool) array_filter($forms, fn (array $state): bool => null !== $state['form'] && $this->forms->needsScript($state['form']));
|
||||
$styled = [];
|
||||
foreach ($forms as $state) {
|
||||
if (null !== $state['form'] && !isset($styled[$state['form']['slug']])) {
|
||||
@@ -104,11 +152,31 @@ final class PageController extends AbstractController
|
||||
// das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den
|
||||
// Seitenaufbau an zwei fremde Verbindungen nacheinander.
|
||||
$localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [];
|
||||
$areaScripts = [];
|
||||
foreach ($areas as $kind => $area) {
|
||||
if (isset($this->areaIds[$kind]) && [] !== $this->code->scriptsOf($area)) {
|
||||
$areaScripts[] = '/assets/bereich/'.$this->areaIds[$kind].'.js';
|
||||
}
|
||||
}
|
||||
$postLists = $this->postLists($documents, max(1, $request->query->getInt('seite', 1)));
|
||||
// Eine blätternde Liste macht die Seite zu einer Folge von Seiten: die Canonical nennt die
|
||||
// Seitenzahl, prev und next verbinden die Folge - sonst hielte eine Suchmaschine Seite 3
|
||||
// für ein Duplikat von Seite 1.
|
||||
$blaettern = null;
|
||||
foreach ($postLists as $liste) {
|
||||
if (isset($liste['pages']) && $liste['pages'] > 1) {
|
||||
$blaettern = $liste;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath());
|
||||
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
|
||||
'canonical' => $canonical,
|
||||
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
'postLists' => $postLists,
|
||||
// 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,
|
||||
@@ -116,6 +184,9 @@ final class PageController extends AbstractController
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $needsTabs,
|
||||
'needsFormScript' => $needsFormScript,
|
||||
'pluginStyles' => $pluginAssets['styles'],
|
||||
'pluginScripts' => $pluginAssets['scripts'],
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
@@ -127,6 +198,7 @@ final class PageController extends AbstractController
|
||||
// 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',
|
||||
'areaScripts' => $areaScripts,
|
||||
'forms' => $forms,
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
@@ -148,8 +220,8 @@ final class PageController extends AbstractController
|
||||
|
||||
// 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 = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -169,8 +241,7 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
@@ -209,13 +280,9 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares one state per form block: the definition, whether this request just sent it, and a
|
||||
* signed timestamp the submission route uses to tell a person from a script.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
/**
|
||||
* Der Zustand jedes Formulars auf dieser Seite.
|
||||
* Der Zustand jedes Formulars auf dieser Seite: die Definition, ob diese Anfrage es gerade
|
||||
* abgeschickt hat, und ein signierter Zeitstempel, an dem der Versand einen Menschen von
|
||||
* einem Skript unterscheidet.
|
||||
*
|
||||
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
|
||||
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
|
||||
@@ -223,7 +290,7 @@ final class PageController extends AbstractController
|
||||
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
|
||||
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
|
||||
*/
|
||||
private function formStates(array $page, string $sentSlug, ?Request $request = null): array
|
||||
private function formStates(array $page, array $documents, string $sentSlug, ?Request $request = null): array
|
||||
{
|
||||
$issued = time();
|
||||
$states = [];
|
||||
@@ -232,8 +299,11 @@ final class PageController extends AbstractController
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
$states[$block['id']] = [
|
||||
'form' => $this->forms->findBySlug($slug),
|
||||
'form' => $form,
|
||||
// Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt.
|
||||
'optionImages' => null === $form ? [] : $this->optionImages($form),
|
||||
'sent' => '' !== $sentSlug && $sentSlug === $slug,
|
||||
'errors' => [], 'values' => [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
@@ -246,16 +316,46 @@ final class PageController extends AbstractController
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
foreach ($uebergeben as $blockId => $zustand) {
|
||||
if (isset($states[$blockId])) {
|
||||
$states[$blockId] = [...$states[$blockId], ...$zustand];
|
||||
// Formulare stehen auch im Kopf, im Fuss oder in einer verknuepften Sektion - ein
|
||||
// Kontaktformular im Fuss jeder Seite ist der haeufigste Fall.
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
// Die Meldungen aus dem Formularversand gelten jedem Block, der dieses Formular zeigt.
|
||||
if (isset($uebergeben['slug'])) {
|
||||
foreach ($states as $blockId => $state) {
|
||||
if (($state['form']['slug'] ?? null) === $uebergeben['slug']) {
|
||||
$states[$blockId] = [...$state, 'errors' => $uebergeben['errors'], 'values' => $uebergeben['values'], 'sent' => false];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bilder der Optionen eines Formulars: Kennung => kleinste Fassung mit Massen. Ein Bild,
|
||||
* das die Mediathek nicht mehr kennt, fehlt hier - die Option zeigt dann ihren Text.
|
||||
*
|
||||
* @return array<string, array{src: string, width: int, height: int}>
|
||||
*/
|
||||
private function optionImages(array $form): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($form['fields'] as $field) {
|
||||
$ids = [...$ids, ...array_values($field['images'] ?? [])];
|
||||
}
|
||||
$images = [];
|
||||
foreach ($this->media->findMany($ids) as $id => $item) {
|
||||
$variant = $item['variants'][0] ?? null;
|
||||
if (null !== $variant) {
|
||||
$images[$id] = ['src' => $variant['src'], 'width' => (int) $variant['width'], 'height' => (int) $variant['height']];
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail from the home page down to this one, resolved from the stored hierarchy rather
|
||||
* than from content, so it cannot claim a page that does not exist.
|
||||
@@ -290,6 +390,15 @@ final class PageController extends AbstractController
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
return [];
|
||||
}
|
||||
// Ein Archiv hat keine Seitenhierarchie: die Spur ist „Beiträge“, dann die Kategorie.
|
||||
if ('archive' === ($page['type'] ?? null)) {
|
||||
$trail = [['name' => 'Beiträge', 'href' => '/beitraege']];
|
||||
if ('beitraege' !== $page['path']) {
|
||||
$trail[] = ['name' => $page['document']['title'], 'href' => '/'.$page['path']];
|
||||
}
|
||||
|
||||
return $trail;
|
||||
}
|
||||
$trail = [];
|
||||
$prefix = '';
|
||||
foreach (explode('/', (string) $page['path']) as $segment) {
|
||||
@@ -508,27 +617,36 @@ final class PageController extends AbstractController
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
$heading = null === $term ? 'Beiträge' : $term['name'];
|
||||
|
||||
$areas = $this->areas();
|
||||
$frames = $this->frameHosts($request, ...array_values($areas));
|
||||
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
|
||||
// Palette und Hinweisfarben wie auf jeder Seite - das Archiv trug bisher weder das eine
|
||||
// noch das andere und zeigte den Einwilligungshinweis in den Standardfarben.
|
||||
$theme = $this->theme->load();
|
||||
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
||||
// Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe
|
||||
// Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften,
|
||||
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
|
||||
// eigene Vorlage, die von alledem nur die Palette kannte.
|
||||
$kopf = [
|
||||
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
|
||||
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? 'Beitrag' : 'Beiträge')]],
|
||||
];
|
||||
if (null !== $term) {
|
||||
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? 'Kategorie' : 'Schlagwort'], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
|
||||
}
|
||||
$document = [
|
||||
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
|
||||
'title' => $heading,
|
||||
'description' => null === $term ? 'Alle Beiträge, die neuesten zuerst.' : sprintf('Beiträge %s „%s“.', 'category' === $kind ? 'in der Kategorie' : 'zum Schlagwort', $term['name']),
|
||||
'blocks' => [
|
||||
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
|
||||
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
|
||||
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]],
|
||||
]],
|
||||
],
|
||||
];
|
||||
$seite = [
|
||||
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => 'de',
|
||||
'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document,
|
||||
];
|
||||
|
||||
return $this->shell($this->render('archive.html.twig', [
|
||||
'pageStyles' => $styles,
|
||||
'areas' => $areas,
|
||||
'needsScrolled' => $needsScrolled,
|
||||
'navigation' => $this->navigation(''),
|
||||
'term' => $term,
|
||||
'heading' => null === $term ? 'Beiträge' : $term['name'],
|
||||
'result' => $result,
|
||||
'base' => $base,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
|
||||
'navigation' => $this->navigation(''),
|
||||
]), $needsScrolled, $styles, $frames);
|
||||
return $this->renderPage($request, $seite, $seite['path']);
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
@@ -553,7 +671,7 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return array<string, list<array>>
|
||||
*/
|
||||
private function postLists(array $document, int $page = 1): array
|
||||
private function postLists(array $documents, int $page = 1): array
|
||||
{
|
||||
$lists = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$lists, $page): void {
|
||||
@@ -576,7 +694,9 @@ final class PageController extends AbstractController
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
@@ -714,9 +834,13 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
/** Die Kennungen der Bereiche dieser Antwort, fuer ihre Skriptdateien. @var array<string, string> */
|
||||
private array $areaIds = [];
|
||||
|
||||
private function areas(?array $page = null): array
|
||||
{
|
||||
$areas = [];
|
||||
$this->areaIds = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
// A page may ask for another area or for none at all: a landing page without a
|
||||
// navigation is a normal wish, and it must not need a second header document that
|
||||
@@ -731,12 +855,31 @@ final class PageController extends AbstractController
|
||||
$partial ??= $this->partials->findByKind($kind);
|
||||
if (null !== $partial) {
|
||||
$areas[$kind] = $partial['document'];
|
||||
$this->areaIds[$kind] = (string) $partial['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eigenen Skripte eines Bereichs, als Datei - dasselbe Muster wie bei einer Seite. Vorher
|
||||
* wurde ein Skript im Kopf oder Fuss gespeichert, geprueft und nie ausgefuehrt.
|
||||
*/
|
||||
#[Route('/assets/bereich/{id}.js', name: 'area_script', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['GET'])]
|
||||
public function areaScript(string $id): Response
|
||||
{
|
||||
$partial = $this->partials->find($id);
|
||||
if (null === $partial || [] === $this->code->scriptsOf($partial['document'])) {
|
||||
throw $this->createNotFoundException('Kein Skript.');
|
||||
}
|
||||
$response = new Response($this->code->compileScript($partial['document']), 200, ['Content-Type' => 'text/javascript; charset=UTF-8']);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every linked section a page or one of its areas refers to, including sections that
|
||||
* link further sections. A missing section is left out; the renderer shows a placeholder only
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
@@ -12,6 +13,7 @@ use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -38,6 +40,8 @@ final class PartialController extends AbstractController
|
||||
private CustomCode $code,
|
||||
private FontLibrary $fonts,
|
||||
private CsrfTokenManagerInterface $csrf,
|
||||
private PluginRegistry $plugins,
|
||||
private FormRepository $forms,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -132,7 +136,7 @@ final class PartialController extends AbstractController
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
if ('admin_partial_preview' === $request->attributes->get('_route')) {
|
||||
$this->validator->validate($document, false);
|
||||
$this->validator->validate($document, false, true);
|
||||
|
||||
return $this->renderPreview($partial, $document);
|
||||
}
|
||||
@@ -143,7 +147,8 @@ final class PartialController extends AbstractController
|
||||
$this->code->assertScriptsUnchanged($document, $partial['document']);
|
||||
}
|
||||
$this->partials->save($id, $document, $body['expectedVersion']);
|
||||
$partial['document'] = $document;
|
||||
// Die gespeicherte Fassung zurueck - bereinigter Rich Text steht sofort im Editor.
|
||||
$partial['document'] = $this->partials->find($id)['document'] ?? $document;
|
||||
$partial['version'] = $body['expectedVersion'] + 1;
|
||||
|
||||
return $this->json($this->payload($partial));
|
||||
@@ -172,6 +177,29 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
// Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben.
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($document).$this->code->compileDocument($document);
|
||||
foreach ($sections as $section) {
|
||||
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
|
||||
}
|
||||
$forms = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$forms): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
$forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||||
if (null !== $form) {
|
||||
$styles .= $this->forms->css($form, $theme);
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
foreach ($sections as $section) {
|
||||
$walk($section['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', [
|
||||
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
||||
'canonical' => '',
|
||||
@@ -182,8 +210,8 @@ final class PartialController extends AbstractController
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'forms' => [],
|
||||
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
|
||||
'forms' => $forms,
|
||||
'pageStyles' => $styles,
|
||||
'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [],
|
||||
]);
|
||||
}
|
||||
@@ -201,6 +229,8 @@ final class PartialController extends AbstractController
|
||||
'revisionId' => null,
|
||||
'publishedRevisionId' => null,
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
'plugins' => $this->plugins->forEditor(),
|
||||
'presets' => $this->plugins->presetsForEditor(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Der Bildschirm „Plugins“: was im Ordner liegt, was davon eingeschaltet ist, der Schalter, die
|
||||
* Einstellungen eines Plugins.
|
||||
*/
|
||||
final class PluginAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins', name: 'admin_plugins', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Ein Plugin bringt Code auf die Website - das schaltet nur ein Administrator.
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/{state}', name: 'admin_plugin_toggle', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'state' => 'an|aus'], methods: ['POST'])]
|
||||
public function toggle(Request $request, string $name, string $state): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('plugins', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
if ('an' === $state) {
|
||||
$this->plugins->activate($name);
|
||||
|
||||
return $this->back(sprintf('„%s“ ist eingeschaltet. Seine Bausteine stehen jetzt im Editor unter „Erweiterungen“.', $this->plugins->all()[$name]['title']), 'success');
|
||||
}
|
||||
$this->plugins->deactivate($name);
|
||||
|
||||
return $this->back(sprintf('„%s“ ist ausgeschaltet. Seine Bausteine bleiben in den Seiten gespeichert, werden aber nicht mehr dargestellt.', $this->plugins->all()[$name]['title']), 'success');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/einstellungen', name: 'admin_plugin_settings', requirements: ['name' => '[a-z][a-z0-9]{1,30}'], methods: ['POST'])]
|
||||
public function settings(Request $request, string $name): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('plugins', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->plugins->saveSettings($name, $request->request->all('settings'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back(sprintf('Die Einstellungen von „%s“ wurden gespeichert.', $this->plugins->all()[$name]['title']), 'success');
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_plugins');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Liefert Stylesheets, Skripte und Bilder eines aktiven Plugins aus.
|
||||
*
|
||||
* Plugins liegen außerhalb von public/, damit ein Ordner plugins/ nichts ausliefert, was nicht
|
||||
* im Manifest steht. Der Weg hierher ist derselbe Ursprung wie die Seite, deshalb reicht der
|
||||
* Richtlinie das 'self', das sie ohnehin erlaubt. Die Adresse trägt die Fassung des Plugins,
|
||||
* also darf der Browser die Datei lange behalten.
|
||||
*/
|
||||
final class PluginAssetController extends AbstractController
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/plugin-assets/{plugin}/{path}', name: 'plugin_asset', requirements: ['plugin' => '[a-z][a-z0-9]{1,30}', 'path' => 'assets/.+'], methods: ['GET'])]
|
||||
public function asset(string $plugin, string $path): Response
|
||||
{
|
||||
$file = $this->plugins->assetFile($plugin, $path) ?? throw $this->createNotFoundException('Datei nicht gefunden.');
|
||||
$response = new BinaryFileResponse($file, 200, [], false);
|
||||
$response->headers->set('Content-Type', PluginRegistry::ASSET_TYPES[strtolower(pathinfo($file, PATHINFO_EXTENSION))]);
|
||||
$response->headers->set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,11 @@ final class TaskController extends AbstractController
|
||||
if (!$state['admin']) {
|
||||
return new JsonResponse(['off' => true, 'ran' => []]);
|
||||
}
|
||||
@set_time_limit(120);
|
||||
// Anheben, nicht senken: auf einem Prozess ohne Grenze waere 120 eine neue Grenze.
|
||||
$limit = (int) ini_get('max_execution_time');
|
||||
if ($limit > 0 && $limit < 120) {
|
||||
@set_time_limit(120);
|
||||
}
|
||||
$ran = $this->scheduler->runDue('verwaltung', 3, 45);
|
||||
|
||||
return new JsonResponse([
|
||||
|
||||
Reference in new issue
Block a user