friendica/src/App/Router.php

433 lines
14 KiB
PHP

<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
namespace Friendica\App;
use Dice\Dice;
use FastRoute\DataGenerator\GroupCountBased;
use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
use FastRoute\RouteParser\Std;
use Friendica\Capabilities\ICanHandleRequests;
use Friendica\Core\Addon;
use Friendica\Core\Cache\Enum\Duration;
use Friendica\Core\Cache\Capability\ICanCache;
use Friendica\Core\Config\Capability\IManageConfigValues;
use Friendica\Core\Hook;
use Friendica\Core\L10n;
use Friendica\Core\Lock\Capability\ICanLock;
use Friendica\Core\Session\Capability\IHandleUserSessions;
use Friendica\LegacyModule;
use Friendica\Module\HTTPException\MethodNotAllowed;
use Friendica\Module\HTTPException\PageNotFound;
use Friendica\Module\Special\Options;
use Friendica\Network\HTTPException;
use Friendica\Network\HTTPException\InternalServerErrorException;
use Friendica\Network\HTTPException\MethodNotAllowedException;
use Friendica\Network\HTTPException\NotFoundException;
use Friendica\Util\Router\FriendicaGroupCountBased;
use Psr\Log\LoggerInterface;
/**
* Wrapper for FastRoute\Router
*
* This wrapper only makes use of a subset of the router features, mainly parses a route rule to return the relevant
* module class.
*
* Actual routes are defined in App->collectRoutes.
*
* @package Friendica\App
*/
class Router
{
const DELETE = 'DELETE';
const GET = 'GET';
const PATCH = 'PATCH';
const POST = 'POST';
const PUT = 'PUT';
const OPTIONS = 'OPTIONS';
const ALLOWED_METHODS = [
self::DELETE,
self::GET,
self::PATCH,
self::POST,
self::PUT,
self::OPTIONS
];
/** @var RouteCollector */
protected $routeCollector;
/**
* @var array Module parameters
*/
protected $parameters = [];
/** @var L10n */
private $l10n;
/** @var ICanCache */
private $cache;
/** @var ICanLock */
private $lock;
/** @var Arguments */
private $args;
/** @var IManageConfigValues */
private $config;
/** @var LoggerInterface */
private $logger;
/** @var bool */
private $isLocalUser;
/** @var float */
private $dice_profiler_threshold;
/** @var Dice */
private $dice;
/** @var string */
private $baseRoutesFilepath;
/** @var array */
private $server;
/** @var string|null */
protected $moduleClass = null;
/**
* @param array $server The $_SERVER variable
* @param string $baseRoutesFilepath The path to a base routes file to leverage cache, can be empty
* @param L10n $l10n
* @param ICanCache $cache
* @param ICanLock $lock
* @param IManageConfigValues $config
* @param Arguments $args
* @param LoggerInterface $logger
* @param Dice $dice
* @param IHandleUserSessions $userSession
* @param RouteCollector|null $routeCollector
*/
public function __construct(array $server, string $baseRoutesFilepath, L10n $l10n, ICanCache $cache, ICanLock $lock, IManageConfigValues $config, Arguments $args, LoggerInterface $logger, Dice $dice, IHandleUserSessions $userSession, RouteCollector $routeCollector = null)
{
$this->baseRoutesFilepath = $baseRoutesFilepath;
$this->l10n = $l10n;
$this->cache = $cache;
$this->lock = $lock;
$this->args = $args;
$this->config = $config;
$this->dice = $dice;
$this->server = $server;
$this->logger = $logger;
$this->isLocalUser = !empty($userSession->getLocalUserId());
$this->dice_profiler_threshold = $config->get('system', 'dice_profiler_threshold', 0);
$this->routeCollector = $routeCollector ?? new RouteCollector(new Std(), new GroupCountBased());
if ($this->baseRoutesFilepath && !file_exists($this->baseRoutesFilepath)) {
throw new HTTPException\InternalServerErrorException('Routes file path does\'n exist.');
}
$this->parameters = [$this->server];
}
/**
* This will be called either automatically if a base routes file path was submitted,
* or can be called manually with a custom route array.
*
* @param array $routes The routes to add to the Router
*
* @return self The router instance with the loaded routes
*
* @throws HTTPException\InternalServerErrorException In case of invalid configs
*/
public function loadRoutes(array $routes): Router
{
$routeCollector = ($this->routeCollector ?? new RouteCollector(new Std(), new GroupCountBased()));
$this->addRoutes($routeCollector, $routes);
$this->routeCollector = $routeCollector;
// Add routes from addons
Hook::callAll('route_collection', $this->routeCollector);
return $this;
}
/**
* Adds multiple routes to a route collector
*
* @param RouteCollector $routeCollector Route collector instance
* @param array $routes Multiple routes to be added
* @throws HTTPException\InternalServerErrorException If route was wrong (somehow)
*/
private function addRoutes(RouteCollector $routeCollector, array $routes)
{
foreach ($routes as $route => $config) {
if ($this->isGroup($config)) {
$this->addGroup($route, $config, $routeCollector);
} elseif ($this->isRoute($config)) {
// Always add the OPTIONS endpoint to a route
$httpMethods = (array) $config[1];
$httpMethods[] = Router::OPTIONS;
$routeCollector->addRoute($httpMethods, $route, $config[0]);
} else {
throw new HTTPException\InternalServerErrorException("Wrong route config for route '" . print_r($route, true) . "'");
}
}
}
/**
* Adds a group of routes to a given group
*
* @param string $groupRoute The route of the group
* @param array $routes The routes of the group
* @param RouteCollector $routeCollector The route collector to add this group
*/
private function addGroup(string $groupRoute, array $routes, RouteCollector $routeCollector)
{
$routeCollector->addGroup($groupRoute, function (RouteCollector $routeCollector) use ($routes) {
$this->addRoutes($routeCollector, $routes);
});
}
/**
* Returns true in case the config is a group config
*
* @param array $config
*
* @return bool
*/
private function isGroup(array $config): bool
{
return
is_array($config) &&
is_string(array_keys($config)[0]) &&
// This entry should NOT be a BaseModule
(substr(array_keys($config)[0], 0, strlen('Friendica\Module')) !== 'Friendica\Module') &&
// The second argument is an array (another routes)
is_array(array_values($config)[0]);
}
/**
* Returns true in case the config is a route config
*
* @param array $config
*
* @return bool
*/
private function isRoute(array $config): bool
{
return
// The config array should at least have one entry
!empty($config[0]) &&
// This entry should be a BaseModule
(substr($config[0], 0, strlen('Friendica\Module')) === 'Friendica\Module') &&
// Either there is no other argument
(empty($config[1]) ||
// Or the second argument is an array (HTTP-Methods)
is_array($config[1]));
}
/**
* The current route collector
*
* @return RouteCollector|null
*/
public function getRouteCollector(): ?RouteCollector
{
return $this->routeCollector;
}
/**
* Returns the Friendica\BaseModule-extending class name if a route rule matched
*
* @return string
*
* @throws InternalServerErrorException
* @throws MethodNotAllowedException
*/
public function getModuleClass(): string
{
if (empty($this->moduleClass)) {
$this->determineModuleClass();
}
return $this->moduleClass;
}
/**
* Returns the relevant module class name for the given page URI or NULL if no route rule matched.
*
* @return void
*
* @throws HTTPException\InternalServerErrorException Unexpected exceptions
* @throws HTTPException\MethodNotAllowedException If a rule is private only
*/
private function determineModuleClass(): void
{
$cmd = $this->args->getCommand();
$cmd = '/' . ltrim($cmd, '/');
$dispatcher = new FriendicaGroupCountBased($this->getCachedDispatchData());
try {
// Check if the HTTP method is OPTIONS and return the special Options Module with the possible HTTP methods
if ($this->args->getMethod() === static::OPTIONS) {
$this->moduleClass = Options::class;
$this->parameters[] = ['AllowedMethods' => $dispatcher->getOptions($cmd)];
} else {
$routeInfo = $dispatcher->dispatch($this->args->getMethod(), $cmd);
if ($routeInfo[0] === Dispatcher::FOUND) {
$this->moduleClass = $routeInfo[1];
$this->parameters[] = $routeInfo[2];
} else if ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
throw new HTTPException\MethodNotAllowedException($this->l10n->t('Method not allowed for this module. Allowed method(s): %s', implode(', ', $routeInfo[1])));
} else {
throw new HTTPException\NotFoundException($this->l10n->t('Page not found.'));
}
}
} catch (MethodNotAllowedException $e) {
$this->moduleClass = MethodNotAllowed::class;
} catch (NotFoundException $e) {
$moduleName = $this->args->getModuleName();
// Then we try addon-provided modules that we wrap in the LegacyModule class
if (Addon::isEnabled($moduleName) && file_exists("addon/{$moduleName}/{$moduleName}.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 (!$this->isLocalUser && Hook::isAddonApp($moduleName) && $privateapps) {
throw new MethodNotAllowedException($this->l10n->t("You must be logged in to use addons. "));
} else {
include_once "addon/{$moduleName}/{$moduleName}.php";
if (function_exists($moduleName . '_module')) {
$this->parameters[] = "addon/{$moduleName}/{$moduleName}.php";
$this->moduleClass = LegacyModule::class;
}
}
}
/* Finally, we look for a 'standard' program module in the 'mod' directory
* We emulate a Module class through the LegacyModule class
*/
if (!$this->moduleClass && file_exists("mod/{$moduleName}.php")) {
$this->parameters[] = "mod/{$moduleName}.php";
$this->moduleClass = LegacyModule::class;
}
$this->moduleClass = $this->moduleClass ?: PageNotFound::class;
}
}
public function getModule(?string $module_class = null): ICanHandleRequests
{
$moduleClass = $module_class ?? $this->getModuleClass();
$stamp = microtime(true);
try {
/** @var ICanHandleRequests $module */
return $this->dice->create($moduleClass, $this->parameters);
} finally {
if ($this->dice_profiler_threshold > 0) {
$dur = floatval(microtime(true) - $stamp);
if ($dur >= $this->dice_profiler_threshold) {
$this->logger->notice('Dice module creation lasts too long.', ['duration' => round($dur, 3), 'module' => $moduleClass, 'parameters' => $this->parameters]);
}
}
}
}
/**
* If a base routes file path has been provided, we can load routes from it if the cache misses.
*
* @return array
* @throws HTTPException\InternalServerErrorException
*/
private function getDispatchData()
{
$dispatchData = [];
if ($this->baseRoutesFilepath) {
$dispatchData = require $this->baseRoutesFilepath;
if (!is_array($dispatchData)) {
throw new HTTPException\InternalServerErrorException('Invalid base routes file');
}
}
$this->loadRoutes($dispatchData);
return $this->routeCollector->getData();
}
/**
* We cache the dispatch data for speed, as computing the current routes (version 2020.09)
* takes about 850ms for each requests.
*
* The cached "routerDispatchData" lasts for a day, and must be cleared manually when there
* is any changes in the enabled addons list.
*
* Additionally, we check for the base routes file last modification time to automatically
* trigger re-computing the dispatch data.
*
* @return array|mixed
* @throws HTTPException\InternalServerErrorException
*/
private function getCachedDispatchData()
{
$routerDispatchData = $this->cache->get('routerDispatchData');
$lastRoutesFileModifiedTime = $this->cache->get('lastRoutesFileModifiedTime');
$forceRecompute = false;
if ($this->baseRoutesFilepath) {
$routesFileModifiedTime = filemtime($this->baseRoutesFilepath);
$forceRecompute = $lastRoutesFileModifiedTime != $routesFileModifiedTime;
}
if (!$forceRecompute && $routerDispatchData) {
return $routerDispatchData;
}
if (!$this->lock->acquire('getCachedDispatchData', 0)) {
// Immediately return uncached data when we can't aquire a lock
return $this->getDispatchData();
}
$routerDispatchData = $this->getDispatchData();
$this->cache->set('routerDispatchData', $routerDispatchData, Duration::DAY);
if (!empty($routesFileModifiedTime)) {
$this->cache->set('lastRoutesFileModifiedTime', $routesFileModifiedTime, Duration::MONTH);
}
if ($this->lock->isLocked('getCachedDispatchData')) {
$this->lock->release('getCachedDispatchData');
}
return $routerDispatchData;
}
}