Files
KantSites/src/Plugin/PluginApi.php
T
kantarciandClaude Opus 5.5 9825bac3b3
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Let plugins run their own PHP code
A plugin can now bring a plugin.php that registers hooks, scheduled tasks, its own tables, public pages and admin screens. Failures stay contained to the plugin; a form on a plugin page can be submitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:56:14 +01:00

249 lines
10 KiB
PHP

<?php
namespace App\Plugin;
use App\Content\MediaLibrary;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Was die plugin.php eines Plugins vom Kern bekommt (ADR 127).
*
* Eine Instanz je Plugin. Das Plugin meldet hier an, was es kann - Hooks, Aufgaben im Takt, Seiten,
* Bildschirme der Verwaltung -, und bekommt seine Einstellungen, die Datenbank und die Mediathek.
* Der Kern übernimmt die Anmeldungen erst, wenn die plugin.php ohne Fehler durchgelaufen ist.
*
* Plugin-Code läuft mit den vollen Rechten der Anwendung, wie bei WordPress. Das ist kein
* Sandkasten und wird nicht so genannt: Ein Plugin installiert nur, wer dem Code traut.
*/
final class PluginApi
{
/** Größte Antwort, die fetch() annimmt. */
private const MAX_BYTES = 16_000_000;
/** @var array{install: list<callable>, deactivate: list<callable>, actions: array<string, list<array{int, callable}>>, filters: array<string, list<array{int, callable}>>, tasks: array<string, array>, pages: list<array>, data: array<string, callable>, adminPages: array<string, array>, adminActions: array<string, array>} */
public array $registered = ['install' => [], 'deactivate' => [], 'actions' => [], 'filters' => [], 'tasks' => [], 'pages' => [], 'data' => [], 'adminPages' => [], 'adminActions' => []];
public function __construct(
private string $name,
private string $directory,
private PluginRegistry $plugins,
private Connection $db,
private MediaLibrary $media,
) {
}
public function name(): string
{
return $this->name;
}
/** Der Ordner des Plugins, ohne Schrägstrich am Ende. */
public function directory(): string
{
return $this->directory;
}
/** Die geprüften Einstellungen aus der plugin.json, jede vorhanden. */
public function settings(): array
{
return $this->plugins->settingsOf($this->name);
}
public function db(): Connection
{
return $this->db;
}
/** Der Name einer eigenen Tabelle: plugin_<name>_<kurz>. Andere Tabellen gehören dem Kern. */
public function table(string $short): string
{
if (1 !== preg_match('/^[a-z][a-z0-9_]{0,30}$/D', $short)) {
throw new \InvalidArgumentException('Ein Tabellenname besteht aus Kleinbuchstaben, Ziffern und Unterstrichen.');
}
return 'plugin_'.$this->name.'_'.$short;
}
public function media(): MediaLibrary
{
return $this->media;
}
// ---------------------------------------------------------------- Lebenszyklus
/**
* Läuft beim Einschalten und wenn sich die Fassung des Plugins geändert hat - muss also
* wiederholbar sein (CREATE TABLE IF NOT EXISTS). Wirft es, bleibt das Plugin aus.
*/
public function onInstall(callable $callback): void
{
$this->registered['install'][] = $callback;
}
/** Läuft beim Ausschalten. Eigene Tabellen bleiben stehen, wie die Bausteine in den Seiten. */
public function onDeactivate(callable $callback): void
{
$this->registered['deactivate'][] = $callback;
}
// ---------------------------------------------------------------- Hooks
/** Reagiert auf ein Ereignis des Kerns oder eines anderen Plugins. Kleinere Priorität zuerst. */
public function addAction(string $hook, callable $callback, int $priority = 10): void
{
$this->registered['actions'][$hook][] = [$priority, $callback];
}
/** Verändert einen Wert: Der Callback bekommt ihn als erstes Argument und gibt ihn zurück. */
public function addFilter(string $hook, callable $callback, int $priority = 10): void
{
$this->registered['filters'][$hook][] = [$priority, $callback];
}
// ---------------------------------------------------------------- Anmeldungen
/**
* Eine Aufgabe im Takt. Sie steht unter „Aufgaben“ im Plan des Kerns; Fehler soll sie werfen,
* ihre Rückgabe ist der Bericht. Die Regeln stehen in App\Ops\Tasks\Task.
*
* @param callable(): string $callback
*/
public function addTask(string $key, string $label, string $explanation, int $interval, callable $callback): void
{
$this->key($key);
if ($interval < 60) {
throw new \InvalidArgumentException('Eine Aufgabe läuft höchstens jede Minute.');
}
$this->registered['tasks'][$key] = ['key' => $key, 'label' => $label, 'explanation' => $explanation, 'interval' => $interval, 'callback' => $callback];
}
/**
* Eine öffentliche Seite im Rahmen der Website, zum Beispiel `/objekte/{slug}`.
*
* Der Callback bekommt die Platzhalter und gibt null zurück (dann 404) oder ein Array mit
* `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image`.
* Eine veröffentlichte Seite mit derselben Adresse geht vor.
*
* @param callable(array<string, string>): ?array $callback
*/
public function addPage(string $pattern, callable $callback): void
{
if (1 !== preg_match('~^(?:/(?:[a-z0-9]+(?:-[a-z0-9]+)*|\{[a-z]+\})){1,4}$~D', $pattern)) {
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ hat nicht die Form /name/{platzhalter}.', $pattern));
}
if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $pattern)) {
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ gehört dem Kern.', $pattern));
}
$regex = '~^'.preg_replace('~\\\\\{([a-z]+)\\\\\}~', '(?P<$1>[a-z0-9]+(?:-[a-z0-9]+)*)', preg_quote($pattern, '~')).'$~D';
$this->registered['pages'][] = ['pattern' => $pattern, 'regex' => $regex, 'callback' => $callback];
}
/**
* Daten für die Twig-Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`.
*
* @param callable(array): mixed $callback
*/
public function addData(string $key, callable $callback): void
{
$this->key($key);
$this->registered['data'][$key] = $callback;
}
/**
* Ein Bildschirm der Verwaltung, nur für Administratoren. Die Vorlage liegt im Pluginordner.
*
* @param callable(): array $callback liefert die Werte für die Vorlage
*/
public function addAdminPage(string $key, string $title, string $template, callable $callback): void
{
$this->key($key);
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || !is_file($this->directory.'/'.$template)) {
throw new \InvalidArgumentException(sprintf('Die Vorlage „%s“ fehlt im Pluginordner.', $template));
}
$this->registered['adminPages'][$key] = ['key' => $key, 'title' => $title, 'template' => '@plugins/'.$this->name.'/'.$template, 'callback' => $callback];
}
/**
* Ein Knopf auf den Bildschirmen des Plugins. Entweder startet er eine angemeldete Aufgabe
* (`$handler` ist ihr Schlüssel, die Sperre des Aufgabenplans gilt) oder ruft einen Callback,
* dessen Rückgabe als Meldung erscheint.
*
* @param string|callable(): string $handler
*/
public function addAdminAction(string $key, string $label, string|callable $handler): void
{
$this->key($key);
$this->registered['adminActions'][$key] = ['key' => $key, 'label' => $label, 'handler' => $handler];
}
// ---------------------------------------------------------------- Werkzeuge
/**
* Ruft eine HTTPS-Adresse ab. Wirft bei einem Netzfehler; ein HTTP-Fehler ist eine Antwort.
*
* @param array{userAgent?: string, timeout?: int, maxBytes?: int} $options
*
* @return array{status: int, body: string, contentType: string}
*/
public function fetch(string $url, array $options = []): array
{
if (!str_starts_with($url, 'https://') || false === filter_var($url, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException('Abgerufen werden nur HTTPS-Adressen.');
}
if (!function_exists('curl_init')) {
throw new \RuntimeException('Auf diesem Hosting fehlt die PHP-Erweiterung cURL.');
}
$limit = min(self::MAX_BYTES, $options['maxBytes'] ?? self::MAX_BYTES);
$body = '';
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => max(1, min(60, $options['timeout'] ?? 20)),
CURLOPT_USERAGENT => $options['userAgent'] ?? 'KantSites-Plugin/'.$this->name,
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body, $limit): int {
$body .= $chunk;
return strlen($body) > $limit ? 0 : strlen($chunk);
},
]);
if (false === curl_exec($handle)) {
throw new \RuntimeException('Abruf gescheitert: '.curl_error($handle));
}
return ['status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE), 'body' => $body, 'contentType' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE)];
}
/**
* Legt Bilddaten in der Mediathek ab und gibt den Eintrag zurück.
*
* @throws \InvalidArgumentException wenn die Daten kein brauchbares Bild sind
*/
public function storeImage(string $bytes, string $title, string $alt = ''): array
{
$temp = tempnam(sys_get_temp_dir(), 'kant-plugin-');
if (false === $temp || false === file_put_contents($temp, $bytes)) {
throw new \RuntimeException('Die Bilddaten ließen sich nicht zwischenspeichern.');
}
try {
$item = $this->media->upload(new UploadedFile($temp, 'bild.jpg', null, null, true));
$this->media->update($item['id'], ['title' => mb_substr('' !== trim($title) ? $title : 'Bild', 0, 40), 'alt' => mb_substr($alt, 0, 150), 'archived' => false, 'version' => $item['version']]);
return $this->media->find($item['id']) ?? $item;
} finally {
@unlink($temp);
}
}
private function key(string $key): void
{
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,40}$/D', $key)) {
throw new \InvalidArgumentException(sprintf('Der Schlüssel „%s“ besteht aus Kleinbuchstaben, Ziffern und Bindestrichen.', $key));
}
}
}