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>
622 lines
31 KiB
PHP
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;
|
|
}
|
|
}
|