, 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)); } } }