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:
kantarciandClaude Opus 5 committed 2026-09-16 07:32:35 +02:00
1 parent 66ff68abf8
commit c8a8fa7460
74 files changed
+5352 -243

No files matched your search

+45 -8
View File
@@ -3,10 +3,13 @@
namespace App\Content;
use App\Domain\InvalidDocument;
use App\Plugin\PluginRegistry;
final class DocumentValidator
{
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode())
private bool $writing = false;
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
{
}
@@ -35,16 +38,24 @@ final class DocumentValidator
public const V15_TYPES = ['icon', 'progress', 'slider'];
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
public const V23_TYPES = ['social'];
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
public const SCHEMA_VERSION = 23;
/**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
* reusable section that appears on many pages must not bring one
* of its own, or every page carrying it would have two.
* @param bool $writing Beim Schreiben werden die Eigenschaften von Plugin-Bausteinen
* gegen ihr Schema geprüft. Beim Lesen nicht: ein Plugin, das
* nach dem Speichern eingeschaltet oder geändert wurde, darf
* eine gespeicherte Seite nicht unlesbar machen - der Renderer
* lässt unpassende Eigenschaften ohnehin nicht durch.
*/
public function validate(array $document, bool $requireHeading = true): void
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
{
$this->writing = $writing;
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], true), 'Unsupported document schema.');
$this->require(is_int($schemaVersion) && $schemaVersion >= 1 && $schemaVersion <= self::SCHEMA_VERSION, 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -112,6 +123,21 @@ final class DocumentValidator
$props = $block['props'] ?? [];
$layout = $block['layout'] ?? [];
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
// Ein aktives Plugin kennt das Schema seines Bausteins - dann wird es auch geprüft, so
// streng wie bei einem Kernbaustein. Ohne Plugin bleibt der Erhaltungsvertrag: aufbewahren,
// nicht prüfen, nicht rendern.
if ($plugin && $this->writing && null !== $this->plugins && null !== ($definition = $this->plugins->block($type))) {
$this->plugins->validateProps($type, $props);
// Ein Container nimmt Kinder auf, ein gewöhnlicher Baustein nicht; eine Liste
// erlaubter Kinder im Manifest gilt wie ALLOW bei Galerie oder Akkordeon.
$kinder = is_array($block['children'] ?? null) ? $block['children'] : [];
$this->require($definition['container'] || [] === $kinder, sprintf('„%s“ nimmt keine Bausteine auf.', $definition['label']));
if (null !== $definition['children']) {
foreach ($kinder as $kind) {
$this->require(in_array($kind['type'] ?? '', $definition['children'], true), sprintf('In „%s“ passt nur: %s.', $definition['label'], implode(', ', $definition['children'])));
}
}
}
foreach ($layout as $key => $value) {
if ('visibility' === $key) {
$this->require($schemaVersion >= 22 && in_array($value, ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'], true), 'Unbekannte Sichtbarkeit oder Dokumentschema vor v22.');
@@ -240,7 +266,9 @@ final class DocumentValidator
foreach (['phone', 'email', 'address'] as $field) {
$this->text($props[$field] ?? null, 'address' === $field ? 200 : 80, true);
}
$this->require('' !== trim($props['phone'].$props['email'].$props['address']), 'A contact block needs at least one detail.');
// Leer ist erlaubt: der Baustein steht nach dem Einfuegen erst einmal ohne Angaben da,
// und ein Dokument mit einem leeren Kontaktblock darf sich speichern lassen. Oeffentlich
// rendert er dann nichts, im Editor einen Hinweis.
if ('' !== $props['phone']) {
$this->require(1 === preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $props['phone']), 'Invalid phone number.');
}
@@ -263,6 +291,10 @@ final class DocumentValidator
if ('tabitem' === $type) {
$this->text($props['title'] ?? null, 160);
}
if ('legal' === $type) {
// Die Beschriftung der Rechtszeile ist der Name der Landmarke fuer Vorlesesoftware.
$this->text($props['label'] ?? 'Rechtliches', 60, true);
}
if ('menu' === $type) {
// The entries live in the menu administration; the block only says which slot to show
// and how to lay it out.
@@ -283,7 +315,7 @@ final class DocumentValidator
if ('partialref' === $type) {
// Only the address of the section is content. Its blocks live in the partial, so one
// change reaches every page that links it and a page cannot alter a shared section.
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
$this->require(is_string($props['partial'] ?? null) && ('' === $props['partial'] || (1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80)), 'Invalid section address.');
}
if ('icon' === $type) {
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
@@ -338,7 +370,9 @@ final class DocumentValidator
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
}
}
if ('embed' === $type) {
if ('embed' === $type && '' !== ($props['url'] ?? '')) {
// Ohne Adresse ist der Baustein ein leerer Rahmen, den der Editor benennt - erst mit
// Adresse gelten Titel, Anbieter und Hoehe.
// Everything about the embed is validated here, because the address ends up in an
// iframe and in the content security policy of the page.
$this->require(isset(Consent::GROUPS[$props['group'] ?? '']), 'Unbekannte Einwilligungsgruppe.');
@@ -358,7 +392,8 @@ final class DocumentValidator
if ('form' === $type) {
// Only the address is content. Fields, recipient and consent text stay in the database,
// so a stored document can never redirect a submission or change what is asked.
$this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.');
// Leer heisst: noch kein Formular gewaehlt. Der Editor zeigt das, die Website nichts.
$this->require(is_string($props['form'] ?? null) && ('' === $props['form'] || (1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80)), 'Invalid form address.');
}
if ('gallery' === $type && isset($props['lightbox'])) {
$this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.');
@@ -432,7 +467,9 @@ final class DocumentValidator
foreach ($design as $device => $settings) {
$this->require(in_array($device, ['desktop', 'tablet', 'mobile'], true) && is_array($settings), 'Invalid responsive device.');
foreach ($settings as $key => $value) {
$this->require(isset($tokens[$key]) && in_array($type, $tokens[$key]['types'], true), 'Design setting does not apply to this block.');
// Plugin-Bausteine dürfen jedes Token tragen: die Klassen dafür sind allgemein, und
// das Manifest kann nicht in dieser Datei stehen.
$this->require(isset($tokens[$key]) && (in_array($type, $tokens[$key]['types'], true) || str_contains($type, '.')), 'Design setting does not apply to this block.');
$this->require(is_string($value) && isset($tokens[$key]['values'][$value]), 'Invalid responsive design token.');
}
}
+232 -21
View File
@@ -9,6 +9,12 @@ use Doctrine\DBAL\Connection;
/**
* Form definitions and their submissions. A definition is a validated list of fields; a submission
* stores the answers, the moment consent was given and a keyed hash of the sender address.
*
* Ein Formular kann in Schritten fragen: ein Feld vom Typ „Schritt“ eröffnet einen Abschnitt, alle
* Felder bis zum nächsten Schritt gehören dazu. Und ein Feld oder ein Schritt kann von einer
* früheren Auswahl abhängen („nur zeigen, wenn Immobilienart = Wohnung“). Beides steht in der
* Definition, nicht im Skript: der Server entscheidet beim Absenden selbst, welche Felder galten,
* und verlangt nichts, was der Besucher gar nicht sehen sollte.
*/
final class FormRepository
{
@@ -16,11 +22,18 @@ final class FormRepository
'text' => 'Einzeiliger Text',
'email' => 'E-Mail-Adresse',
'phone' => 'Telefonnummer',
'number' => 'Zahl',
'textarea' => 'Mehrzeiliger Text',
'select' => 'Auswahl',
'select' => 'Auswahl (Liste)',
'radio' => 'Auswahl (Optionsfelder)',
'checkboxes' => 'Mehrfachauswahl',
'consent' => 'Einwilligung',
'step' => 'Neuer Schritt',
];
public const MAX_FIELDS = 25;
/** Die Feldtypen mit Optionen; nur von ihnen kann ein anderes Feld abhängen. */
public const CHOICE_TYPES = ['select', 'radio', 'checkboxes'];
/** Ein Preisfinder mit vier Objektarten braucht mehr als ein Kontaktformular. */
public const MAX_FIELDS = 40;
/** Die gestaltbaren Farben eines Formulars, mit ihrer Bezeichnung in der Verwaltung. */
public const COLORS = [
'fieldBorderColor' => 'den Rahmen der Felder',
@@ -56,10 +69,14 @@ final class FormRepository
private function present(array $row): array
{
// Ältere Definitionen kennen weder Grenzen noch Bedingungen; die Vorlage und die Prüfung
// verlassen sich darauf, dass jeder Schlüssel da ist.
$fields = array_map(static fn (array $field): array => $field + ['help' => '', 'options' => [], 'images' => [], 'min' => null, 'max' => null, 'unit' => '', 'showIf' => null], json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR));
return [
'id' => $row['id'], 'slug' => $row['slug'], 'name' => $row['name'],
'fields' => json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR),
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR),
'fields' => $fields,
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR) + ['summary' => false],
'version' => (int) $row['version'],
];
}
@@ -114,6 +131,8 @@ final class FormRepository
throw new InvalidForm(sprintf('Ein Formular fasst höchstens %d Felder.', self::MAX_FIELDS));
}
$names = [];
// Die Optionen der bisherigen Auswahlfelder: nur davon darf ein späteres Feld abhängen.
$choices = [];
$clean = [];
foreach ($fields as $field) {
$name = strtolower(trim((string) ($field['name'] ?? '')));
@@ -130,34 +149,149 @@ final class FormRepository
throw new InvalidForm('Jedes Feld braucht eine Beschriftung mit höchstens 120 Zeichen.');
}
$options = [];
if ('select' === $type) {
if (in_array($type, self::CHOICE_TYPES, true)) {
foreach ((array) ($field['options'] ?? []) as $option) {
$option = trim((string) $option);
if ('' !== $option && mb_strlen($option) <= 120) {
// Kein Komma in einer Option: die Verwaltung trennt Optionen daran, und eine
// Mehrfachauswahl wird mit Komma gespeichert.
if ('' !== $option && mb_strlen($option) <= 120 && !str_contains($option, ',') && !in_array($option, $options, true)) {
$options[] = $option;
}
}
if (count($options) < 2) {
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen.', $label));
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen ohne Komma.', $label));
}
}
$images = in_array($type, self::CHOICE_TYPES, true) ? $this->validateImages($field['images'] ?? [], $options, $label) : [];
$min = $max = null;
$unit = '';
if ('number' === $type) {
foreach (['min', 'max'] as $grenze) {
$wert = str_replace(',', '.', trim((string) ($field[$grenze] ?? '')));
if ('' === $wert) {
continue;
}
if (!is_numeric($wert)) {
throw new InvalidForm(sprintf('Die Grenze „%s“ von „%s“ ist keine Zahl.', $wert, $label));
}
$$grenze = (float) $wert;
}
if (null !== $min && null !== $max && $min >= $max) {
throw new InvalidForm(sprintf('Bei „%s“ muss die Untergrenze unter der Obergrenze liegen.', $label));
}
$unit = mb_substr(trim((string) ($field['unit'] ?? '')), 0, 12);
}
$showIf = $this->validateCondition($field, $label, $choices);
$clean[] = [
'name' => $name,
'type' => $type,
'label' => $label,
// A consent field is only meaningful when it has to be ticked.
'required' => 'consent' === $type ? true : (bool) ($field['required'] ?? false),
// A consent field is only meaningful when it has to be ticked; a step asks nothing.
'required' => 'consent' === $type ? true : ('step' !== $type && (bool) ($field['required'] ?? false)),
'help' => mb_substr(trim((string) ($field['help'] ?? '')), 0, 200),
'options' => $options,
'images' => $images,
'min' => $min,
'max' => $max,
'unit' => $unit,
'showIf' => $showIf,
];
if (in_array($type, self::CHOICE_TYPES, true)) {
$choices[$name] = $options;
}
}
if (count(array_filter($clean, static fn (array $f): bool => 'consent' === $f['type'])) > 1) {
throw new InvalidForm('Ein Formular hat höchstens ein Einwilligungsfeld.');
}
if ([] === array_filter($clean, static fn (array $f): bool => 'step' !== $f['type'])) {
throw new InvalidForm('Ein Formular braucht mindestens ein Feld, das etwas fragt – Schritte allein reichen nicht.');
}
return $clean;
}
/**
* Die Bedingung eines Feldes: es gilt nur, wenn ein früheres Auswahlfeld einen der genannten
* Werte hat. Angenommen wird die gespeicherte Form (showIf: {field, values}) und die aus der
* Verwaltung (showIfField, showIfValues als Kommaliste).
*
* @param array<string, list<string>> $choices Optionen der Auswahlfelder vor diesem Feld
*
* @return array{field: string, values: list<string>}|null
*/
private function validateCondition(array $field, string $label, array $choices): ?array
{
$ref = strtolower(trim((string) ($field['showIfField'] ?? ($field['showIf']['field'] ?? ''))));
if ('' === $ref) {
return null;
}
if (!isset($choices[$ref])) {
throw new InvalidForm(sprintf('„%s“ soll von „%s“ abhängen, aber davor steht kein Auswahlfeld mit diesem Namen.', $label, $ref));
}
$wanted = $field['showIfValues'] ?? ($field['showIf']['values'] ?? []);
if (is_string($wanted)) {
$wanted = explode(',', $wanted);
}
$values = [];
foreach ((array) $wanted as $wert) {
$wert = trim((string) $wert);
if ('' === $wert || in_array($wert, $values, true)) {
continue;
}
if (!in_array($wert, $choices[$ref], true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ – die Bedingung von „%s“ könnte nie zutreffen.', $wert, $ref, $label));
}
$values[] = $wert;
}
if ([] === $values) {
throw new InvalidForm(sprintf('Für die Bedingung von „%s“ fehlt der Wert, bei dem das Feld gilt.', $label));
}
return ['field' => $ref, 'values' => $values];
}
/**
* Die Bilder einer Auswahl: je Option höchstens ein Bild aus der Mediathek. Sobald eine Option
* eines hat, zeigt die Website die Auswahl als anklickbare Bilder statt als Kästchen.
* Angenommen wird die gespeicherte Form (Option => Kennung) und die aus der Verwaltung
* (eine Zeile je Option: „Option = Kennung“).
*
* @param list<string> $options
*
* @return array<string, string>
*/
private function validateImages(mixed $input, array $options, string $label): array
{
if (is_string($input)) {
$paare = [];
foreach (preg_split('/\\r?\\n/', $input) ?: [] as $zeile) {
if (!str_contains($zeile, '=')) {
continue;
}
[$option, $kennung] = array_map(trim(...), explode('=', $zeile, 2));
$paare[$option] = $kennung;
}
$input = $paare;
}
$images = [];
foreach ((array) $input as $option => $kennung) {
$option = trim((string) $option);
$kennung = strtolower(trim((string) $kennung));
if ('' === $kennung) {
continue;
}
if (!in_array($option, $options, true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ und kann kein Bild bekommen.', $option, $label));
}
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $kennung)) {
throw new InvalidForm(sprintf('Das Bild für „%s“ ist keine Kennung aus der Mediathek.', $option));
}
$images[$option] = $kennung;
}
return $images;
}
private function validateSettings(array $settings, array $fields): array
{
$recipient = trim((string) ($settings['recipient'] ?? ''));
@@ -184,6 +318,9 @@ final class FormRepository
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
'replyToField' => $replyTo,
'buttonLabel' => mb_substr(trim((string) ($settings['buttonLabel'] ?? '')), 0, 40) ?: 'Absenden',
// Die Zusammenfassung vor dem Absenden - ein Schritt, den nur das Skript zeigt; ohne
// Skript steht das ganze Formular ohnehin auf einer Seite.
'summary' => (bool) ($settings['summary'] ?? false),
];
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
@@ -198,6 +335,21 @@ final class FormRepository
return $clean;
}
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
public function needsScript(array $form): bool
{
if ($form['settings']['summary'] ?? false) {
return true;
}
foreach ($form['fields'] as $field) {
if ('step' === $field['type'] || null !== ($field['showIf'] ?? null)) {
return true;
}
}
return false;
}
/**
* Die Gestaltung eines Formulars als Regel fuer die Seite, die es zeigt.
*
@@ -217,7 +369,7 @@ final class FormRepository
}
$values[] = $variable.':'.$value;
if ('buttonColor' === $field) {
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,#000)';
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,var(--ink))';
$button = true;
}
if ('buttonInk' === $field) {
@@ -243,6 +395,10 @@ final class FormRepository
/**
* Checks one submission against the stored definition.
*
* Ein Feld hinter einer Bedingung zählt nur, wenn sie zutrifft; ein Schritt hinter einer
* Bedingung nimmt seine Felder mit. Was nicht galt, wird weder verlangt noch gespeichert -
* auch wenn ein Skript im Browser es mitgeschickt hat.
*
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
*/
public function check(array $form, array $input): array
@@ -250,44 +406,99 @@ final class FormRepository
$values = [];
$errors = [];
$consent = false;
// Die gewählten Optionen der bisherigen Auswahlfelder, für die Bedingungen danach.
$chosen = [];
$stepActive = true;
// Kein Pfeil: der bekäme die leere Liste von oben und sähe keine spätere Wahl.
$holds = static function (?array $showIf) use (&$chosen): bool {
return null === $showIf || [] !== array_intersect($showIf['values'], $chosen[$showIf['field']] ?? []);
};
foreach ($form['fields'] as $field) {
$raw = $input[$field['name']] ?? '';
$name = $field['name'];
if ('step' === $field['type']) {
$stepActive = $holds($field['showIf']);
continue;
}
if (!$stepActive || !$holds($field['showIf'])) {
$values[$name] = '';
continue;
}
$raw = $input[$name] ?? '';
if ('checkboxes' === $field['type']) {
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
if ([] !== array_diff($picked, $field['options'])) {
$errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.';
$picked = array_values(array_intersect($picked, $field['options']));
} elseif ([] === $picked && $field['required']) {
$errors[$name] = 'Bitte wähle mindestens eine Option.';
}
$chosen[$name] = $picked;
$values[$name] = implode(', ', $picked);
continue;
}
$value = is_string($raw) ? trim($raw) : '';
$limit = 'textarea' === $field['type'] ? 5000 : 200;
if (mb_strlen($value) > $limit) {
$errors[$field['name']] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$value = mb_substr($value, 0, $limit);
}
if ('consent' === $field['type']) {
$consent = '' !== $value;
if (!$consent) {
$errors[$field['name']] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
$errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
}
$values[$field['name']] = $consent ? 'ja' : '';
$values[$name] = $consent ? 'ja' : '';
continue;
}
if ('' === $value) {
if ($field['required']) {
$errors[$field['name']] = 'Dieses Feld wird benötigt.';
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
}
$values[$field['name']] = '';
$values[$name] = '';
continue;
}
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field['name']] = 'Bitte gib eine gültige E-Mail-Adresse an.';
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
$errors[$field['name']] = 'Bitte gib eine gültige Telefonnummer an.';
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
}
if ('select' === $field['type'] && !in_array($value, $field['options'], true)) {
$errors[$field['name']] = 'Bitte wähle eine der angebotenen Optionen.';
if ('number' === $field['type']) {
$zahl = str_replace(',', '.', $value);
if (!is_numeric($zahl)) {
$errors[$name] = 'Bitte gib eine Zahl an.';
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
$errors[$name] = match (true) {
null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
};
} else {
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
$value = self::zahl((float) $zahl);
}
}
$values[$field['name']] = $value;
if (in_array($field['type'], ['select', 'radio'], true)) {
if (!in_array($value, $field['options'], true)) {
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
} else {
$chosen[$name] = [$value];
}
}
$values[$name] = $value;
}
return ['values' => $values, 'errors' => $errors, 'consent' => $consent];
}
/** Eine Zahl ohne unnötige Nachkommastellen, für Meldungen und die gespeicherte Antwort. */
public static function zahl(float $wert): string
{
$text = number_format($wert, 3, '.', '');
return str_contains($text, '.') ? rtrim(rtrim($text, '0'), '.') : $text;
}
/** True when this sender has already sent the allowed number of messages within the hour. */
public function rateLimited(string $senderHash): bool
{
+2 -2
View File
@@ -32,7 +32,7 @@ final class PageRepository
throw new InvalidPage('Unbekannter Inhaltstyp.');
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
$this->validator->validate($document, writing: true);
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
$path = $slug;
@@ -334,7 +334,7 @@ final class PageRepository
// Sanitising happens before validating and before storing, so the database never holds
// markup the allowlist would reject and no renderer has to remember to filter.
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
$this->validator->validate($document, writing: true);
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
$id = bin2hex(random_bytes(16));
+67 -2
View File
@@ -99,7 +99,7 @@ final class PartialRepository
$slug = 'default';
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
$this->validator->validate($document, false, true);
if (null !== $this->findByKind($kind, $slug, $locale)) {
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
}
@@ -117,7 +117,7 @@ final class PartialRepository
public function save(string $id, array $document, int $expectedVersion): void
{
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
$this->validator->validate($document, false, true);
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
// A section must not end up containing itself, directly or through another section.
$this->guardAgainstCycles($id, $document);
@@ -179,6 +179,71 @@ final class PartialRepository
}
/** @return list<string> */
/**
* Dieselbe Sektion mehrfach auf einer Seite: die zweite und jede weitere Verknüpfung bekommt
* eine Kopie mit eigenen Kennungen (`id-2`, `id-3`) und ohne Sprungziele, damit die Seite keine
* doppelten IDs trägt - ein Anker, ein Stil und jede Skriptsuche träfen sonst nur die erste.
*
* Gezählt wird in Renderreihenfolge über alle Dokumente einer Seite hinweg (`$seen` teilen sich
* Kopf, Inhalt und Fuß); verschachtelte Verknüpfungen in einer Sektion zählen mit, bis zur
* Verschachtelungsgrenze. `$sections` bekommt die Kopien unter `slug~n`, der Verweis im
* zurückgegebenen Dokument zeigt dorthin.
*
* @param array<string, array> $sections die aufgelösten Sektionen, werden um Kopien ergänzt
* @param array<string, int> $seen Zähler je Slug
* @param array<string, array> $pristine die Sektionen, wie sie gespeichert sind
*/
public function expandRepeatedLinks(array $document, array &$sections, array &$seen, array $pristine, int $depth = 0): array
{
$walk = function (array $blocks) use (&$walk, &$sections, &$seen, $pristine, $depth): array {
foreach ($blocks as $i => $block) {
$slug = 'partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null) ? $block['props']['partial'] : null;
if (null !== $slug && isset($pristine[$slug])) {
$n = $seen[$slug] = ($seen[$slug] ?? 0) + 1;
if (1 === $n) {
if ($depth < self::MAX_NESTING) {
$sections[$slug] = $this->expandRepeatedLinks($pristine[$slug], $sections, $seen, $pristine, $depth + 1);
}
} else {
$key = $slug.'~'.$n;
$copy = $this->renamed($pristine[$slug], '-'.$n);
$sections[$key] = $depth < self::MAX_NESTING ? $this->expandRepeatedLinks($copy, $sections, $seen, $pristine, $depth + 1) : $copy;
$blocks[$i]['props']['partial'] = $key;
}
}
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$i]['children'] = $walk($block['children']);
}
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
/** Ein Dokument mit angehängter Kennung an jedem Block, ohne Sprungziele. */
private function renamed(array $document, string $suffix): array
{
$walk = static function (array $blocks) use (&$walk, $suffix): array {
foreach ($blocks as $i => $block) {
if (isset($block['id'])) {
$blocks[$i]['id'] = substr((string) $block['id'], 0, 64 - strlen($suffix)).$suffix;
}
unset($blocks[$i]['anchor']);
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$i]['children'] = $walk($block['children']);
}
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
public function referencedSlugs(array $document): array
{
$slugs = [];
+26
View File
@@ -311,6 +311,21 @@ final class Theme
return null;
}
/** Die relative Leuchtdichte einer Hexfarbe nach WCAG; 0 fuer Schwarz, 1 fuer Weiss, 0.5 wenn unlesbar. */
public static function luminance(string $hex): float
{
if (1 !== preg_match('/^#([0-9a-f]{6})/i', $hex, $m)) {
return 0.5;
}
$kanal = static function (int $wert): float {
$c = $wert / 255;
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * $kanal(hexdec(substr($m[1], 0, 2))) + 0.7152 * $kanal(hexdec(substr($m[1], 2, 2))) + 0.0722 * $kanal(hexdec(substr($m[1], 4, 2)));
}
public function css(array $theme): string
{
$variables = [];
@@ -344,6 +359,17 @@ final class Theme
$variables[] = sprintf('--muted:var(--k-muted,color-mix(in srgb,%s 80%%,%s))', $ink, $paper);
$variables[] = sprintf('--line:var(--k-line,color-mix(in srgb,%s 20%%,%s))', $ink, $paper);
$variables[] = sprintf('--mint:var(--k-mint,color-mix(in srgb,%s 14%%,%s))', $colour($base['linkColor']), $paper);
// Dunkle Palette: Formularfelder, Rollbalken und Fehlermeldungen folgen ihr. Das Rot fuer
// Fehler war fest auf Weiss gedacht; auf dunklem Grund ist es unlesbar. Also je nach
// Helligkeit des Papiers ein Rot, das dort steht - und der Hintergrund der Meldung aus
// Rot und Papier gemischt statt aus einem festen Hellrosa.
// Fuer die Leuchtdichte braucht es den Hexwert, nicht den Verweis var(--k-...).
$hexwert = static fn (string $wert): string => str_starts_with($wert, '@') ? (string) (array_column($theme['colors'], 'value', 'key')[substr($wert, 1)] ?? '#ffffff') : $wert;
$dunkel = self::luminance($hexwert($base['backgroundColor'])) < 0.4;
$variables[] = '--k-scheme:'.($dunkel ? 'dark' : 'light');
$variables[] = '--k-error:'.($dunkel ? '#ff9b8a' : '#8b2f22');
$variables[] = sprintf('--k-error-bg:color-mix(in srgb,%s 12%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
$variables[] = sprintf('--k-error-line:color-mix(in srgb,%s 45%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
$scale = (float) $base['headingScale'];
$headings = '';
+62 -5
View File
@@ -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()];
}
}
+13 -1
View File
@@ -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),
+12 -11
View File
@@ -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.
+185 -42
View File
@@ -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
+34 -4
View File
@@ -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(),
];
}
+73
View File
@@ -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');
}
}
+36
View File
@@ -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;
}
}
+5 -1
View File
@@ -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([
+17 -1
View File
@@ -45,6 +45,22 @@ final class TaskWorkerSubscriber implements EventSubscriberInterface
return [KernelEvents::TERMINATE => ['onTerminate', -64]];
}
/**
* Hebt die Laufzeitgrenze an - und senkt sie nie.
*
* `set_time_limit()` setzt die Grenze, es verlaengert nicht: auf einem Prozess ohne Grenze
* (Konsole, Testlauf, Hoster mit `max_execution_time = 0`) macht ein fester Wert aus
* "unbegrenzt" plötzlich "noch so viele Sekunden" - und der Prozess stirbt spaeter an einer
* Stelle, die mit Aufgaben nichts zu tun hat.
*/
private function raiseTimeLimit(int $seconds): void
{
$current = (int) ini_get('max_execution_time');
if ($current > 0 && $current < $seconds) {
@set_time_limit($seconds);
}
}
public function onTerminate(TerminateEvent $event): void
{
$request = $event->getRequest();
@@ -73,7 +89,7 @@ final class TaskWorkerSubscriber implements EventSubscriberInterface
// Die Antwort ist raus; ein abgebrochener Aufruf soll die Arbeit trotzdem zu Ende
// bringen, sonst bliebe eine Aufgabe auf halbem Weg mit gesetzter Sperre liegen.
@ignore_user_abort(true);
@set_time_limit($this->budget + 60);
$this->raiseTimeLimit($this->budget + 60);
$this->scheduler->runDue($admin ? 'verwaltung' : 'besuch', $this->limit, $this->budget);
} catch (\Throwable) {
// Eine Aufgabe darf niemals eine Website kaputtmachen. Was schiefging, steht in der
+85 -5
View File
@@ -127,6 +127,23 @@ final class ArchiveUpdate
return ['ok' => true, 'steps' => $schritte];
}
$lock = $this->updater->lock();
if (null === $lock) {
$melden($schritte, 'Sperre', false, 'Ein anderes Update läuft gerade (var/update.lock). Bitte warten.');
return ['ok' => false, 'steps' => $schritte];
}
try {
return $this->apply($schritte, $melden, $ziel, $neueste);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Der Teil des Updates, der die Installation veraendert - unter der Sperre. */
private function apply(array &$schritte, callable $melden, string $ziel, array $neueste): array
{
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
@@ -145,14 +162,14 @@ final class ArchiveUpdate
}
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
if (null === $inhalt || '' === $inhalt) {
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
// Megabyte passte in memory_limit, ein groesseres nicht mehr.
if (!$this->download->toFile(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel), $archiv)) {
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
file_put_contents($archiv, $inhalt);
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 1024)));
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round((int) filesize($archiv) / 1024)));
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
if (null === $erwartet) {
@@ -204,6 +221,9 @@ final class ArchiveUpdate
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
// Betreiber und wären sonst weg.
$this->mergePublic($quelle.'/public', $this->root.'/public');
// Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber
// installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt.
$this->mergePublic($quelle.'/plugins', $this->root.'/plugins');
} catch (\Throwable $fehler) {
foreach (self::ERSETZEN as $eintrag) {
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
@@ -286,8 +306,9 @@ final class ArchiveUpdate
@unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false);
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
$schritte[] = ['step' => 'Aufräumen', 'ok' => true, 'detail' => $this->aufraeumen()];
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
$this->updater->version(),
)];
@@ -327,10 +348,69 @@ final class ArchiveUpdate
continue;
}
if ('.htaccess' === $eintrag && is_file($ziel)) {
$this->mergeHtaccess($quelle, $ziel);
continue;
}
@copy($quelle, $ziel);
}
}
/**
* Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat,
* eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten
* Zeilen des Releases zurueck, und die Website lief nach dem Update wieder ohne Umleitung.
*/
private function mergeHtaccess(string $neu, string $alt): void
{
$vorher = (string) file_get_contents($alt);
$inhalt = (string) file_get_contents($neu);
foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) {
$aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher);
if ($aktiv) {
$inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt);
}
}
file_put_contents($alt, $inhalt);
}
/**
* Raeumt nach einem gelungenen Update auf: die Arbeitsordner unter var/update/ bis auf die
* zwei juengsten, und die Zwischenspeicher frueherer Fassungen unter var/cache/. Beides wuchs
* vorher mit jedem Update, bis der Speicherplatz des Hostings voll war.
*/
private function aufraeumen(): string
{
$entfernt = 0;
$ordner = glob($this->root.'/var/update/*', GLOB_ONLYDIR) ?: [];
rsort($ordner);
foreach (array_slice($ordner, 2) as $pfad) {
$this->loeschen($pfad);
++$entfernt;
}
$aktuell = 'release-'.substr(hash_file('sha256', $this->root.'/VERSION') ?: '', 0, 16);
foreach (glob($this->root.'/var/cache/*/release-*', GLOB_ONLYDIR) ?: [] as $pfad) {
if (basename($pfad) !== $aktuell) {
$this->loeschen($pfad);
++$entfernt;
}
}
return 0 === $entfernt ? 'Nichts aufzuräumen.' : sprintf('%d alte Ordner unter var/update und var/cache entfernt.', $entfernt);
}
private function loeschen(string $pfad): void
{
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
is_dir($pfad.'/'.$eintrag) && !is_link($pfad.'/'.$eintrag) ? $this->loeschen($pfad.'/'.$eintrag) : @unlink($pfad.'/'.$eintrag);
}
@rmdir($pfad);
}
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
private function checksumOf(string $url): ?string
{
+96 -11
View File
@@ -172,14 +172,22 @@ final class Backup
$file = $this->databaseFile();
if (null === $file) {
$step($steps, 'Datenbank zurückschreiben', false, 'Diese Installation nutzt keine Dateidatenbank. Für MySQL fehlt das Einspielen noch (siehe docs/UPDATE.md).');
// MySQL: der Dump wird mit dem Client eingespielt - derselbe Weg, den man von Hand
// gehen wuerde, nur ohne Tippfehler in der Verbindung.
try {
$this->loadMysql($path.'/'.(string) ($manifest['datenbank']['datei'] ?? 'datenbank.sql'));
$step($steps, 'Datenbank zurückschreiben', true, 'Der Dump wurde mit dem MySQL-Client eingespielt.');
} catch (InvalidPage $fehler) {
$step($steps, 'Datenbank zurückschreiben', false, $fehler->getMessage());
return ['steps' => $steps, 'ok' => false];
}
$this->db->close();
$copied = @copy($path.'/'.basename($file), $file);
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
return ['steps' => $steps, 'ok' => false];
return ['steps' => $steps, 'ok' => false];
}
} else {
$this->db->close();
$copied = @copy($path.'/'.basename($file), $file);
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
return ['steps' => $steps, 'ok' => false];
}
}
// Media are added, never deleted: files uploaded after the backup would otherwise be lost
@@ -216,10 +224,7 @@ final class Backup
{
$file = $this->databaseFile();
if (null === $file) {
if (!$this->runner->has('mysqldump', $this->root)) {
throw new InvalidPage('Diese Installation nutzt keine Dateidatenbank, und mysqldump ist nicht aufrufbar. Ohne eines von beiden gibt es keine Sicherung.');
}
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
return $this->dumpMysql($directory);
}
$target = $directory.'/'.basename($file);
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
@@ -366,6 +371,86 @@ final class Backup
@rmdir($path);
}
/**
* Die Verbindungsdaten einer MySQL/MariaDB-Adresse, oder null bei einer Dateidatenbank.
*
* @return array{host: string, port: string, user: string, password: string, name: string}|null
*/
private function mysqlConnection(): ?array
{
if (1 !== preg_match('#^(mysql|mariadb)://#', $this->databaseUrl)) {
return null;
}
$teile = parse_url($this->databaseUrl);
if (!is_array($teile) || '' === trim((string) ($teile['path'] ?? ''), '/')) {
return null;
}
return [
'host' => (string) ($teile['host'] ?? '127.0.0.1'), 'port' => (string) ($teile['port'] ?? 3306),
'user' => rawurldecode((string) ($teile['user'] ?? '')), 'password' => rawurldecode((string) ($teile['pass'] ?? '')),
'name' => trim((string) $teile['path'], '/'),
];
}
/** @return list<string> die Verbindungsargumente fuer mysqldump und mysql */
private function mysqlArguments(array $verbindung): array
{
$args = ['--host='.$verbindung['host'], '--port='.$verbindung['port'], '--user='.$verbindung['user']];
if ('' !== $verbindung['password']) {
// Als Argument, nicht als Umgebungsvariable: der Befehlslaeufer startet mit einer
// festen Umgebung. Das Argument ist fuer andere Nutzer des Servers kurz sichtbar -
// wie bei jedem mysqldump-Aufruf von Hand.
$args[] = '--password='.$verbindung['password'];
}
return $args;
}
/** @return array{datei: string, bytes: int, sha256: string} */
private function dumpMysql(string $directory): array
{
$verbindung = $this->mysqlConnection();
if (null === $verbindung) {
throw new InvalidPage('Die Datenbankadresse ist weder eine SQLite-Datei noch eine MySQL-Verbindung.');
}
$dump = $this->runner->resolve('mysqldump', $this->root);
if (null === $dump) {
throw new InvalidPage('Diese Installation nutzt MySQL, und mysqldump ist nicht aufrufbar. Ohne den Client gibt es keine Sicherung - beim Hoster nach dem Pfad fragen.');
}
$target = $directory.'/datenbank.sql';
// --single-transaction: ein konsistenter Stand ohne Sperre der Tabellen; --result-file:
// direkt in die Datei, nicht durch den Speicher dieses Prozesses.
$ergebnis = $this->runner->run([...$dump, ...$this->mysqlArguments($verbindung), '--single-transaction', '--quick', '--skip-lock-tables', '--routines', '--result-file='.$target, $verbindung['name']], $this->root, 900);
if (!$ergebnis['ok'] || !is_readable($target) || 0 === (int) filesize($target)) {
@unlink($target);
throw new InvalidPage('mysqldump ist gescheitert: '.trim($ergebnis['output']));
}
return ['datei' => 'datenbank.sql', 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
}
/** Spielt einen Dump mit dem MySQL-Client ein. @throws InvalidPage */
private function loadMysql(string $dump): void
{
$verbindung = $this->mysqlConnection();
if (null === $verbindung) {
throw new InvalidPage('Die Datenbankadresse ist keine MySQL-Verbindung.');
}
if (!is_readable($dump)) {
throw new InvalidPage('Der Dump fehlt in der Sicherung: '.basename($dump));
}
$client = $this->runner->resolve('mysql', $this->root);
if (null === $client) {
throw new InvalidPage('Der MySQL-Client (mysql) ist nicht aufrufbar; der Dump liegt unter '.$dump.' und lässt sich von Hand einspielen.');
}
$this->db->close();
$ergebnis = $this->runner->run([...$client, ...$this->mysqlArguments($verbindung), '--database='.$verbindung['name'], '--execute=source '.str_replace('\\', '/', $dump)], $this->root, 900);
if (!$ergebnis['ok']) {
throw new InvalidPage('Der MySQL-Client hat den Dump nicht eingespielt: '.trim($ergebnis['output']));
}
}
private function databaseFile(): ?string
{
if (!str_starts_with($this->databaseUrl, 'sqlite:')) {
+60
View File
@@ -46,6 +46,66 @@ class Download
return $this->grund;
}
/**
* Holt die Adresse direkt in eine Datei - fuer Archive, die nicht als Zeichenkette in den
* Speicher passen sollen. Ohne curl geht es ueber get(); dann ist der Speicher die Grenze.
*/
public function toFile(string $url, string $path): bool
{
$this->grund = '';
if (!str_starts_with($url, 'https://')) {
$this->grund = 'Nur HTTPS-Adressen werden geholt.';
return false;
}
if (function_exists('curl_init')) {
$datei = @fopen($path, 'w');
if (!is_resource($datei)) {
$this->grund = 'Die Zieldatei ließ sich nicht anlegen: '.$path;
return false;
}
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_FILE => $datei,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_USERAGENT => 'KantSites-Updater',
]);
$ok = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$fehler = curl_errno($curl);
$text = curl_error($curl);
curl_close($curl);
fclose($datei);
if (true === $ok && 200 === $status && (int) filesize($path) > 0) {
return true;
}
@unlink($path);
if (!in_array($fehler, self::ZERTIFIKATSFEHLER, true)) {
$this->grund = match (true) {
0 !== $fehler => 'Die Verbindung kam nicht zustande: '.$text,
0 === $status => 'Der Server hat nicht geantwortet.',
default => sprintf('Die Adresse antwortet mit %d statt 200.', $status),
};
return false;
}
}
$inhalt = $this->get($url);
if (null === $inhalt || '' === $inhalt) {
return false;
}
return false !== file_put_contents($path, $inhalt);
}
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
public function get(string $url): ?string
{
+70 -1
View File
@@ -24,15 +24,46 @@ final class Updater
public const MAINTENANCE_FILE = 'var/maintenance';
public const LOG_FILE = 'var/updates.log';
/** Eine Datei, die waehrend eines Updates gesperrt ist - Git-Weg und Archiv-Weg teilen sie sich. */
public const LOCK_FILE = 'var/update.lock';
/** Der Name der Sicherung, die vor dem Update entstand - der Rueckweg spielt sie ein. */
private ?string $backupName = null;
public function __construct(
private string $root,
private string $databaseUrl,
private CommandRunner $runner,
private Connection $db,
private ?string $php = null,
private ?Backup $backups = null,
) {
}
/**
* Sperrt Updates gegeneinander. Zwei Klicks auf "Aktualisieren", ein Cronjob neben der
* Verwaltung, der Archiv-Weg neben dem Git-Weg: jeder zweite Lauf bekommt null und faengt
* nichts an. Die Sperre faellt mit dem Handle, also spaetestens mit dem Prozess.
*
* @return resource|null
*/
public function lock(): mixed
{
$file = $this->root.'/'.self::LOCK_FILE;
@mkdir(dirname($file), 0o775, true);
$handle = @fopen($file, 'c');
if (!is_resource($handle)) {
return null;
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return null;
}
return $handle;
}
/** The version that is installed, from the file that ships inside the tag. */
public function version(): string
{
@@ -224,6 +255,21 @@ final class Updater
}
// ---- from here on the installation is being changed ----
$lock = $this->lock();
if (!$step($steps, 'Sperre', null !== $lock, null !== $lock ? 'Kein anderes Update läuft.' : 'Ein anderes Update läuft gerade (var/update.lock). Bitte warten.')) {
return ['ok' => false, 'steps' => $steps, 'target' => $target];
}
try {
return $this->apply($steps, $step, $target, $from, $lockBefore);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Der Teil des Updates, der die Installation veraendert - unter der Sperre. */
private function apply(array &$steps, callable $step, string $target, string $from, string $lockBefore): array
{
$backup = $this->backup();
if (!$step($steps, 'Sicherung', null !== $backup, $backup ?? 'Die Datenbank ließ sich nicht sichern. Ohne Sicherung wird nicht aktualisiert.')) {
return ['ok' => false, 'steps' => $steps, 'target' => $target];
@@ -350,7 +396,19 @@ final class Updater
}
$step($steps, 'Zurück auf den alten Stand', $back['ok'], $back['ok'] ? 'Auf '.substr($commit, 0, 8).' zurückgesetzt.' : $back['output']);
if (null !== $backup && null !== $this->databaseFile()) {
if (null !== $backup && null !== $this->backupName && null !== $this->backups) {
// Ueber die Sicherung, nicht ueber eine rohe Kopie: derselbe konsistente Weg fuer
// SQLite (VACUUM INTO) und MySQL (Dump und Client).
$restored = $this->backups->restore($this->backupName, true)['ok'];
$step($steps, 'Datenbank zurückschreiben', $restored, $restored
? 'Die Sicherung von vor der Migration ist wieder eingespielt.'
: 'Die Sicherung ließ sich nicht zurückschreiben. Sie liegt unter var/backups/'.$this->backupName.'.');
if (!$restored) {
$step($steps, 'Wartungszustand bleibt', false, 'Die Datenbank konnte nicht wiederhergestellt werden. Der Betrieb bleibt angehalten.');
return;
}
} elseif (null !== $backup && null !== $this->databaseFile()) {
$file = $this->databaseFile();
$copy = $this->backupDirectory.'/'.basename((string) $file);
$restored = is_readable($copy) && @copy($copy, (string) $file);
@@ -398,6 +456,17 @@ final class Updater
private function backup(): ?string
{
$this->backupDirectory = null;
$this->backupName = null;
if (null !== $this->backups) {
try {
$sicherung = $this->backups->create('vor dem Update');
$this->backupName = $sicherung['name'];
return sprintf('var/backups/%s (Datenbank %s, Medien %d Datei(en)).', $sicherung['name'], $this->bytes((int) ($sicherung['manifest']['datenbank']['bytes'] ?? 0)), (int) ($sicherung['manifest']['medien']['dateien'] ?? 0));
} catch (\Throwable) {
return null;
}
}
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
if (!@mkdir($directory, 0o700, true)) {
return null;
+609
View File
@@ -0,0 +1,609 @@
<?php
namespace App\Plugin;
use App\Domain\InvalidDocument;
use Doctrine\DBAL\Connection;
/**
* Die Erweiterungen dieser Installation: ein Ordner je Plugin unter plugins/, darin plugin.json.
*
* Ein Plugin bringt Bausteine mit. Jeder Baustein hat einen Typ mit Namensraum (`vergleich.bild`),
* ein Schema seiner Eigenschaften, eine Twig-Vorlage und optional ein Stylesheet und ein Skript,
* die nur auf Seiten geladen werden, auf denen der Baustein vorkommt. Der Kern prüft die
* Eigenschaften gegen das Schema, bevor die Vorlage etwas davon sieht - ein Wert, der nicht im
* Schema steht oder nicht hineinpasst, erreicht die Ausgabe nie.
*
* Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit
* Kindern (Container), Einstellungen für den Betreiber, eigene öffentliche Adressen und
* Seitenvorlagen für den Editor. Alles davon ist Manifest plus Twig - PHP-Code bringt ein
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen.
*
* Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein
* ausgeschaltetes oder fehlendes Plugin lässt seine Bausteine unangetastet im Dokument (ADR 011):
* sie werden gespeichert, im Editor als Platzhalter gezeigt und öffentlich als Kommentar
* ausgegeben.
*
* Serverseitiger Plugin-Code (Twig-Vorlagen) läuft mit den Rechten der Anwendung. Das ist kein
* Sandkasten und wird auch nicht so genannt: ein Plugin installiert nur, wer dem Code traut.
*/
final class PluginRegistry
{
public const NAME = '/^[a-z][a-z0-9]{1,30}$/D';
public const BLOCK = '/^[a-z][a-z0-9-]{1,40}$/D';
/** Die Arten von Eigenschaften, die ein Schema kennt; jede hat ein Feld im Editor. */
public const KINDS = ['text', 'textarea', 'number', 'boolean', 'select', 'url', 'media'];
/** Was ein Plugin als Datei ausliefern darf. */
public const ASSET_TYPES = ['css' => 'text/css; charset=UTF-8', 'js' => 'text/javascript; charset=UTF-8', 'svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'webp' => 'image/webp', 'woff2' => 'font/woff2'];
/** Was eine eigene Adresse eines Plugins antworten darf. */
public const ROUTE_TYPES = ['html' => 'text/html; charset=UTF-8', 'json' => 'application/json', 'text' => 'text/plain; charset=UTF-8', 'xml' => 'application/xml; charset=UTF-8'];
/** Eine Adresse wie eine Seite: Slugs, bis zu vier Ebenen - dieselbe Form, die der Seitenaufruf annimmt. */
public const ROUTE_PATH = '/^\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$/D';
public const MAX_PROPS = 30;
/** @var array<string, array>|null */
private ?array $manifests = null;
/** @var array<string, array{active: bool, version: string, activatedAt: ?string, settings: array}>|null */
private ?array $rows = null;
public function __construct(private Connection $db, private string $projectDir)
{
}
public function directory(): string
{
return $this->projectDir.'/plugins';
}
/**
* Alle Plugins im Ordner, gültige wie ungültige, mit ihrem Schalter.
*
* @return array<string, array{name: string, title: string, description: string, version: string, requires: string, blocks: array<string, array>, settings: array<string, array>, routes: array<string, array>, presets: list<array>, active: bool, error: ?string, activatedVersion: string, activatedAt: ?string, values: array}>
*/
public function all(): array
{
if (null !== $this->manifests) {
return $this->manifests;
}
$this->manifests = [];
$rows = $this->rows();
foreach (glob($this->directory().'/*/plugin.json') ?: [] as $file) {
$name = basename(dirname($file));
$row = $rows[$name] ?? ['active' => false, 'version' => '', 'activatedAt' => null, 'settings' => []];
$state = ['active' => $row['active'], 'error' => null, 'activatedVersion' => $row['version'], 'activatedAt' => $row['activatedAt']];
try {
$manifest = $this->manifest($file, $name);
// Die gespeicherten Einstellungen, ergänzt um die Vorgaben des Manifests.
$values = [];
foreach ($manifest['settings'] as $key => $schema) {
$values[$key] = array_key_exists($key, $row['settings']) ? $row['settings'][$key] : $schema['default'];
}
$this->manifests[$name] = [...$manifest, ...$state, 'values' => $values];
} catch (\RuntimeException $e) {
// Ein kaputtes Manifest hält die anderen nicht auf: das Plugin erscheint in der
// Verwaltung mit seinem Fehler und ist nie aktiv, egal was der Schalter sagt.
$this->manifests[$name] = ['name' => $name, 'title' => $name, 'description' => '', 'version' => '', 'requires' => '', 'blocks' => [], 'settings' => [], 'routes' => [], 'presets' => [], ...$state, 'active' => false, 'error' => $e->getMessage(), 'values' => []];
}
}
ksort($this->manifests);
return $this->manifests;
}
/** @return array<string, array{active: bool, version: string, activatedAt: ?string, settings: array}> */
private function rows(): array
{
if (null !== $this->rows) {
return $this->rows;
}
$this->rows = [];
try {
foreach ($this->db->fetchAllAssociative('SELECT name, active, version, activated_at, settings FROM plugin') as $row) {
$settings = json_decode((string) ($row['settings'] ?? '{}'), true);
$this->rows[$row['name']] = ['active' => (bool) $row['active'], 'version' => (string) $row['version'], 'activatedAt' => $row['activated_at'], 'settings' => is_array($settings) ? $settings : []];
}
} catch (\Throwable) {
// Vor der Migration gibt es die Tabelle nicht; dann ist kein Plugin aktiv, und die
// Website läuft trotzdem.
}
return $this->rows;
}
public function activate(string $name): void
{
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
if (null !== $plugin['error']) {
throw new \InvalidArgumentException('Das Plugin lässt sich nicht einschalten: '.$plugin['error']);
}
$this->write($name, ['active' => true, 'version' => $plugin['version'], 'activated_at' => gmdate('Y-m-d H:i:s')]);
}
public function deactivate(string $name): void
{
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
$this->write($name, ['active' => false, 'version' => $plugin['version'], 'activated_at' => null]);
}
/**
* Speichert die Einstellungen eines Plugins, geprüft gegen das Schema seines Manifests.
*
* @throws \InvalidArgumentException
*/
public function saveSettings(string $name, array $input): void
{
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
if (null !== $plugin['error']) {
throw new \InvalidArgumentException('Das Plugin lässt sich nicht einstellen: '.$plugin['error']);
}
try {
$values = $this->validateAgainst($plugin['settings'], $input, true);
} catch (InvalidDocument $e) {
throw new \InvalidArgumentException($e->getMessage());
}
$this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]);
}
/** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */
public function settingsOf(string $name): array
{
return $this->all()[$name]['values'] ?? [];
}
/** @param array<string, mixed> $fields */
private function write(string $name, array $fields): void
{
$existing = $this->rows()[$name] ?? null;
if (null === $existing) {
$this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
} else {
$this->db->update('plugin', $fields, ['name' => $name]);
}
$this->manifests = null;
$this->rows = null;
}
/** Die Bausteine aller aktiven Plugins, nach Typ. @return array<string, array> */
public function blocks(): array
{
$blocks = [];
foreach ($this->all() as $plugin) {
if (!$plugin['active'] || null !== $plugin['error']) {
continue;
}
foreach ($plugin['blocks'] as $block) {
$blocks[$block['type']] = $block;
}
}
return $blocks;
}
/** Die Definition eines Bausteins, wenn sein Plugin aktiv ist. */
public function block(string $type): ?array
{
return $this->blocks()[$type] ?? null;
}
/** Eine eigene Adresse eines aktiven Plugins, zum Beispiel /vergleich/daten - oder null. */
public function route(string $path): ?array
{
$path = '/'.ltrim($path, '/');
foreach ($this->all() as $plugin) {
if (!$plugin['active'] || null !== $plugin['error']) {
continue;
}
foreach ($plugin['routes'] as $route) {
if ($route['path'] === $path) {
return $route;
}
}
}
return null;
}
/**
* Prüft die Eigenschaften eines Bausteins gegen sein Schema und gibt sie bereinigt zurück.
*
* Fehlende Eigenschaften bekommen ihren Vorgabewert; unbekannte werden ignoriert, damit ein
* Plugin eine Eigenschaft streichen kann, ohne alte Seiten unlesbar zu machen. Was da ist,
* muss passen - sonst InvalidDocument, wie bei jedem Kernbaustein.
*
* @throws InvalidDocument
*/
public function validateProps(string $type, array $props): array
{
$block = $this->block($type) ?? throw new InvalidDocument(sprintf('Der Baustein „%s“ gehört zu keinem aktiven Plugin.', $type));
return $this->validateAgainst($block['props'], $props, false);
}
/**
* Werte gegen ein Schema. Aus einem Formular der Verwaltung kommen Zahlen und Schalter als
* Text - dann wird umgewandelt; aus einem Dokument müssen die Typen schon stimmen.
*
* @throws InvalidDocument
*/
private function validateAgainst(array $schemaMap, array $values, bool $fromForm): array
{
$clean = [];
foreach ($schemaMap as $key => $schema) {
$value = $values[$key] ?? ($fromForm && 'boolean' === $schema['kind'] ? false : $schema['default']);
$label = $schema['label'];
if ($fromForm) {
if ('number' === $schema['kind'] && is_string($value)) {
$value = is_numeric(str_replace(',', '.', trim($value))) ? str_replace(',', '.', trim($value)) + 0 : $value;
}
if ('boolean' === $schema['kind']) {
$value = (bool) $value;
}
}
switch ($schema['kind']) {
case 'text':
case 'textarea':
case 'url':
if (!is_string($value)) {
throw new InvalidDocument(sprintf('„%s“ muss Text sein.', $label));
}
$value = trim($value);
if (mb_strlen($value) > $schema['maxLength']) {
throw new InvalidDocument(sprintf('„%s“ ist auf %d Zeichen begrenzt.', $label, $schema['maxLength']));
}
if ('url' === $schema['kind'] && '' !== $value && 1 !== preg_match('~^(/(?!/)|#[a-zA-Z]|https://|mailto:|tel:)~', $value)) {
throw new InvalidDocument(sprintf('„%s“ muss ein lokaler Pfad, eine Sprungmarke oder eine HTTPS-Adresse sein.', $label));
}
break;
case 'number':
if (!is_int($value) && !is_float($value)) {
throw new InvalidDocument(sprintf('„%s“ muss eine Zahl sein.', $label));
}
if ($value < $schema['min'] || $value > $schema['max']) {
throw new InvalidDocument(sprintf('„%s“ liegt zwischen %s und %s.', $label, $schema['min'], $schema['max']));
}
break;
case 'boolean':
if (!is_bool($value)) {
throw new InvalidDocument(sprintf('„%s“ ist an oder aus.', $label));
}
break;
case 'select':
if (!is_string($value) || !array_key_exists($value, $schema['choices'])) {
throw new InvalidDocument(sprintf('„%s“ kennt diesen Wert nicht.', $label));
}
break;
case 'media':
if (!is_string($value) || ('' !== $value && 1 !== preg_match('/^[a-f0-9]{32}$/D', $value))) {
throw new InvalidDocument(sprintf('„%s“ muss ein Bild aus der Mediathek sein.', $label));
}
break;
}
$clean[$key] = $value;
}
return $clean;
}
/** Was der Editor braucht: Typ, Beschriftung, Symbol, Container-Regeln und das Schema mit Vorgaben. @return list<array> */
public function forEditor(): array
{
return array_values(array_map(static fn (array $block): array => [
'type' => $block['type'], 'label' => $block['label'], 'icon' => $block['icon'], 'plugin' => $block['plugin'],
'description' => $block['description'], 'props' => $block['props'],
'container' => $block['container'], 'children' => $block['children'],
], $this->blocks()));
}
/** Die Seitenvorlagen der aktiven Plugins für den Werkzeugkasten des Editors. @return list<array> */
public function presetsForEditor(): array
{
$presets = [];
foreach ($this->all() as $plugin) {
if (!$plugin['active'] || null !== $plugin['error']) {
continue;
}
foreach ($plugin['presets'] as $preset) {
$presets[] = ['id' => 'plugin:'.$plugin['name'].':'.$preset['name'], 'label' => $preset['label'], 'description' => $preset['description'], 'plugin' => $plugin['title'], 'blocks' => $preset['blocks']];
}
}
return $presets;
}
/**
* Stylesheets und Skripte der Plugin-Bausteine, die in diesen Dokumenten vorkommen - jede
* Datei einmal, in der Reihenfolge des ersten Auftretens.
*
* @param list<array> $documents
*
* @return array{styles: list<string>, scripts: list<string>}
*/
public function assetsFor(array $documents): array
{
$blocks = $this->blocks();
if ([] === $blocks) {
return ['styles' => [], 'scripts' => []];
}
$styles = [];
$scripts = [];
$walk = static function (array $list) use (&$walk, &$styles, &$scripts, $blocks): void {
foreach ($list as $block) {
$definition = $blocks[$block['type'] ?? ''] ?? null;
if (null !== $definition) {
if (null !== $definition['css']) {
$styles[$definition['css']] = true;
}
if (null !== $definition['script']) {
$scripts[$definition['script']] = true;
}
}
$walk($block['children'] ?? []);
}
};
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
return ['styles' => array_keys($styles), 'scripts' => array_keys($scripts)];
}
/** Der Pfad einer Datei, die ein aktives Plugin ausliefern darf - oder null. */
public function assetFile(string $plugin, string $relative): ?string
{
$definition = $this->all()[$plugin] ?? null;
if (null === $definition || !$definition['active'] || null !== $definition['error']) {
return null;
}
if (1 !== preg_match('~^assets/[a-zA-Z0-9_./-]{1,120}$~D', $relative) || str_contains($relative, '..')) {
return null;
}
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
if (!isset(self::ASSET_TYPES[$extension])) {
return null;
}
$file = $this->directory().'/'.$plugin.'/'.$relative;
return is_file($file) ? $file : null;
}
/** Liest und prüft ein Manifest. @throws \RuntimeException */
private function manifest(string $file, string $name): array
{
if (1 !== preg_match(self::NAME, $name)) {
throw new \RuntimeException('Der Ordnername darf nur Kleinbuchstaben und Ziffern enthalten und muss mit einem Buchstaben beginnen.');
}
try {
$data = json_decode((string) file_get_contents($file), true, 24, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException('plugin.json ist kein gültiges JSON: '.$e->getMessage());
}
if (!is_array($data) || ($data['name'] ?? null) !== $name) {
throw new \RuntimeException(sprintf('plugin.json muss "name": "%s" enthalten, so wie der Ordner heißt.', $name));
}
$title = trim((string) ($data['title'] ?? ''));
if ('' === $title || mb_strlen($title) > 80) {
throw new \RuntimeException('Das Manifest braucht einen Titel mit höchstens 80 Zeichen.');
}
$version = (string) ($data['version'] ?? '');
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) {
throw new \RuntimeException('Die Fassung des Plugins hat die Form 1.2.3.');
}
$requires = (string) ($data['requires'] ?? '');
if ('' !== $requires) {
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $requires)) {
throw new \RuntimeException('"requires" nennt die kleinste KantSites-Fassung in der Form 1.2.3.');
}
$installed = trim((string) @file_get_contents($this->projectDir.'/VERSION'));
if ('' !== $installed && version_compare($installed, $requires, '<')) {
throw new \RuntimeException(sprintf('Braucht KantSites %s, installiert ist %s.', $requires, $installed));
}
}
$blocks = [];
foreach ((array) ($data['blocks'] ?? []) as $key => $block) {
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($block)) {
throw new \RuntimeException(sprintf('Der Bausteinname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
}
$blocks[$name.'.'.$key] = $this->blockDefinition($name, $key, $block, $version);
}
// Erlaubte Kinder eines Containers muessen Bausteine sein, die es gibt: Kernbausteine
// oder Bausteine dieses Plugins.
foreach ($blocks as $type => $block) {
foreach ($block['children'] ?? [] as $child) {
if (!isset($blocks[$child]) && !str_contains($child, '.') && 1 !== preg_match('/^[a-z]+$/D', $child)) {
throw new \RuntimeException(sprintf('„%s“ erlaubt das Kind „%s“, das es nicht gibt.', $type, $child));
}
}
}
$settings = [];
if (!is_array($data['settings'] ?? [])) {
throw new \RuntimeException('"settings" ist kein Objekt.');
}
foreach ((array) ($data['settings'] ?? []) as $key => $schema) {
$settings[$key] = $this->propSchema('Einstellungen', (string) $key, is_array($schema) ? $schema : []);
}
$routes = $this->routes($name, $data['routes'] ?? []);
$presets = $this->presets($name, $data['presets'] ?? []);
if ([] === $blocks && [] === $routes && [] === $presets) {
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.');
}
return ['name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
}
private function blockDefinition(string $plugin, string $key, array $block, string $version): array
{
$label = trim((string) ($block['label'] ?? ''));
if ('' === $label || mb_strlen($label) > 60) {
throw new \RuntimeException(sprintf('Der Baustein „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key));
}
$template = (string) ($block['template'] ?? '');
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ des Bausteins „%s“ fehlt im Pluginordner.', $template, $key));
}
$assets = [];
foreach (['css', 'script'] as $art) {
$relative = $block[$art] ?? null;
if (null === $relative || '' === $relative) {
$assets[$art] = null;
continue;
}
$extension = 'css' === $art ? 'css' : 'js';
if (!is_string($relative) || 1 !== preg_match('~^assets/[a-zA-Z0-9_./-]+\.'.$extension.'$~D', $relative) || str_contains($relative, '..') || !is_file($this->directory().'/'.$plugin.'/'.$relative)) {
throw new \RuntimeException(sprintf('Die Datei „%s“ des Bausteins „%s“ fehlt oder liegt nicht unter assets/.', (string) $relative, $key));
}
$assets[$art] = '/plugin-assets/'.$plugin.'/'.$relative.'?v='.$version;
}
$props = [];
if (!is_array($block['props'] ?? [])) {
throw new \RuntimeException(sprintf('"props" des Bausteins „%s“ ist kein Objekt.', $key));
}
if (count($block['props'] ?? []) > self::MAX_PROPS) {
throw new \RuntimeException(sprintf('Der Baustein „%s“ hat mehr als %d Eigenschaften.', $key, self::MAX_PROPS));
}
foreach ($block['props'] ?? [] as $name => $schema) {
$props[$name] = $this->propSchema($key, (string) $name, is_array($schema) ? $schema : []);
}
// Ein Container nimmt Kinder auf - alle Bausteine oder nur die genannten.
$container = (bool) ($block['container'] ?? false);
$children = null;
if ($container && isset($block['children'])) {
if (!is_array($block['children']) || [] === $block['children']) {
throw new \RuntimeException(sprintf('"children" des Bausteins „%s“ ist eine Liste erlaubter Bausteintypen.', $key));
}
$children = [];
foreach ($block['children'] as $child) {
$child = (string) $child;
if (1 !== preg_match('/^[a-z]+(?:\.[a-z][a-z0-9-]{1,40})?$/D', $child)) {
throw new \RuntimeException(sprintf('„%s“ ist kein Bausteintyp.', $child));
}
$children[] = $child;
}
}
return [
'type' => $plugin.'.'.$key, 'plugin' => $plugin, 'label' => $label,
'description' => mb_substr(trim((string) ($block['description'] ?? '')), 0, 200),
'icon' => mb_substr(trim((string) ($block['icon'] ?? '')), 0, 2) ?: '⬡',
'template' => '@plugins/'.$plugin.'/'.$template,
'css' => $assets['css'], 'script' => $assets['script'],
'props' => $props,
'container' => $container, 'children' => $children,
];
}
/** Eigene Adressen: Pfad wie eine Seite, Vorlage im Pluginordner, Antwortart aus der Liste. */
private function routes(string $plugin, mixed $input): array
{
if (!is_array($input)) {
throw new \RuntimeException('"routes" ist kein Objekt.');
}
$routes = [];
foreach ($input as $key => $route) {
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($route)) {
throw new \RuntimeException(sprintf('Der Adressname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
}
$path = (string) ($route['path'] ?? '');
if (1 !== preg_match(self::ROUTE_PATH, $path)) {
throw new \RuntimeException(sprintf('Die Adresse „%s“ hat nicht die Form /name oder /name/unterseite.', $path));
}
// Was der Kern selbst beantwortet, darf ein Plugin nicht belegen.
if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $path)) {
throw new \RuntimeException(sprintf('Die Adresse „%s“ gehört dem Kern.', $path));
}
$template = (string) ($route['template'] ?? '');
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+(?:\.[a-z]+)?\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ der Adresse „%s“ fehlt im Pluginordner.', $template, $key));
}
$type = (string) ($route['type'] ?? 'html');
if (!isset(self::ROUTE_TYPES[$type])) {
throw new \RuntimeException(sprintf('Die Antwortart „%s“ der Adresse „%s“ ist keine von html, json, text, xml.', $type, $key));
}
$routes[$key] = ['name' => $key, 'plugin' => $plugin, 'path' => $path, 'template' => '@plugins/'.$plugin.'/'.$template, 'type' => $type, 'contentType' => self::ROUTE_TYPES[$type]];
}
return $routes;
}
/** Seitenvorlagen: Bausteine, die der Editor als Ganzes einfügt. Geprüft wird beim Einfügen wie jedes Dokument. */
private function presets(string $plugin, mixed $input): array
{
if (!is_array($input)) {
throw new \RuntimeException('"presets" ist kein Objekt.');
}
$presets = [];
foreach ($input as $key => $preset) {
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($preset)) {
throw new \RuntimeException(sprintf('Der Vorlagenname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
}
$label = trim((string) ($preset['label'] ?? ''));
if ('' === $label || mb_strlen($label) > 60) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key));
}
if (!is_array($preset['blocks'] ?? null) || !array_is_list($preset['blocks']) || [] === $preset['blocks']) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Liste von Bausteinen unter "blocks".', $key));
}
if (strlen(json_encode($preset['blocks'], JSON_THROW_ON_ERROR)) > 65536) {
throw new \RuntimeException(sprintf('Die Vorlage „%s“ ist größer als 64 KiB.', $key));
}
$presets[] = ['name' => $key, 'label' => $label, 'description' => mb_substr(trim((string) ($preset['description'] ?? '')), 0, 200), 'blocks' => $preset['blocks']];
}
return $presets;
}
private function propSchema(string $block, string $name, array $schema): array
{
if (1 !== preg_match('/^[a-z][a-zA-Z0-9]{0,31}$/D', $name)) {
throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ braucht einen Namen aus Buchstaben und Ziffern.', $name, $block));
}
$kind = (string) ($schema['kind'] ?? 'text');
if (!in_array($kind, self::KINDS, true)) {
throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ hat die unbekannte Art „%s“.', $name, $block, $kind));
}
$label = trim((string) ($schema['label'] ?? $name));
$clean = ['kind' => $kind, 'label' => mb_substr($label, 0, 80), 'help' => mb_substr(trim((string) ($schema['help'] ?? '')), 0, 200)];
switch ($kind) {
case 'text':
case 'url':
$clean['maxLength'] = max(1, min(2000, (int) ($schema['maxLength'] ?? 200)));
$clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']);
break;
case 'textarea':
$clean['maxLength'] = max(1, min(10000, (int) ($schema['maxLength'] ?? 2000)));
$clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']);
break;
case 'number':
$clean['min'] = is_numeric($schema['min'] ?? null) ? $schema['min'] + 0 : 0;
$clean['max'] = is_numeric($schema['max'] ?? null) ? $schema['max'] + 0 : 1000;
if ($clean['min'] >= $clean['max']) {
throw new \RuntimeException(sprintf('Bei „%s“ von „%s“ muss min unter max liegen.', $name, $block));
}
$default = is_numeric($schema['default'] ?? null) ? $schema['default'] + 0 : $clean['min'];
$clean['default'] = max($clean['min'], min($clean['max'], $default));
break;
case 'boolean':
$clean['default'] = (bool) ($schema['default'] ?? false);
break;
case 'select':
$choices = [];
foreach ((array) ($schema['choices'] ?? []) as $value => $text) {
$value = (string) $value;
if ('' !== $value && mb_strlen($value) <= 40) {
$choices[$value] = mb_substr(trim((string) $text), 0, 60) ?: $value;
}
}
if (count($choices) < 2) {
throw new \RuntimeException(sprintf('Die Auswahl „%s“ von „%s“ braucht mindestens zwei Werte.', $name, $block));
}
$clean['choices'] = $choices;
$default = (string) ($schema['default'] ?? array_key_first($choices));
$clean['default'] = array_key_exists($default, $choices) ? $default : array_key_first($choices);
break;
case 'media':
$clean['default'] = '';
break;
}
return $clean;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Twig;
use App\Content\MediaLibrary;
use App\Domain\InvalidDocument;
use App\Plugin\PluginRegistry;
use Twig\Extension\AbstractExtension;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction;
/**
* Was eine Plugin-Vorlage vom Kern bekommt: die Definition ihres Bausteins, die geprüften
* Eigenschaften und Bilder aus der Mediathek. Mehr nicht - keine Datenbank, keine Einstellungen.
*/
final class PluginExtension extends AbstractExtension
{
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader)
{
// Der Namensraum @plugins zeigt auf den Pluginordner - aber nur, wenn es ihn gibt. Ein
// fester Pfad in twig.yaml liesse eine Installation ohne plugins/ mit einem Ladefehler
// stehen, bevor sie eine einzige Seite ausgeliefert hat.
$directory = $plugins->directory();
if (is_dir($directory) && !in_array($directory, $loader->getPaths('plugins'), true)) {
$loader->addPath($directory, 'plugins');
}
}
public function getFunctions(): array
{
return [
new TwigFunction('kant_plugin_block', $this->plugins->block(...)),
new TwigFunction('kant_plugin_props', $this->props(...)),
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
];
}
/** Die geprüften Eigenschaften eines Bausteins - oder null, wenn sie nicht ins Schema passen. */
public function props(array $block): ?array
{
try {
return $this->plugins->validateProps((string) ($block['type'] ?? ''), is_array($block['props'] ?? null) ? $block['props'] : []);
} catch (InvalidDocument) {
return null;
}
}
/**
* Ein Bild der Mediathek für eine Vorlage: Kennung, Titel, Alternativtext und die Fassungen
* (src, width, height), die kleinste zuerst.
*/
public function mediaItem(string $id): ?array
{
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $id)) {
return null;
}
$item = $this->media->find($id);
if (null === $item || ($item['archived'] ?? false)) {
return null;
}
return ['id' => $item['id'], 'title' => (string) ($item['title'] ?? ''), 'alt' => (string) ($item['alt'] ?? ''), 'variants' => $item['variants']];
}
}