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>
257 lines
8.6 KiB
PHP
257 lines
8.6 KiB
PHP
<?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'] ?? [],
|
|
],
|
|
];
|
|
}
|
|
}
|