diff --git a/CHANGELOG b/CHANGELOG index 0708576f55..ab4dab7b14 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,6 +13,7 @@ Version 2019.06 (UNRELEASED) (2019-06-?) Added syslog and stream Logger [nupplaphil] Added storage move cronjob [MrPetovan] Added collapsible panel for connector permission fields [MrPetovan] + Added rule-based router [MrPetovan] Closed Issues: 6303, 6478, 6319, 6921, 6903, 6943 diff --git a/composer.json b/composer.json index 765ec23a24..3f910cbe92 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "michelf/php-markdown": "^1.7", "mobiledetect/mobiledetectlib": "2.8.*", "monolog/monolog": "^1.24", + "nikic/fast-route": "^1.3", "paragonie/random_compat": "^2.0", "pear/text_languagedetect": "1.*", "psr/container": "^1.0", diff --git a/composer.lock b/composer.lock index ff38c81050..8595e3ddde 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8897c1f6912cc9b889534a8c59deead1", + "content-hash": "cf9846983bd7e4eb34f93ab6b493736b", "packages": [ { "name": "asika/simple-console", @@ -887,6 +887,52 @@ ], "time": "2018-11-05T09:00:11+00:00" }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "FastRoute\\": "src/" + }, + "files": [ + "src/functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "time": "2018-02-13T20:26:39+00:00" + }, { "name": "npm-asset/cropperjs", "version": "1.2.2", @@ -1083,22 +1129,6 @@ "require": { "npm-asset/ev-emitter": ">=1.0.0,<2.0.0" }, - "require-dev": { - "npm-asset/chalk": ">=1.1.1,<2.0.0", - "npm-asset/cheerio": ">=0.19.0,<0.20.0", - "npm-asset/gulp": ">=3.9.0,<4.0.0", - "npm-asset/gulp-jshint": ">=1.11.2,<2.0.0", - "npm-asset/gulp-json-lint": ">=0.1.0,<0.2.0", - "npm-asset/gulp-rename": ">=1.2.2,<2.0.0", - "npm-asset/gulp-replace": ">=0.5.4,<0.6.0", - "npm-asset/gulp-requirejs-optimize": "dev-github:metafizzy/gulp-requirejs-optimize", - "npm-asset/gulp-uglify": ">=1.4.2,<2.0.0", - "npm-asset/gulp-util": ">=3.0.7,<4.0.0", - "npm-asset/highlight.js": ">=8.9.1,<9.0.0", - "npm-asset/marked": ">=0.3.5,<0.4.0", - "npm-asset/minimist": ">=1.2.0,<2.0.0", - "npm-asset/transfob": ">=1.0.0,<2.0.0" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { @@ -1144,14 +1174,6 @@ "reference": null, "shasum": "2736e332aaee73ccf0a14a5f0066391a0a13f4a3" }, - "require-dev": { - "npm-asset/grunt": "~0.4.2", - "npm-asset/grunt-contrib-cssmin": "~0.9.0", - "npm-asset/grunt-contrib-jshint": "~0.6.3", - "npm-asset/grunt-contrib-less": "~0.11.0", - "npm-asset/grunt-contrib-uglify": "~0.4.0", - "npm-asset/grunt-contrib-watch": "~0.6.1" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { @@ -1185,32 +1207,6 @@ "reference": null, "shasum": "2c89d6889b5eac522a7eea32c14521559c6cbf02" }, - "require-dev": { - "npm-asset/commitplease": "2.0.0", - "npm-asset/core-js": "0.9.17", - "npm-asset/grunt": "0.4.5", - "npm-asset/grunt-babel": "5.0.1", - "npm-asset/grunt-cli": "0.1.13", - "npm-asset/grunt-compare-size": "0.4.0", - "npm-asset/grunt-contrib-jshint": "0.11.2", - "npm-asset/grunt-contrib-uglify": "0.9.2", - "npm-asset/grunt-contrib-watch": "0.6.1", - "npm-asset/grunt-git-authors": "2.0.1", - "npm-asset/grunt-jscs": "2.1.0", - "npm-asset/grunt-jsonlint": "1.0.4", - "npm-asset/grunt-npmcopy": "0.1.0", - "npm-asset/gzip-js": "0.3.2", - "npm-asset/jsdom": "5.6.1", - "npm-asset/load-grunt-tasks": "1.0.0", - "npm-asset/qunit-assert-step": "1.0.3", - "npm-asset/qunitjs": "1.17.1", - "npm-asset/requirejs": "2.1.17", - "npm-asset/sinon": "1.10.3", - "npm-asset/sizzle": "2.2.1", - "npm-asset/strip-json-comments": "1.0.3", - "npm-asset/testswarm": "1.1.0", - "npm-asset/win-spawn": "2.0.0" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { @@ -1361,12 +1357,6 @@ "reference": null, "shasum": "06f0335f16e353a695e7206bf50503cb523a6ee5" }, - "require-dev": { - "npm-asset/grunt": "~0.4.1", - "npm-asset/grunt-contrib-connect": "~0.5.0", - "npm-asset/grunt-contrib-jshint": "~0.7.1", - "npm-asset/grunt-contrib-uglify": "~0.2.7" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { diff --git a/doc/Addons.md b/doc/Addons.md index 5d4be6db16..b47c32e6d6 100644 --- a/doc/Addons.md +++ b/doc/Addons.md @@ -433,7 +433,11 @@ For `select`, **field** is: - [3] (String): Additional help text; Optional, default is none. - [4] (Array): Associative array of options. Item key is option value, item value is option label; Mandatory. +### route_collection +Called just before dispatching the router. +Hook data is a `\FastRoute\RouterCollector` object that should be used to add addon routes pointing to classes. +**Notice**: The class whose name is provided in the route handler must be reachable via auto-loader. ## Complete list of hook callbacks @@ -610,6 +614,7 @@ Here is a complete list of all hook callbacks with file locations (as of 24-Sep- Hook::callAll('load_config'); Hook::callAll('head'); Hook::callAll('footer'); + Hook::callAll('route_collection'); ### src/Model/Item.php diff --git a/src/App.php b/src/App.php index 7ed0377130..1649a87454 100644 --- a/src/App.php +++ b/src/App.php @@ -8,8 +8,10 @@ use Detection\MobileDetect; use DOMDocument; use DOMXPath; use Exception; +use FastRoute\RouteCollector; use Friendica\Core\Config\Cache\IConfigCache; use Friendica\Core\Config\Configuration; +use Friendica\Core\Hook; use Friendica\Core\Theme; use Friendica\Database\DBA; use Friendica\Model\Profile; @@ -35,7 +37,6 @@ use Psr\Log\LoggerInterface; */ class App { - public $module_loaded = false; public $module_class = null; public $query_string = ''; public $page = []; @@ -77,6 +78,11 @@ class App */ private $mode; + /** + * @var App\Router + */ + private $router; + /** * @var string The App URL path */ @@ -172,6 +178,11 @@ class App return $this->mode; } + public function getRouter() + { + return $this->router; + } + /** * Register a stylesheet file path to be included in the tag of every page. * Inclusion is done in App->initHead(). @@ -217,20 +228,22 @@ class App * * @param Configuration $config The Configuration * @param App\Mode $mode The mode of this Friendica app + * @param App\Router $router The router of this Friendica app * @param LoggerInterface $logger The current app logger * @param Profiler $profiler The profiler of this application * @param bool $isBackend Whether it is used for backend or frontend (Default true=backend) * * @throws Exception if the Basepath is not usable */ - public function __construct(Configuration $config, App\Mode $mode, LoggerInterface $logger, Profiler $profiler, $isBackend = true) + public function __construct(Configuration $config, App\Mode $mode, App\Router $router, LoggerInterface $logger, Profiler $profiler, $isBackend = true) { BaseObject::setApp($this); - $this->logger = $logger; $this->config = $config; - $this->profiler = $profiler; $this->mode = $mode; + $this->router = $router; + $this->profiler = $profiler; + $this->logger = $logger; $this->checkBackend($isBackend); $this->checkFriendicaApp(); @@ -1247,9 +1260,24 @@ class App $this->module = "login"; } - $privateapps = $this->config->get('config', 'private_addons', false); - if (Core\Addon::isEnabled($this->module) && file_exists("addon/{$this->module}/{$this->module}.php")) { + /* + * ROUTING + * + * From the request URL, routing consists of obtaining the name of a BaseModule-extending class of which the + * post() and/or content() static methods can be respectively called to produce a data change or an output. + */ + + // First we try explicit routes defined in App\Router + $this->router->collectRoutes(); + + Hook::callAll('route_collection', $this->router->getRouteCollector()); + + $this->module_class = $this->router->getModuleClass($this->cmd); + + // Then we try addon-provided modules that we wrap in the LegacyModule class + if (!$this->module_class && Core\Addon::isEnabled($this->module) && file_exists("addon/{$this->module}/{$this->module}.php")) { //Check if module is an app and if public access to apps is allowed or not + $privateapps = $this->config->get('config', 'private_addons', false); if ((!local_user()) && Core\Hook::isAddonApp($this->module) && $privateapps) { info(Core\L10n::t("You must be logged in to use addons. ")); } else { @@ -1257,24 +1285,21 @@ class App if (function_exists($this->module . '_module')) { LegacyModule::setModuleFile("addon/{$this->module}/{$this->module}.php"); $this->module_class = 'Friendica\\LegacyModule'; - $this->module_loaded = true; } } } - // Controller class routing - if (! $this->module_loaded && class_exists('Friendica\\Module\\' . ucfirst($this->module))) { + // Then we try name-matching a Friendica\Module class + if (!$this->module_class && class_exists('Friendica\\Module\\' . ucfirst($this->module))) { $this->module_class = 'Friendica\\Module\\' . ucfirst($this->module); - $this->module_loaded = true; } - /* If not, next look for a 'standard' program module in the 'mod' directory + /* Finally, we look for a 'standard' program module in the 'mod' directory * We emulate a Module class through the LegacyModule class */ - if (! $this->module_loaded && file_exists("mod/{$this->module}.php")) { + if (!$this->module_class && file_exists("mod/{$this->module}.php")) { LegacyModule::setModuleFile("mod/{$this->module}.php"); $this->module_class = 'Friendica\\LegacyModule'; - $this->module_loaded = true; } /* The URL provided does not resolve to a valid module. @@ -1286,7 +1311,7 @@ class App * * Otherwise we are going to emit a 404 not found. */ - if (! $this->module_loaded) { + if (!$this->module_class) { // Stupid browser tried to pre-fetch our Javascript img template. Don't log the event or return anything - just quietly exit. if (!empty($_SERVER['QUERY_STRING']) && preg_match('/{[0-9]}/', $_SERVER['QUERY_STRING']) !== 0) { exit(); @@ -1310,7 +1335,7 @@ class App $content = ''; // Initialize module that can set the current theme in the init() method, either directly or via App->profile_uid - if ($this->module_loaded) { + if ($this->module_class) { $this->page['page_title'] = $this->module; $placeholder = ''; @@ -1336,7 +1361,7 @@ class App $func($this); } - if ($this->module_loaded) { + if ($this->module_class) { if (! $this->error && $_SERVER['REQUEST_METHOD'] === 'POST') { Core\Hook::callAll($this->module . '_mod_post', $_POST); call_user_func([$this->module_class, 'post']); diff --git a/src/App/Router.php b/src/App/Router.php new file mode 100644 index 0000000000..be85fcd9dc --- /dev/null +++ b/src/App/Router.php @@ -0,0 +1,83 @@ +collectRoutes. + * + * @package Friendica\App + */ +class Router +{ + /** @var RouteCollector */ + protected $routeCollector; + + /** + * Static declaration of Friendica routes. + * + * Supports: + * - Route groups + * - Variable parts + * Disregards: + * - HTTP method other than GET + * - Named parameters + * + * Handler must be the name of a class extending Friendica\BaseModule. + * + * @brief Static declaration of Friendica routes. + */ + public function collectRoutes() + { + $this->routeCollector->addRoute(['GET', 'POST'], '/itemsource[/{guid}]', Module\Itemsource::class); + } + + public function __construct(RouteCollector $routeCollector = null) + { + if (!$routeCollector) { + $routeCollector = new RouteCollector(new Std(), new GroupCountBased()); + } + + $this->routeCollector = $routeCollector; + } + + public function getRouteCollector() + { + return $this->routeCollector; + } + + /** + * Returns the relevant module class name for the given page URI or NULL if no route rule matched. + * + * @param string $cmd The path component of the request URL without the query string + * @return string|null A Friendica\BaseModule-extending class name if a route rule matched + */ + public function getModuleClass($cmd) + { + $cmd = '/' . ltrim($cmd, '/'); + + $dispatcher = new \FastRoute\Dispatcher\GroupCountBased($this->routeCollector->getData()); + + $moduleClass = null; + + // @TODO: Enable method-specific modules + $httpMethod = 'GET'; + $routeInfo = $dispatcher->dispatch($httpMethod, $cmd); + if ($routeInfo[0] === Dispatcher::FOUND) { + $moduleClass = $routeInfo[1]; + } + + return $moduleClass; + } +} diff --git a/src/Factory/DependencyFactory.php b/src/Factory/DependencyFactory.php index 63defd95f5..0f33e095bc 100644 --- a/src/Factory/DependencyFactory.php +++ b/src/Factory/DependencyFactory.php @@ -24,6 +24,7 @@ class DependencyFactory { $basePath = BasePath::create($directory, $_SERVER); $mode = new App\Mode($basePath); + $router = new App\Router(); $configLoader = new Config\ConfigFileLoader($basePath, $mode); $configCache = Factory\ConfigFactory::createCache($configLoader); $profiler = Factory\ProfilerFactory::create($configCache); @@ -34,6 +35,6 @@ class DependencyFactory $logger = Factory\LoggerFactory::create($channel, $config, $profiler); Factory\LoggerFactory::createDev($channel, $config, $profiler); - return new App($config, $mode, $logger, $profiler, $isBackend); + return new App($config, $mode, $router, $logger, $profiler, $isBackend); } } diff --git a/src/Module/Itemsource.php b/src/Module/Itemsource.php index 12ce04f95c..f92baa987c 100644 --- a/src/Module/Itemsource.php +++ b/src/Module/Itemsource.php @@ -37,7 +37,6 @@ class Itemsource extends \Friendica\BaseModule $conversation = Model\Conversation::getByItemUri($item['uri']); - $guid = $item['guid']; $item_id = $item['id']; $item_uri = $item['uri']; $source = $conversation['source']; diff --git a/tests/include/ApiTest.php b/tests/include/ApiTest.php index 80a25c20c1..ecfe3e9621 100644 --- a/tests/include/ApiTest.php +++ b/tests/include/ApiTest.php @@ -50,6 +50,7 @@ class ApiTest extends DatabaseTest { $basePath = BasePath::create(dirname(__DIR__) . '/../'); $mode = new App\Mode($basePath); + $router = new App\Router(); $configLoader = new ConfigFileLoader($basePath, $mode); $configCache = Factory\ConfigFactory::createCache($configLoader); $profiler = Factory\ProfilerFactory::create($configCache); @@ -57,7 +58,7 @@ class ApiTest extends DatabaseTest $config = Factory\ConfigFactory::createConfig($configCache); Factory\ConfigFactory::createPConfig($configCache); $logger = Factory\LoggerFactory::create('test', $config, $profiler); - $this->app = new App($config, $mode, $logger, $profiler, false); + $this->app = new App($config, $mode, $router, $logger, $profiler, false); parent::setUp(); diff --git a/tests/src/App/RouterTest.php b/tests/src/App/RouterTest.php new file mode 100644 index 0000000000..5a573bda95 --- /dev/null +++ b/tests/src/App/RouterTest.php @@ -0,0 +1,42 @@ +getRouteCollector(); + $routeCollector->addRoute(['GET'], '/', 'IndexModuleClassName'); + $routeCollector->addRoute(['GET'], '/test', 'TestModuleClassName'); + $routeCollector->addRoute(['GET'], '/test/sub', 'TestSubModuleClassName'); + $routeCollector->addRoute(['GET'], '/optional[/option]', 'OptionalModuleClassName'); + $routeCollector->addRoute(['GET'], '/variable/{var}', 'VariableModuleClassName'); + $routeCollector->addRoute(['GET'], '/optionalvariable[/{option}]', 'OptionalVariableModuleClassName'); + $routeCollector->addRoute(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'], '/unsupported', 'UnsupportedMethodModuleClassName'); + + $this->assertEquals('IndexModuleClassName', $router->getModuleClass('/')); + + $this->assertEquals('TestModuleClassName', $router->getModuleClass('/test')); + $this->assertNull($router->getModuleClass('/tes')); + + $this->assertEquals('TestSubModuleClassName', $router->getModuleClass('/test/sub')); + + $this->assertEquals('OptionalModuleClassName', $router->getModuleClass('/optional')); + $this->assertEquals('OptionalModuleClassName', $router->getModuleClass('/optional/option')); + $this->assertNull($router->getModuleClass('/optional/opt')); + + $this->assertEquals('VariableModuleClassName', $router->getModuleClass('/variable/123abc')); + $this->assertNull($router->getModuleClass('/variable')); + + $this->assertEquals('OptionalVariableModuleClassName', $router->getModuleClass('/optionalvariable')); + $this->assertEquals('OptionalVariableModuleClassName', $router->getModuleClass('/optionalvariable/123abc')); + + $this->assertNull($router->getModuleClass('/unsupported')); + } +} diff --git a/tests/src/Database/DBATest.php b/tests/src/Database/DBATest.php index c941377219..889ae6af0d 100644 --- a/tests/src/Database/DBATest.php +++ b/tests/src/Database/DBATest.php @@ -15,6 +15,7 @@ class DBATest extends DatabaseTest { $basePath = BasePath::create(dirname(__DIR__) . '/../../'); $mode = new App\Mode($basePath); + $router = new App\Router(); $configLoader = new ConfigFileLoader($basePath, $mode); $configCache = Factory\ConfigFactory::createCache($configLoader); $profiler = Factory\ProfilerFactory::create($configCache); @@ -22,7 +23,7 @@ class DBATest extends DatabaseTest $config = Factory\ConfigFactory::createConfig($configCache); Factory\ConfigFactory::createPConfig($configCache); $logger = Factory\LoggerFactory::create('test', $config, $profiler); - $this->app = new App($config, $mode, $logger, $profiler, false); + $this->app = new App($config, $mode, $router, $logger, $profiler, false); parent::setUp(); diff --git a/tests/src/Database/DBStructureTest.php b/tests/src/Database/DBStructureTest.php index 152014c114..ec1531783e 100644 --- a/tests/src/Database/DBStructureTest.php +++ b/tests/src/Database/DBStructureTest.php @@ -15,6 +15,7 @@ class DBStructureTest extends DatabaseTest { $basePath = BasePath::create(dirname(__DIR__) . '/../../'); $mode = new App\Mode($basePath); + $router = new App\Router(); $configLoader = new ConfigFileLoader($basePath, $mode); $configCache = Factory\ConfigFactory::createCache($configLoader); $profiler = Factory\ProfilerFactory::create($configCache); @@ -22,7 +23,7 @@ class DBStructureTest extends DatabaseTest $config = Factory\ConfigFactory::createConfig($configCache); Factory\ConfigFactory::createPConfig($configCache); $logger = Factory\LoggerFactory::create('test', $config, $profiler); - $this->app = new App($config, $mode, $logger, $profiler, false); + $this->app = new App($config, $mode, $router, $logger, $profiler, false); parent::setUp(); }