Adds plugin route registry, one plugin can register multiple routes
parent
e2e3d74de1
commit
521d8eafab
|
|
@ -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'] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,8 +22,10 @@ define('APP_PATH', __DIR__ . '/../app/');
|
||||||
// Prepare config loader
|
// Prepare config loader
|
||||||
require_once APP_PATH . 'core/ConfigLoader.php';
|
require_once APP_PATH . 'core/ConfigLoader.php';
|
||||||
require_once APP_PATH . 'core/App.php';
|
require_once APP_PATH . 'core/App.php';
|
||||||
|
require_once APP_PATH . 'core/PluginRouteRegistry.php';
|
||||||
use App\Core\ConfigLoader;
|
use App\Core\ConfigLoader;
|
||||||
use App\App;
|
use App\App;
|
||||||
|
use App\Core\PluginRouteRegistry;
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
$config = ConfigLoader::loadConfig([
|
$config = ConfigLoader::loadConfig([
|
||||||
|
|
@ -78,6 +80,9 @@ function filter_public_pages(array $pages): array {
|
||||||
function filter_allowed_urls(array $urls): array {
|
function filter_allowed_urls(array $urls): array {
|
||||||
return HookDispatcher::applyFilters('filter_allowed_urls', $urls);
|
return HookDispatcher::applyFilters('filter_allowed_urls', $urls);
|
||||||
}
|
}
|
||||||
|
function register_plugin_route_prefix(string $prefix, array $definition = []): void {
|
||||||
|
PluginRouteRegistry::registerPrefix($prefix, $definition);
|
||||||
|
}
|
||||||
|
|
||||||
// Load enabled plugins
|
// Load enabled plugins
|
||||||
$plugins_dir = dirname(__DIR__) . '/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
|
// Let plugins filter/extend public_pages
|
||||||
$public_pages = filter_public_pages($public_pages);
|
$public_pages = filter_public_pages($public_pages);
|
||||||
|
$public_pages = PluginRouteRegistry::injectPublicPages($public_pages);
|
||||||
|
|
||||||
// Middleware pipeline for security, sanitization & CSRF
|
// Middleware pipeline for security, sanitization & CSRF
|
||||||
require_once APP_PATH . 'core/MiddlewarePipeline.php';
|
require_once APP_PATH . 'core/MiddlewarePipeline.php';
|
||||||
|
|
@ -154,6 +160,7 @@ $allowed_urls = [
|
||||||
|
|
||||||
// Let plugins filter/extend allowed_urls
|
// Let plugins filter/extend allowed_urls
|
||||||
$allowed_urls = filter_allowed_urls($allowed_urls);
|
$allowed_urls = filter_allowed_urls($allowed_urls);
|
||||||
|
$allowed_urls = PluginRouteRegistry::injectAllowedPages($allowed_urls);
|
||||||
|
|
||||||
// Dispatch routing and auth
|
// Dispatch routing and auth
|
||||||
require_once APP_PATH . 'core/Router.php';
|
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
|
// page building
|
||||||
if (in_array($page, $allowed_urls)) {
|
if (in_array($page, $allowed_urls)) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,9 @@ if (!headers_sent()) {
|
||||||
ini_set('session.gc_maxlifetime', 1440); // 24 minutes
|
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/App.php';
|
||||||
|
require_once __DIR__ . '/../app/core/PluginRouteRegistry.php';
|
||||||
|
|
||||||
// Load plugin Log model and IP helper early so fallback wrapper is bypassed
|
// Load plugin Log model and IP helper early so fallback wrapper is bypassed
|
||||||
require_once __DIR__ . '/../app/helpers/ip_helper.php';
|
require_once __DIR__ . '/../app/helpers/ip_helper.php';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue