Adds plugin route registry, one plugin can register multiple routes

main
Yasen Pramatarov 2026-01-13 13:49:56 +02:00
parent e2e3d74de1
commit 521d8eafab
4 changed files with 256 additions and 1 deletions

View File

@ -0,0 +1,129 @@
<?php
namespace App\Core;
/**
* Registry for plugin route prefixes/dispatchers. Allows plugins to handle
* sub-actions without registering dozens of standalone controllers.
*/
final class PluginRouteRegistry
{
/** @var array<string, array{dispatcher: mixed, access?: string, defaults?: array, plugin?: string}> */
private static array $prefixes = [];
/**
* Register a route prefix for a plugin.
*
* @param string $prefix Query parameter value for "page" (e.g. "calls").
* @param array $definition dispatcher callable/class plus optional metadata.
*/
public static function registerPrefix(string $prefix, array $definition): void
{
$key = strtolower(trim($prefix));
if ($key === '') {
return;
}
$dispatcher = $definition['dispatcher'] ?? null;
if (!is_callable($dispatcher) && !(is_string($dispatcher) && $dispatcher !== '')) {
return;
}
$meta = [
'dispatcher' => $dispatcher,
'access' => strtolower((string)($definition['access'] ?? 'private')),
'defaults' => is_array($definition['defaults'] ?? null) ? $definition['defaults'] : [],
'plugin' => $definition['plugin'] ?? null,
];
self::$prefixes[$key] = $meta;
if (!isset($GLOBALS['plugin_route_prefixes']) || !is_array($GLOBALS['plugin_route_prefixes'])) {
$GLOBALS['plugin_route_prefixes'] = [];
}
$GLOBALS['plugin_route_prefixes'][$key] = $meta;
}
/**
* Return a registered route definition, if any.
*/
public static function match(string $prefix): ?array
{
$key = strtolower(trim($prefix));
return self::$prefixes[$key] ?? null;
}
/**
* Append registered prefixes to the allowed pages list.
*/
public static function injectAllowedPages(array $allowed): array
{
return array_values(array_unique(array_merge($allowed, array_keys(self::$prefixes))));
}
/**
* Append any public prefixes to the public pages list.
*/
public static function injectPublicPages(array $public): array
{
foreach (self::$prefixes as $prefix => $meta) {
if (($meta['access'] ?? 'private') === 'public') {
$public[] = $prefix;
}
}
return array_values(array_unique($public));
}
/**
* Dispatch the provided prefix using its registered handler.
*
* The dispatcher can be:
* - A callable accepting ($action, array $context)
* - A class name with a handle($action, array $context): bool method
*
* Returning `false` allows core routing to continue. Any other return value
* (including null) is treated as handled.
*/
public static function dispatch(string $prefix, array $context = []): bool
{
$route = self::match($prefix);
if (!$route) {
return false;
}
$action = $context['action']
?? ($context['request']['action'] ?? null)
?? ($route['defaults']['action'] ?? 'index');
$context['action'] = $action;
$dispatcher = $route['dispatcher'];
$handled = null;
if (is_string($dispatcher) && class_exists($dispatcher)) {
$instance = new $dispatcher();
if (method_exists($instance, 'handle')) {
$handled = $instance->handle($action, $context);
}
} elseif (is_callable($dispatcher)) {
$handled = call_user_func($dispatcher, $action, $context);
}
return $handled !== false;
}
/**
* Expose current registry (useful for debugging or admin UIs).
*/
public static function all(): array
{
return self::$prefixes;
}
/**
* Reset registry (primarily for unit tests).
*/
public static function reset(): void
{
self::$prefixes = [];
$GLOBALS['plugin_route_prefixes'] = [];
}
}

View File

@ -22,8 +22,10 @@ define('APP_PATH', __DIR__ . '/../app/');
// Prepare config loader
require_once APP_PATH . 'core/ConfigLoader.php';
require_once APP_PATH . 'core/App.php';
require_once APP_PATH . 'core/PluginRouteRegistry.php';
use App\Core\ConfigLoader;
use App\App;
use App\Core\PluginRouteRegistry;
// Load configuration
$config = ConfigLoader::loadConfig([
@ -78,6 +80,9 @@ function filter_public_pages(array $pages): array {
function filter_allowed_urls(array $urls): array {
return HookDispatcher::applyFilters('filter_allowed_urls', $urls);
}
function register_plugin_route_prefix(string $prefix, array $definition = []): void {
PluginRouteRegistry::registerPrefix($prefix, $definition);
}
// Load enabled plugins
$plugins_dir = dirname(__DIR__) . '/plugins/';
@ -117,6 +122,7 @@ $public_pages = ['login', 'help', 'about', 'theme-asset', 'plugin-asset'];
// Let plugins filter/extend public_pages
$public_pages = filter_public_pages($public_pages);
$public_pages = PluginRouteRegistry::injectPublicPages($public_pages);
// Middleware pipeline for security, sanitization & CSRF
require_once APP_PATH . 'core/MiddlewarePipeline.php';
@ -154,6 +160,7 @@ $allowed_urls = [
// Let plugins filter/extend allowed_urls
$allowed_urls = filter_allowed_urls($allowed_urls);
$allowed_urls = PluginRouteRegistry::injectAllowedPages($allowed_urls);
// Dispatch routing and auth
require_once APP_PATH . 'core/Router.php';
@ -393,6 +400,32 @@ if ($page == 'logout') {
}
}
}
if (!empty($mapped_plugin_controllers)) {
$allowed_urls = array_unique(array_merge($allowed_urls, array_keys($mapped_plugin_controllers)));
}
// Check if the requested page is handled by a plugin route dispatcher.
$routeContext = [
'page' => $page,
'request' => $_REQUEST,
'get' => $_GET,
'post' => $_POST,
'user_id' => $userId,
'user_object' => $userObject ?? null,
'valid_session' => $validSession,
'app_root' => $app_root,
'db' => $db,
'config' => $config,
'logger' => $logObject,
'time_now' => $timeNow ?? null,
];
if (PluginRouteRegistry::match($page)) {
$handled = PluginRouteRegistry::dispatch($page, $routeContext);
if ($handled !== false) {
ob_end_flush();
exit;
}
}
// page building
if (in_array($page, $allowed_urls)) {

View File

@ -0,0 +1,92 @@
<?php
namespace Tests\Unit\Core;
use App\Core\PluginRouteRegistry;
use PHPUnit\Framework\TestCase;
class PluginRouteRegistryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
PluginRouteRegistry::reset();
}
protected function tearDown(): void
{
PluginRouteRegistry::reset();
parent::tearDown();
}
/**
* Registering a prefix should make it discoverable via match() and the
* prefix should be appended to allowed/public lists as configured.
*/
public function testRegisterPrefixAndInjection(): void
{
$callable = static function () {};
PluginRouteRegistry::registerPrefix('Calls', [
'dispatcher' => $callable,
'access' => 'public',
]);
$definition = PluginRouteRegistry::match('calls');
$this->assertIsArray($definition);
$this->assertSame($callable, $definition['dispatcher']);
$allowed = PluginRouteRegistry::injectAllowedPages(['dashboard']);
$this->assertContains('calls', $allowed);
$public = PluginRouteRegistry::injectPublicPages(['login']);
$this->assertContains('calls', $public);
}
/**
* Dispatch should pass the action to the registered callable.
*/
public function testDispatchWithCallable(): void
{
$captured = [];
PluginRouteRegistry::registerPrefix('reports', [
'dispatcher' => function ($action, array $context) use (&$captured) {
$captured = [$action, $context];
return true;
},
]);
$context = ['request' => ['action' => 'list'], 'foo' => 'bar'];
$handled = PluginRouteRegistry::dispatch('reports', $context);
$this->assertTrue($handled);
$this->assertSame('list', $captured[0]);
$expectedContext = $context;
$expectedContext['action'] = 'list';
$this->assertSame($expectedContext, $captured[1]);
}
/**
* Dispatch should instantiate classes with a handle() method.
*/
public function testDispatchWithClassHandler(): void
{
PluginRouteRegistry::registerPrefix('exports', [
'dispatcher' => FakeRouteHandler::class,
]);
$handled = PluginRouteRegistry::dispatch('exports', ['request' => ['action' => 'download']]);
$this->assertTrue($handled);
$this->assertSame(['exports', 'download'], FakeRouteHandler::$handledCalls);
}
}
class FakeRouteHandler
{
public static array $handledCalls = [];
public function handle(string $action, array $context)
{
self::$handledCalls = [$context['page'] ?? 'exports', $action];
return true;
}
}

View File

@ -12,8 +12,9 @@ if (!headers_sent()) {
ini_set('session.gc_maxlifetime', 1440); // 24 minutes
}
// load the main App registry
// load the main App registry and plugin route registry
require_once __DIR__ . '/../app/core/App.php';
require_once __DIR__ . '/../app/core/PluginRouteRegistry.php';
// Load plugin Log model and IP helper early so fallback wrapper is bypassed
require_once __DIR__ . '/../app/helpers/ip_helper.php';