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>
This commit is contained in:
1 parent
b61f958ca1
commit
9825bac3b3
17 files changed
+897
-19
No files matched your search
@@ -9,6 +9,7 @@ use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Turnstile;
|
||||
use App\Ops\MailCheck;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -33,6 +34,7 @@ final class FormController extends AbstractController
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
private Languages $languages,
|
||||
private PluginRuntime $plugins,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -41,7 +43,8 @@ final class FormController extends AbstractController
|
||||
{
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
|
||||
$page = $this->pages->findPublished($path);
|
||||
// Ein Formular kann auch auf der Seite eines Plugins stehen (ADR 127).
|
||||
$page = $this->pages->findPublished($path) ?? $this->plugins->page('/'.$path);
|
||||
if (null === $form || null === $page) {
|
||||
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
|
||||
}
|
||||
@@ -77,8 +80,11 @@ final class FormController extends AbstractController
|
||||
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
// Angaben eines Plugins zur Seite, von der die Anfrage kommt: Name => Text.
|
||||
$extra = array_filter((array) $this->plugins->applyFilters('form.context', [], $form, $path), 'is_string');
|
||||
$checked['values'] += $extra;
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
if ($this->deliver($form, $checked['values'])) {
|
||||
if ($this->deliver($form, $checked['values'], $extra)) {
|
||||
$this->forms->markDelivered($submission);
|
||||
}
|
||||
}
|
||||
@@ -101,9 +107,12 @@ final class FormController extends AbstractController
|
||||
return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS;
|
||||
}
|
||||
|
||||
private function deliver(array $form, array $values): bool
|
||||
private function deliver(array $form, array $values, array $extra = []): bool
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($extra as $name => $text) {
|
||||
$lines[] = $name.': '.$text;
|
||||
}
|
||||
foreach ($form['fields'] as $field) {
|
||||
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
|
||||
if ('step' === $field['type']) {
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Content\Turnstile;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -29,7 +30,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, private PluginRegistry $plugins, private Languages $languages)
|
||||
public function __construct(private PageRepository $pages, private PluginRuntime $runtime, 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, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -78,6 +79,10 @@ final class PageController extends AbstractController
|
||||
if (null !== $route) {
|
||||
return $this->pluginRoute($request, $route);
|
||||
}
|
||||
$pluginPage = $this->runtime->page('/'.$path);
|
||||
if (null !== $pluginPage) {
|
||||
return $this->renderPage($request, $pluginPage, $path, $pluginPage['status']);
|
||||
}
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class PluginAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins)
|
||||
public function __construct(private PluginRegistry $plugins, private PluginRuntime $runtime, private Scheduler $scheduler)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -24,7 +26,12 @@ final class PluginAdminController extends AbstractController
|
||||
// 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()]);
|
||||
$admin = [];
|
||||
foreach ($this->plugins->all() as $name => $plugin) {
|
||||
$admin[$name] = $this->runtime->admin($name);
|
||||
}
|
||||
|
||||
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory(), 'admin' => $admin, 'runtimeErrors' => $this->runtime->errors()]);
|
||||
}
|
||||
|
||||
#[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'])]
|
||||
@@ -36,11 +43,13 @@ final class PluginAdminController extends AbstractController
|
||||
}
|
||||
try {
|
||||
if ('an' === $state) {
|
||||
$this->plugins->activate($name);
|
||||
$this->runtime->activate($name);
|
||||
$this->scheduler->pluginsChanged();
|
||||
|
||||
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);
|
||||
$this->runtime->deactivate($name);
|
||||
$this->scheduler->pluginsChanged();
|
||||
|
||||
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) {
|
||||
@@ -64,6 +73,41 @@ final class PluginAdminController extends AbstractController
|
||||
return $this->back(sprintf('Die Einstellungen von „%s“ wurden gespeichert.', $this->plugins->all()[$name]['title']), 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/seite/{key}', name: 'admin_plugin_page', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['GET'])]
|
||||
public function page(string $name, string $key): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$admin = $this->runtime->admin($name);
|
||||
$page = $admin['pages'][$key] ?? throw $this->createNotFoundException();
|
||||
|
||||
return $this->render('admin/plugin-page.html.twig', ['plugin' => $this->plugins->all()[$name], 'page' => $page, 'actions' => $admin['actions'], 'data' => ($page['callback'])()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/aktion/{key}', name: 'admin_plugin_action', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['POST'])]
|
||||
public function action(Request $request, string $name, string $key): 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.');
|
||||
}
|
||||
$admin = $this->runtime->admin($name);
|
||||
$action = $admin['actions'][$key] ?? throw $this->createNotFoundException();
|
||||
try {
|
||||
if (is_string($action['handler'])) {
|
||||
$result = $this->scheduler->runNow(PluginRuntime::taskName($name, $action['handler']));
|
||||
[$type, $message] = null === $result ? ['error', 'Die Aufgabe läuft gerade schon.'] : ['ok' === $result['status'] ? 'success' : 'error', $result['message']];
|
||||
} else {
|
||||
[$type, $message] = ['success', (string) ($action['handler'])()];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
[$type, $message] = ['error', $e->getMessage()];
|
||||
}
|
||||
$this->addFlash($type, $message);
|
||||
$back = $request->request->getString('zurueck');
|
||||
|
||||
return isset($admin['pages'][$back]) ? $this->redirectToRoute('admin_plugin_page', ['name' => $name, 'key' => $back]) : $this->redirectToRoute('admin_plugins');
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SeoController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
public function __construct(private \App\Plugin\PluginRuntime $plugins,
|
||||
private Connection $db,
|
||||
private SiteSettings $settings,
|
||||
private PostRepository $posts,
|
||||
@@ -74,6 +74,8 @@ final class SeoController extends AbstractController
|
||||
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
|
||||
}
|
||||
|
||||
$entries = $this->plugins->applyFilters('sitemap.entries', $entries, $origin);
|
||||
|
||||
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ final class Scheduler
|
||||
private Connection $db,
|
||||
private iterable $tasks,
|
||||
private string $projectDir,
|
||||
private ?\App\Plugin\PluginRuntime $plugins = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -268,6 +269,14 @@ final class Scheduler
|
||||
* @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und
|
||||
* zwischen hochgeladenem Code und ausgefuehrter Migration
|
||||
*/
|
||||
/** Nach dem Ein- oder Ausschalten eines Plugins: Der Plan kennt dessen Aufgaben sofort. */
|
||||
public function pluginsChanged(): void
|
||||
{
|
||||
$this->registry = null;
|
||||
$this->sync();
|
||||
$this->refreshState();
|
||||
}
|
||||
|
||||
public function sync(?\DateTimeImmutable $now = null): bool
|
||||
{
|
||||
$now ??= self::now();
|
||||
@@ -396,6 +405,9 @@ final class Scheduler
|
||||
foreach ($this->tasks as $task) {
|
||||
$registry[$task->name()] = $task;
|
||||
}
|
||||
foreach ($this->plugins?->tasks() ?? [] as $task) {
|
||||
$registry[$task->name()] = $task;
|
||||
}
|
||||
ksort($registry);
|
||||
|
||||
return $this->registry = $registry;
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ use Doctrine\DBAL\Connection;
|
||||
*
|
||||
* 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.
|
||||
* 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):
|
||||
@@ -144,6 +144,12 @@ final class PluginRegistry
|
||||
$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
|
||||
{
|
||||
@@ -423,11 +429,15 @@ final class PluginRegistry
|
||||
}
|
||||
$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.');
|
||||
$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 ['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];
|
||||
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
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Plugin;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Ops\Tasks\Task;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Lädt die plugin.php der aktiven Plugins und führt aus, was sie angemeldet haben (ADR 127).
|
||||
*
|
||||
* Ein Plugin, das beim Laden oder in einem Hook eine Ausnahme wirft, darf die Website nicht
|
||||
* mitreißen: Es fällt für diese Anfrage aus, der Grund steht auf dem Bildschirm „Plugins“. Einen
|
||||
* echten PHP-Abbruch fängt auch das nicht ab - dann hilft, den Ordner umzubenennen.
|
||||
*/
|
||||
final class PluginRuntime
|
||||
{
|
||||
/** @var array<string, PluginApi>|null */
|
||||
private ?array $apis = null;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private array $errors = [];
|
||||
|
||||
public function __construct(private PluginRegistry $plugins, private Connection $db, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
/** Nach dem Ein- oder Ausschalten: beim nächsten Zugriff neu laden. */
|
||||
public function reset(): void
|
||||
{
|
||||
$this->apis = null;
|
||||
$this->errors = [];
|
||||
}
|
||||
|
||||
/** @return array<string, string> Plugin => Grund, aus dem es gerade nicht läuft */
|
||||
public function errors(): array
|
||||
{
|
||||
$this->apis();
|
||||
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet ein und richtet ein. Scheitert das Einrichten, bleibt das Plugin aus.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function activate(string $name): void
|
||||
{
|
||||
$this->plugins->activate($name);
|
||||
$this->reset();
|
||||
if (isset($this->errors()[$name])) {
|
||||
$reason = $this->errors()[$name];
|
||||
$this->plugins->deactivate($name);
|
||||
$this->reset();
|
||||
throw new \InvalidArgumentException('Das Plugin lässt sich nicht laden: '.$reason);
|
||||
}
|
||||
try {
|
||||
$this->install($this->apis()[$name] ?? null);
|
||||
} catch (\Throwable $e) {
|
||||
$this->plugins->deactivate($name);
|
||||
$this->reset();
|
||||
throw new \InvalidArgumentException('Das Plugin ließ sich nicht einrichten: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws \InvalidArgumentException */
|
||||
public function deactivate(string $name): void
|
||||
{
|
||||
foreach ($this->apis()[$name]->registered['deactivate'] ?? [] as $callback) {
|
||||
try {
|
||||
$callback();
|
||||
} catch (\Throwable $e) {
|
||||
// Ausschalten muss immer gehen, gerade bei einem Plugin, das Fehler macht.
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$this->plugins->deactivate($name);
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt ein Ereignis aus. Der Fehler eines Plugins hält die übrigen nicht auf.
|
||||
*/
|
||||
public function doAction(string $hook, mixed ...$arguments): void
|
||||
{
|
||||
foreach ($this->hooks('actions', $hook) as [$name, $callback]) {
|
||||
try {
|
||||
$callback(...$arguments);
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reicht einen Wert durch die Filter. Wirft einer, gilt der Wert von davor.
|
||||
*/
|
||||
public function applyFilters(string $hook, mixed $value, mixed ...$arguments): mixed
|
||||
{
|
||||
foreach ($this->hooks('filters', $hook) as [$name, $callback]) {
|
||||
try {
|
||||
$value = $callback($value, ...$arguments);
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @return list<Task> */
|
||||
public function tasks(): array
|
||||
{
|
||||
$tasks = [];
|
||||
foreach ($this->apis() as $name => $api) {
|
||||
foreach ($api->registered['tasks'] as $task) {
|
||||
$tasks[] = new PluginTask(self::taskName($name, $task['key']), $task['label'], $task['explanation'], $task['interval'], $task['callback']);
|
||||
}
|
||||
}
|
||||
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
public static function taskName(string $plugin, string $key): string
|
||||
{
|
||||
return 'plugin-'.$plugin.'-'.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Seite eines Plugins zu dieser Adresse, in der Form einer gespeicherten Seite - oder null.
|
||||
*/
|
||||
public function page(string $path): ?array
|
||||
{
|
||||
foreach ($this->apis() as $name => $api) {
|
||||
foreach ($api->registered['pages'] as $page) {
|
||||
if (1 !== preg_match($page['regex'], $path, $match)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$result = $page['callback'](array_filter($match, 'is_string', ARRAY_FILTER_USE_KEY));
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($result) ? $this->pageRecord($name, $path, $result) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function data(string $plugin, string $key, array $arguments = []): mixed
|
||||
{
|
||||
$callback = $this->apis()[$plugin]->registered['data'][$key] ?? null;
|
||||
if (null === $callback) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return $callback($arguments);
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$plugin] = $e->getMessage();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{pages: array<string, array>, actions: array<string, array>} */
|
||||
public function admin(string $plugin): array
|
||||
{
|
||||
$api = $this->apis()[$plugin] ?? null;
|
||||
|
||||
return ['pages' => $api?->registered['adminPages'] ?? [], 'actions' => $api?->registered['adminActions'] ?? []];
|
||||
}
|
||||
|
||||
/** @return array<string, PluginApi> nur Plugins, deren plugin.php fehlerfrei geladen wurde */
|
||||
private function apis(): array
|
||||
{
|
||||
if (null !== $this->apis) {
|
||||
return $this->apis;
|
||||
}
|
||||
$this->apis = [];
|
||||
foreach ($this->plugins->all() as $name => $plugin) {
|
||||
if (!$plugin['active'] || null !== $plugin['error'] || null === ($plugin['main'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$directory = $this->plugins->directory().'/'.$name;
|
||||
$api = new PluginApi($name, $directory, $this->plugins, $this->db, $this->media);
|
||||
try {
|
||||
$boot = self::load($directory.'/'.$plugin['main']);
|
||||
if (!is_callable($boot)) {
|
||||
throw new \RuntimeException($plugin['main'].' gibt keine Funktion zurück.');
|
||||
}
|
||||
$boot($api);
|
||||
// Eine neue Fassung im Ordner: einrichten, bevor etwas sie benutzt.
|
||||
if ($plugin['activatedVersion'] !== $plugin['version']) {
|
||||
$this->install($api);
|
||||
$this->plugins->markInstalled($name);
|
||||
}
|
||||
$this->apis[$name] = $api;
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->apis;
|
||||
}
|
||||
|
||||
/** In einer eigenen statischen Funktion, damit die Datei nichts von hier sieht. */
|
||||
private static function load(string $file): mixed
|
||||
{
|
||||
return require $file;
|
||||
}
|
||||
|
||||
private function install(?PluginApi $api): void
|
||||
{
|
||||
foreach ($api?->registered['install'] ?? [] as $callback) {
|
||||
$callback();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array{string, callable}> nach Priorität, bei Gleichstand in Reihenfolge der Plugins */
|
||||
private function hooks(string $kind, string $hook): array
|
||||
{
|
||||
$found = [];
|
||||
foreach ($this->apis() as $name => $api) {
|
||||
foreach ($api->registered[$kind][$hook] ?? [] as [$priority, $callback]) {
|
||||
$found[] = [$priority, $name, $callback];
|
||||
}
|
||||
}
|
||||
usort($found, static fn (array $a, array $b): int => $a[0] <=> $b[0]);
|
||||
|
||||
return array_map(static fn (array $entry): array => [$entry[1], $entry[2]], $found);
|
||||
}
|
||||
|
||||
private function pageRecord(string $plugin, string $path, array $result): array
|
||||
{
|
||||
return [
|
||||
'id' => 'plugin-'.$plugin, 'path' => ltrim($path, '/'), 'type' => 'page', 'locale' => 'de',
|
||||
'seo_title' => (string) ($result['seoTitle'] ?? ''),
|
||||
'robots' => 'noindex' === ($result['robots'] ?? 'index') ? 'noindex' : 'index',
|
||||
'status' => (int) ($result['status'] ?? 200),
|
||||
'og_media_id' => $result['image'] ?? null,
|
||||
'header_area' => null, 'footer_area' => null,
|
||||
'document' => [
|
||||
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
|
||||
'title' => (string) ($result['title'] ?? ''),
|
||||
'description' => (string) ($result['description'] ?? ''),
|
||||
'blocks' => $result['blocks'] ?? [],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Plugin;
|
||||
|
||||
use App\Ops\Tasks\Task;
|
||||
|
||||
/** Eine Aufgabe, die ein Plugin angemeldet hat, im Vertrag des Aufgabenplans. */
|
||||
final class PluginTask implements Task
|
||||
{
|
||||
/** @param callable(): string $callback */
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private string $label,
|
||||
private string $explanation,
|
||||
private int $interval,
|
||||
private $callback,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return $this->explanation;
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return $this->interval;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
return (string) ($this->callback)();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Twig;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\TwigFunction;
|
||||
@@ -15,7 +16,7 @@ use Twig\TwigFunction;
|
||||
*/
|
||||
final class PluginExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader)
|
||||
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader, private PluginRuntime $runtime)
|
||||
{
|
||||
// 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
|
||||
@@ -33,6 +34,7 @@ final class PluginExtension extends AbstractExtension
|
||||
new TwigFunction('kant_plugin_props', $this->props(...)),
|
||||
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
|
||||
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
||||
new TwigFunction('kant_plugin_data', $this->runtime->data(...)),
|
||||
new TwigFunction('kant_media', $this->adminMedia(...)),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user