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]));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user