Files
KantSites/src/Plugin/PluginRegistry.php
T
kantarciandClaude Fable 5.2 d464c82ac8 Fix lost form submissions on archives, the unlocked update finish and six more
Update Symfony to 7.4.23 (12 advisories in 8 packages).

- A form can be sent from /beitraege, /kategorie/... and /schlagwort/...;
  it answered 404 before anything was stored or mailed.
- Booleans reach the database as integers, so MySQL in strict mode
  accepts form submissions, task switches and plugin states.
- A stepped form with a required multiple choice shows its message
  instead of stopping on a TypeError.
- The second update step runs under the update lock; a concurrent
  finish no longer rolls a successful update back.
- Accordion entries and gallery images can be selected from the
  editor panel again.
- Category and tag names are escaped in the archive heading.
- Restoring a published page from the bin needs the publisher role.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:15:49 +01:00

622 lines
31 KiB
PHP

<?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. Seit ADR 127 darf es zusätzlich eine plugin.php mitbringen
* ("main" im Manifest); was die anmeldet, führt PluginRuntime aus.
*
* 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 Fassung, für die die plugin.php zuletzt eingerichtet wurde. */
public function markInstalled(string $name): void
{
$this->write($name, ['version' => $this->all()[$name]['version']]);
}
/** 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;
// Wahrheitswerte als Zahl: false ginge sonst als leere Zeichenkette an die Datenbank.
$fields = array_map(static fn (mixed $value): mixed => \is_bool($value) ? (int) $value : $value, $fields);
if (null === $existing) {
$this->db->insert('plugin', ['name' => $name, 'active' => 0, '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'] ?? []);
$main = $data['main'] ?? null;
if (null !== $main && (!is_string($main) || 1 !== preg_match('~^[a-zA-Z0-9_-]+\.php$~D', $main) || !is_file($this->directory().'/'.$name.'/'.$main))) {
throw new \RuntimeException(sprintf('Die Datei „%s“ aus "main" fehlt im Pluginordner.', is_string($main) ? $main : ''));
}
if ([] === $blocks && [] === $routes && [] === $presets && null === $main) {
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse, eine Vorlage oder eine plugin.php.');
}
return ['main' => $main, '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;
}
}