From 9825bac3b3fc8c8b4fa9d10a9f0d294e6120d4cb Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 9 Nov 2026 18:56:14 +0100 Subject: [PATCH] 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 --- config/services.yaml | 3 +- docs/ARCHITECTURE.md | 8 + docs/PLUGINS.md | 49 ++++- docs/STATUS.md | 10 + src/Controller/FormController.php | 15 +- src/Controller/PageController.php | 7 +- src/Controller/PluginAdminController.php | 52 ++++- src/Controller/SeoController.php | 4 +- src/Ops/Scheduler.php | 12 ++ src/Plugin/PluginApi.php | 248 ++++++++++++++++++++++ src/Plugin/PluginRegistry.php | 20 +- src/Plugin/PluginRuntime.php | 256 +++++++++++++++++++++++ src/Plugin/PluginTask.php | 44 ++++ src/Twig/PluginExtension.php | 4 +- templates/admin/plugin-page.html.twig | 19 ++ templates/admin/plugins.html.twig | 3 + tests/PluginServerTest.php | 162 ++++++++++++++ 17 files changed, 897 insertions(+), 19 deletions(-) create mode 100644 src/Plugin/PluginApi.php create mode 100644 src/Plugin/PluginRuntime.php create mode 100644 src/Plugin/PluginTask.php create mode 100644 templates/admin/plugin-page.html.twig create mode 100644 tests/PluginServerTest.php diff --git a/config/services.yaml b/config/services.yaml index 3357ba3..9abd21b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,7 +30,7 @@ services: tags: ['kant.task'] App\: resource: '../src/' - exclude: '../src/{Kernel.php,Domain/}' + exclude: '../src/{Kernel.php,Domain/,Plugin/PluginApi.php,Plugin/PluginTask.php}' Doctrine\DBAL\Connection: factory: ['App\Infrastructure\ConnectionFactory', 'create'] arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter'] @@ -156,6 +156,7 @@ services: arguments: $tasks: !tagged_iterator kant.task $projectDir: '%kernel.project_dir%' + $plugins: '@App\Plugin\PluginRuntime' App\Ops\Zentrale: arguments: $root: '%kernel.project_dir%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d28f771..115641c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,14 @@ Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 127 – Plugins mit eigenem PHP-Code, wie bei WordPress (09.11.2026) + +**Kontext:** Umut will einen ImmoScout-Abgleich für DEEG, ausdrücklich als Plugin und nicht im Kern: „Ansonsten musst du Kern so erweitern, dass Plugins das können“, „wie WordPress“. Das hebt die Grundentscheidung aus ADR 103 auf, dass ein Plugin nur Manifest plus Twig ist; dort war sie mit dem fehlenden Bedarf und der Stabilität begründet. Eigene Daten und Arbeit im Takt gehen ohne Servercode nicht. + +**Entscheidung:** `"main"` im Manifest nennt eine PHP-Datei, die eine Funktion zurückgibt. `PluginRuntime` lädt sie für aktive Plugins, `PluginApi` nimmt Hooks, Aufgaben (im vorhandenen `Scheduler`), Seiten (durch `renderPage`, also mit Kopf, Fuß und Design), Twig-Daten und Verwaltungsbildschirme entgegen. Eigene Tabellen legt das Plugin in `onInstall` an, Präfix `plugin__`; dadurch braucht der Kern keine Plugin-Migrationen. Reine Twig-Plugins bleiben unverändert. + +**Folgen:** Plugin-Code läuft mit vollen Rechten, das steht in der Verwaltung und wird nicht als Sandkasten bezeichnet. Anders als bei WordPress gibt es bewusst keinen ZIP-Upload in der Verwaltung: Wer PHP auf den Server legt, tut das per FTP, ein gestohlenes Adminkonto wird so nicht zur Codeausführung. Ausnahmen grenzt die Runtime auf das Plugin ein, einen harten PHP-Abbruch nicht. Der Kern löst vorerst nur `sitemap.entries` aus; weitere Hooks kommen mit dem ersten Bedarf. + ## ADR 126 – Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026) **Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 7f2f163..a4f278a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -5,9 +5,9 @@ Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört de Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins** (Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`. -**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine -Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update -des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen. +Ein Plugin kann reines Manifest plus Twig sein. Braucht es mehr – eigene Tabellen, Aufgaben im +Takt, Seiten aus eigenen Daten, Verwaltungsbildschirme –, bringt es wie bei WordPress eine +`plugin.php` mit (siehe „Eigener PHP-Code“, ADR 127). ## Der Ordner @@ -121,6 +121,49 @@ Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der W nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort eingestellt wurde, geht durch ein Plugin verloren (ADR 105). +## Eigener PHP-Code (`plugin.php`) + +`"main": "plugin.php"` im Manifest lädt die Datei, solange das Plugin eingeschaltet ist. Sie gibt +eine Funktion zurück und meldet sich dort beim Kern an: + +```php +table('objekt'); // plugin__objekt + $api->onInstall(fn () => $api->db()->executeStatement("CREATE TABLE IF NOT EXISTS $tabelle (id VARCHAR(20) PRIMARY KEY)")); + $api->addTask('abgleich', 'Abgleich', 'Was ausbleibt, wenn sie nicht läuft.', 3600, fn (): string => 'Bericht'); + $api->addPage('/objekte/{slug}', fn (array $p): ?array => ['title' => 'Titel', 'description' => 'Text', 'blocks' => []]); + $api->addData('liste', fn (array $argumente): array => []); + $api->addFilter('sitemap.entries', fn (array $e, string $origin): array => $e); + $api->addAdminPage('uebersicht', 'Übersicht', 'admin/uebersicht.html.twig', fn (): array => []); + $api->addAdminAction('jetzt', 'Jetzt ausführen', 'abgleich'); // Aufgabenschlüssel oder Callback +}; +``` + +| Methode | Zweck | +|---|---| +| `onInstall`, `onDeactivate` | Beim Einschalten und bei neuer Fassung; beim Ausschalten. Muss wiederholbar sein. Tabellen bleiben beim Ausschalten stehen. | +| `addAction`, `addFilter` | Hooks wie bei WordPress, kleinere Priorität zuerst. | +| `addTask` | Aufgabe im Plan des Kerns (Bildschirm „Aufgaben“); Fehler werfen, Rückgabe ist der Bericht. | +| `addPage` | Öffentliche Seite im Rahmen der Website. Gibt `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image` zurück oder `null` für 404. Eine veröffentlichte Seite geht vor. | +| `addData` | Werte für Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`. | +| `addAdminPage`, `addAdminAction` | Bildschirm und Knopf der Verwaltung, nur für Administratoren, Knöpfe mit CSRF-Schutz. | +| `settings`, `db`, `table`, `media`, `fetch`, `storeImage`, `directory` | Einstellungen, Datenbank, Mediathek, HTTPS-Abruf, Bild ablegen. | + +Der Kern ruft bisher diese Filter auf: `sitemap.entries` (`$entries`, `$origin`) und `form.context` +(`$extra`, `$form`, `$path`: zusätzliche Angaben `Name => Text` für eine eingehende Anfrage). +Weitere Hooks kommen in den +Kern, wenn ein Plugin sie braucht. + +Die SQL-Anweisungen müssen auf SQLite und MySQL laufen. Klassen und Funktionen gehören in einen +eigenen Namensraum. Plugin-Tabellen gehören nicht zum Inhaltspaket; bei SQLite sind sie in jeder +Sicherung, weil die ganze Datei gesichert wird. + +**Rechte:** Der Code läuft mit den vollen Rechten der Anwendung. Das ist kein Sandkasten. Eine +Ausnahme beim Laden, in einem Hook oder auf einer Seite nimmt nur das Plugin aus dem Spiel und +steht auf dem Bildschirm „Plugins“. Einen echten PHP-Abbruch fängt das nicht ab: Dann den Ordner +unter `plugins/` umbenennen. + ## Die Vorlagen ### Bausteine diff --git a/docs/STATUS.md b/docs/STATUS.md index fe4c99b..4f4aa0f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## Kern: Plugins mit PHP-Code; Plugin „ImmoScout-Abgleich“ für DEEG (09.11.2026) + +**Anlass:** Umut: ImmoScout-Objekte im Takt abgleichen, kostenlos per URL, „100 % ein Plugin“, „wie WordPress“. + +**Kern (ADR 127):** `plugin.php` mit `PluginApi`/`PluginRuntime`: Hooks, Aufgaben, Seiten, eigene Tabellen, Verwaltungsbildschirme, Fehler je Plugin eingegrenzt. `VERSION` steht im Arbeitsstand auf 1.39.0, nicht committet, kein Release. + +**Geprüft:** PHPUnit 692 grün (PHP 8.4, neu `PluginServerTest`), `npm test` 41, `test:editor`, Twig-Lint, php-cs-fixer. Auf einer Kopie der DEEG-Installation in der Verwaltung: einschalten, einstellen, echter Abgleich (5 Objekte, Bilder, Exposés), Liste, Objektseite 1440 und 390 px ohne waagrechtes Scrollen, Sitemap, Anfrage mit Objektangabe gespeichert, verschwundenes Objekt beim zweiten Lauf inaktiv mit 410. Nicht geprüft: Hosting, MySQL, Mailversand, Lighthouse der Objektseiten; die Abweichung Angebotszahl/gelesene Objekte wurde nicht einzeln simuliert. + +**Plugin:** liegt in `plugins/immoscout` (lokal von Git ausgeschlossen, Auslieferung als ZIP), nicht Teil des Kerns. ImmoScouts `robots.txt` verbietet automatisches Abrufen; das hat Umut entschieden, der Kunde sollte zustimmen. Eine Sperre wird nicht umgangen. + ## Kern: Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026) **Anlass:** SEO-Durchsicht bei Luximmo. Neu unter Einstellungen (ADR 126): Art des Unternehmens, Breiten-/Längengrad, Logo aus der Mediathek. Ohne Angaben bleibt die Ausgabe wie bisher, keine Migration. diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index 94ad8c9..0f367c6 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -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']) { diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 32d3d02..7ea6d13 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -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)); diff --git a/src/Controller/PluginAdminController.php b/src/Controller/PluginAdminController.php index c342ef3..6db7f84 100644 --- a/src/Controller/PluginAdminController.php +++ b/src/Controller/PluginAdminController.php @@ -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); diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php index 0039ea2..f11b637 100644 --- a/src/Controller/SeoController.php +++ b/src/Controller/SeoController.php @@ -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])); } diff --git a/src/Ops/Scheduler.php b/src/Ops/Scheduler.php index 81565b6..049e210 100644 --- a/src/Ops/Scheduler.php +++ b/src/Ops/Scheduler.php @@ -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; diff --git a/src/Plugin/PluginApi.php b/src/Plugin/PluginApi.php new file mode 100644 index 0000000..ff6e9b2 --- /dev/null +++ b/src/Plugin/PluginApi.php @@ -0,0 +1,248 @@ +, deactivate: list, actions: array>, filters: array>, tasks: array, pages: list, data: array, adminPages: array, adminActions: 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__. 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): ?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)); + } + } +} diff --git a/src/Plugin/PluginRegistry.php b/src/Plugin/PluginRegistry.php index 0202d1d..9885585 100644 --- a/src/Plugin/PluginRegistry.php +++ b/src/Plugin/PluginRegistry.php @@ -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 diff --git a/src/Plugin/PluginRuntime.php b/src/Plugin/PluginRuntime.php new file mode 100644 index 0000000..81409e9 --- /dev/null +++ b/src/Plugin/PluginRuntime.php @@ -0,0 +1,256 @@ +|null */ + private ?array $apis = null; + + /** @var array */ + 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 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 */ + 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, actions: array} */ + public function admin(string $plugin): array + { + $api = $this->apis()[$plugin] ?? null; + + return ['pages' => $api?->registered['adminPages'] ?? [], 'actions' => $api?->registered['adminActions'] ?? []]; + } + + /** @return array 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 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'] ?? [], + ], + ]; + } +} diff --git a/src/Plugin/PluginTask.php b/src/Plugin/PluginTask.php new file mode 100644 index 0000000..da5859e --- /dev/null +++ b/src/Plugin/PluginTask.php @@ -0,0 +1,44 @@ +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)(); + } +} diff --git a/src/Twig/PluginExtension.php b/src/Twig/PluginExtension.php index 1e431ec..a05634b 100644 --- a/src/Twig/PluginExtension.php +++ b/src/Twig/PluginExtension.php @@ -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(...)), ]; } diff --git a/templates/admin/plugin-page.html.twig b/templates/admin/plugin-page.html.twig new file mode 100644 index 0000000..19493e2 --- /dev/null +++ b/templates/admin/plugin-page.html.twig @@ -0,0 +1,19 @@ +{% extends 'admin/shell.html.twig' %} +{% set nav = 'plugins' %} +{% block title %}{{ page.title }}{% endblock %} +{% block heading %}{{ page.title }}{% endblock %} +{% block lead %}{{ plugin.title }} · Zurück zu den Plugins{% endblock %} +{% block content %} +{% if actions %} +
+ {% for action in actions %} +
+ + + +
+ {% endfor %} +
+{% endif %} +{% include page.template with data only %} +{% endblock %} diff --git a/templates/admin/plugins.html.twig b/templates/admin/plugins.html.twig index 70e931a..84de65e 100644 --- a/templates/admin/plugins.html.twig +++ b/templates/admin/plugins.html.twig @@ -56,6 +56,9 @@
{% if plugin.active %}{{ route.path }}{% else %}{{ route.path }}{% endif %} · {{ route.type }}
{% endfor %} + {% if plugin.main %}

Führt eigenen PHP-Code mit den vollen Rechten der Website aus. Nur einschalten, wenn du der Quelle vertraust.

{% endif %} + {% if runtimeErrors[plugin.name] is defined %}

Fehler im Plugin: {{ runtimeErrors[plugin.name] }}

{% endif %} + {% for page in admin[plugin.name].pages %}

{{ page.title }}

{% endfor %} {% if plugin.active and plugin.settings is not empty %}
diff --git a/tests/PluginServerTest.php b/tests/PluginServerTest.php new file mode 100644 index 0000000..71b2536 --- /dev/null +++ b/tests/PluginServerTest.php @@ -0,0 +1,162 @@ +root = sys_get_temp_dir().'/kant-plugin-server-'.bin2hex(random_bytes(6)); + mkdir($this->root.'/plugins', 0o775, true); + file_put_contents($this->root.'/VERSION', "1.39.0\n"); + $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/test.db'); + TestSchema::build($this->db); + $this->fresh(); + } + + protected function tearDown(): void + { + unset($this->runtime, $this->plugins); + $this->db->close(); + $remove = static function (string $path) use (&$remove): void { + foreach (glob($path.'/*') ?: [] as $entry) { + is_dir($entry) ? $remove($entry) : @unlink($entry); + } + @rmdir($path); + }; + $remove($this->root); + } + + public function testAPluginRegistersHooksTasksPagesAndItsOwnTable(): void + { + $this->plugin('demo', <<<'PHP' + $api->onInstall(fn () => $api->db()->executeStatement('CREATE TABLE IF NOT EXISTS '.$api->table('zeile').' (wert TEXT)')); + $api->addFilter('liste', fn (array $w) => [...$w, 'spaet'], 20); + $api->addFilter('liste', fn (array $w) => [...$w, 'frueh'], 5); + $api->addTask('takt', 'Takt', 'Erklaerung', 600, fn () => 'gelaufen'); + $api->addPage('/objekte/{slug}', fn (array $p) => 'gibt-es' === $p['slug'] ? ['title' => 'T', 'description' => 'D', 'blocks' => [], 'status' => 410, 'robots' => 'noindex'] : null); + $api->addData('zahl', fn (array $a) => $a['x'] * 2); + PHP); + $this->runtime->activate('demo'); + + $this->db->executeStatement('INSERT INTO plugin_demo_zeile (wert) VALUES (?)', ['ja']); + self::assertSame(['frueh', 'spaet'], $this->runtime->applyFilters('liste', [])); + self::assertSame(['plugin-demo-takt'], array_map(static fn ($t) => $t->name(), $this->runtime->tasks())); + self::assertSame('gelaufen', $this->runtime->tasks()[0]->run()); + self::assertSame(42, $this->runtime->data('demo', 'zahl', ['x' => 21])); + + $seite = $this->runtime->page('/objekte/gibt-es'); + self::assertSame(410, $seite['status']); + self::assertSame('noindex', $seite['robots']); + self::assertSame('objekte/gibt-es', $seite['path']); + self::assertNull($this->runtime->page('/objekte/gibt-es-nicht')); + self::assertNull($this->runtime->page('/objekte/a/b')); + + // Der Aufgabenplan kennt die Aufgabe, und nach dem Ausschalten nicht mehr. + $plan = new Scheduler($this->db, [], $this->root, $this->runtime); + $plan->pluginsChanged(); + self::assertSame('plugin-demo-takt', $this->db->fetchOne('SELECT name FROM scheduled_task')); + self::assertSame('ok', $plan->runNow('plugin-demo-takt')['status']); + $this->runtime->deactivate('demo'); + $plan->pluginsChanged(); + self::assertFalse($this->db->fetchOne('SELECT name FROM scheduled_task')); + self::assertSame([], $this->runtime->applyFilters('liste', [])); + + // Eigene Tabellen überleben das Ausschalten. + self::assertSame('ja', $this->db->fetchOne('SELECT wert FROM plugin_demo_zeile')); + } + + public function testAFaultyPluginStaysContained(): void + { + $this->plugin('kaputt', 'throw new \RuntimeException("Beim Laden gescheitert");'); + $this->plugin('wackel', <<<'PHP' + $api->addFilter('wert', function ($w) { throw new \RuntimeException('Filterfehler'); }); + $api->addFilter('wert', fn ($w) => $w.'+gut', 20); + $api->addPage('/wackel', function () { throw new \RuntimeException('Seitenfehler'); }); + PHP); + try { + $this->runtime->activate('kaputt'); + self::fail('Ein Plugin, das nicht lädt, darf nicht eingeschaltet bleiben.'); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString('Beim Laden gescheitert', $e->getMessage()); + } + self::assertFalse($this->plugins->all()['kaputt']['active']); + + $this->runtime->activate('wackel'); + self::assertSame('x+gut', $this->runtime->applyFilters('wert', 'x')); + self::assertNull($this->runtime->page('/wackel')); + self::assertSame('Seitenfehler', $this->runtime->errors()['wackel']); + } + + public function testAFailedInstallLeavesThePluginOff(): void + { + $this->plugin('demo', '$api->onInstall(function () { throw new \RuntimeException("Keine Tabelle"); });'); + $this->expectException(\InvalidArgumentException::class); + try { + $this->runtime->activate('demo'); + } finally { + self::assertFalse($this->plugins->all()['demo']['active']); + } + } + + public function testANewVersionRunsTheInstallAgainOnce(): void + { + $code = '$api->onInstall(fn () => $api->db()->executeStatement("INSERT INTO media (id, title, alt, variants, archived, version, created_at) VALUES (lower(hex(randomblob(16))), \'t\', \'\', \'[]\', 0, 1, \'2026-01-01 00:00:00\')"));'; + $this->plugin('demo', $code); + $this->runtime->activate('demo'); + self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media')); + + $this->plugin('demo', $code, '1.1.0'); + $this->fresh(); + $this->runtime->tasks(); + self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media')); + $this->fresh(); + $this->runtime->tasks(); + self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media')); + } + + public function testAPluginCannotTakeCoreAddressesOrOtherFolders(): void + { + $this->plugin('demo', '$api->addPage("/admin/{x}", fn () => null);'); + try { + $this->runtime->activate('demo'); + self::fail('Die Verwaltung gehört dem Kern.'); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString('gehört dem Kern', $e->getMessage()); + } + file_put_contents($this->root.'/plugins/demo/plugin.json', json_encode(['name' => 'demo', 'title' => 'D', 'version' => '1.0.0', 'main' => '../fremd.php'], JSON_THROW_ON_ERROR)); + $this->fresh(); + self::assertNotNull($this->plugins->all()['demo']['error']); + } + + private function fresh(): void + { + $this->plugins = new PluginRegistry($this->db, $this->root); + $this->runtime = new PluginRuntime($this->plugins, $this->db, new MediaLibrary($this->db, $this->root.'/media')); + } + + private function plugin(string $name, string $body, string $version = '1.0.0'): void + { + @mkdir($this->root.'/plugins/'.$name); + file_put_contents($this->root.'/plugins/'.$name.'/plugin.json', json_encode(['name' => $name, 'title' => $name, 'version' => $version, 'main' => 'plugin.php'], JSON_THROW_ON_ERROR)); + file_put_contents($this->root.'/plugins/'.$name.'/plugin.php', "root.'/plugins/'.$name.'/plugin.php', true); + } + } +}