From 521d8eafab3429e2ddc77edb73bc4e29acec85c1 Mon Sep 17 00:00:00 2001 From: Yasen Pramatarov Date: Tue, 13 Jan 2026 13:49:56 +0200 Subject: [PATCH] Adds plugin route registry, one plugin can register multiple routes --- app/core/PluginRouteRegistry.php | 129 ++++++++++++++++++++ public_html/index.php | 33 +++++ tests/Unit/Core/PluginRouteRegistryTest.php | 92 ++++++++++++++ tests/bootstrap.php | 3 +- 4 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 app/core/PluginRouteRegistry.php create mode 100644 tests/Unit/Core/PluginRouteRegistryTest.php diff --git a/app/core/PluginRouteRegistry.php b/app/core/PluginRouteRegistry.php new file mode 100644 index 0000000..cddfc27 --- /dev/null +++ b/app/core/PluginRouteRegistry.php @@ -0,0 +1,129 @@ + */ + 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'] = []; + } +} diff --git a/public_html/index.php b/public_html/index.php index 8150728..8bf2e22 100644 --- a/public_html/index.php +++ b/public_html/index.php @@ -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)) { diff --git a/tests/Unit/Core/PluginRouteRegistryTest.php b/tests/Unit/Core/PluginRouteRegistryTest.php new file mode 100644 index 0000000..8353733 --- /dev/null +++ b/tests/Unit/Core/PluginRouteRegistryTest.php @@ -0,0 +1,92 @@ + $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; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 550906b..7b2e6b8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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';