]> git.mxchange.org Git - friendica.git/blob - src/App/Router.php
e90cb3ace06523c3e95620289c33bab580ab3a6e
[friendica.git] / src / App / Router.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  */
21
22 namespace Friendica\App;
23
24
25 use FastRoute\DataGenerator\GroupCountBased;
26 use FastRoute\Dispatcher;
27 use FastRoute\RouteCollector;
28 use FastRoute\RouteParser\Std;
29 use Friendica\Core\Cache\Duration;
30 use Friendica\Core\Cache\ICache;
31 use Friendica\Core\Hook;
32 use Friendica\Core\L10n;
33 use Friendica\Network\HTTPException;
34
35 /**
36  * Wrapper for FastRoute\Router
37  *
38  * This wrapper only makes use of a subset of the router features, mainly parses a route rule to return the relevant
39  * module class.
40  *
41  * Actual routes are defined in App->collectRoutes.
42  *
43  * @package Friendica\App
44  */
45 class Router
46 {
47         const POST = 'POST';
48         const GET  = 'GET';
49
50         const ALLOWED_METHODS = [
51                 self::POST,
52                 self::GET,
53         ];
54
55         /** @var RouteCollector */
56         protected $routeCollector;
57
58         /**
59          * @var string The HTTP method
60          */
61         private $httpMethod;
62
63         /**
64          * @var array Module parameters
65          */
66         private $parameters = [];
67
68         /** @var L10n */
69         private $l10n;
70
71         /** @var ICache */
72         private $cache;
73
74         /** @var string */
75         private $baseRoutesFilepath;
76
77         /**
78          * @param array               $server             The $_SERVER variable
79          * @param string              $baseRoutesFilepath The path to a base routes file to leverage cache, can be empty
80          * @param L10n                $l10n
81          * @param ICache              $cache
82          * @param RouteCollector|null $routeCollector
83          */
84         public function __construct(array $server, string $baseRoutesFilepath, L10n $l10n, ICache $cache, RouteCollector $routeCollector = null)
85         {
86                 $this->baseRoutesFilepath = $baseRoutesFilepath;
87                 $this->l10n = $l10n;
88                 $this->cache = $cache;
89
90                 $httpMethod = $server['REQUEST_METHOD'] ?? self::GET;
91                 $this->httpMethod = in_array($httpMethod, self::ALLOWED_METHODS) ? $httpMethod : self::GET;
92
93                 $this->routeCollector = isset($routeCollector) ?
94                         $routeCollector :
95                         new RouteCollector(new Std(), new GroupCountBased());
96
97                 if ($this->baseRoutesFilepath && !file_exists($this->baseRoutesFilepath)) {
98                         throw new HTTPException\InternalServerErrorException('Routes file path does\'n exist.');
99                 }
100         }
101
102         /**
103          * This will be called either automatically if a base routes file path was submitted,
104          * or can be called manually with a custom route array.
105          *
106          * @param array $routes The routes to add to the Router
107          *
108          * @return self The router instance with the loaded routes
109          *
110          * @throws HTTPException\InternalServerErrorException In case of invalid configs
111          */
112         public function loadRoutes(array $routes)
113         {
114                 $routeCollector = (isset($this->routeCollector) ?
115                         $this->routeCollector :
116                         new RouteCollector(new Std(), new GroupCountBased()));
117
118                 $this->addRoutes($routeCollector, $routes);
119
120                 $this->routeCollector = $routeCollector;
121
122                 // Add routes from addons
123                 Hook::callAll('route_collection', $this->routeCollector);
124
125                 return $this;
126         }
127
128         private function addRoutes(RouteCollector $routeCollector, array $routes)
129         {
130                 foreach ($routes as $route => $config) {
131                         if ($this->isGroup($config)) {
132                                 $this->addGroup($route, $config, $routeCollector);
133                         } elseif ($this->isRoute($config)) {
134                                 $routeCollector->addRoute($config[1], $route, $config[0]);
135                         } else {
136                                 throw new HTTPException\InternalServerErrorException("Wrong route config for route '" . print_r($route, true) . "'");
137                         }
138                 }
139         }
140
141         /**
142          * Adds a group of routes to a given group
143          *
144          * @param string         $groupRoute     The route of the group
145          * @param array          $routes         The routes of the group
146          * @param RouteCollector $routeCollector The route collector to add this group
147          */
148         private function addGroup(string $groupRoute, array $routes, RouteCollector $routeCollector)
149         {
150                 $routeCollector->addGroup($groupRoute, function (RouteCollector $routeCollector) use ($routes) {
151                         $this->addRoutes($routeCollector, $routes);
152                 });
153         }
154
155         /**
156          * Returns true in case the config is a group config
157          *
158          * @param array $config
159          *
160          * @return bool
161          */
162         private function isGroup(array $config)
163         {
164                 return
165                         is_array($config) &&
166                         is_string(array_keys($config)[0]) &&
167                         // This entry should NOT be a BaseModule
168                         (substr(array_keys($config)[0], 0, strlen('Friendica\Module')) !== 'Friendica\Module') &&
169                         // The second argument is an array (another routes)
170                         is_array(array_values($config)[0]);
171         }
172
173         /**
174          * Returns true in case the config is a route config
175          *
176          * @param array $config
177          *
178          * @return bool
179          */
180         private function isRoute(array $config)
181         {
182                 return
183                         // The config array should at least have one entry
184                         !empty($config[0]) &&
185                         // This entry should be a BaseModule
186                         (substr($config[0], 0, strlen('Friendica\Module')) === 'Friendica\Module') &&
187                         // Either there is no other argument
188                         (empty($config[1]) ||
189                          // Or the second argument is an array (HTTP-Methods)
190                          is_array($config[1]));
191         }
192
193         /**
194          * The current route collector
195          *
196          * @return RouteCollector|null
197          */
198         public function getRouteCollector()
199         {
200                 return $this->routeCollector;
201         }
202
203         /**
204          * Returns the relevant module class name for the given page URI or NULL if no route rule matched.
205          *
206          * @param string $cmd The path component of the request URL without the query string
207          *
208          * @return string A Friendica\BaseModule-extending class name if a route rule matched
209          *
210          * @throws HTTPException\InternalServerErrorException
211          * @throws HTTPException\MethodNotAllowedException    If a rule matched but the method didn't
212          * @throws HTTPException\NotFoundException            If no rule matched
213          */
214         public function getModuleClass($cmd)
215         {
216                 $cmd = '/' . ltrim($cmd, '/');
217
218                 $dispatcher = new Dispatcher\GroupCountBased($this->getCachedDispatchData());
219
220                 $moduleClass = null;
221                 $this->parameters = [];
222
223                 $routeInfo  = $dispatcher->dispatch($this->httpMethod, $cmd);
224                 if ($routeInfo[0] === Dispatcher::FOUND) {
225                         $moduleClass = $routeInfo[1];
226                         $this->parameters = $routeInfo[2];
227                 } elseif ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
228                         throw new HTTPException\MethodNotAllowedException($this->l10n->t('Method not allowed for this module. Allowed method(s): %s', implode(', ', $routeInfo[1])));
229                 } else {
230                         throw new HTTPException\NotFoundException($this->l10n->t('Page not found.'));
231                 }
232
233                 return $moduleClass;
234         }
235
236         /**
237          * Returns the module parameters.
238          *
239          * @return array parameters
240          */
241         public function getModuleParameters()
242         {
243                 return $this->parameters;
244         }
245
246         /**
247          * If a base routes file path has been provided, we can load routes from it if the cache misses.
248          *
249          * @return array
250          * @throws HTTPException\InternalServerErrorException
251          */
252         private function getDispatchData()
253         {
254                 $dispatchData = [];
255
256                 if ($this->baseRoutesFilepath) {
257                         $dispatchData = require $this->baseRoutesFilepath;
258                         if (!is_array($dispatchData)) {
259                                 throw new HTTPException\InternalServerErrorException('Invalid base routes file');
260                         }
261                 }
262
263                 $this->loadRoutes($dispatchData);
264
265                 return $this->routeCollector->getData();
266         }
267
268         /**
269          * We cache the dispatch data for speed, as computing the current routes (version 2020.09)
270          * takes about 850ms for each requests.
271          *
272          * The cached "routerDispatchData" lasts for a day, and must be cleared manually when there
273          * is any changes in the enabled addons list.
274          *
275          * Additionally, we check for the base routes file last modification time to automatically
276          * trigger re-computing the dispatch data.
277          *
278          * @return array|mixed
279          * @throws HTTPException\InternalServerErrorException
280          */
281         private function getCachedDispatchData()
282         {
283                 $routerDispatchData = $this->cache->get('routerDispatchData');
284                 $lastRoutesFileModifiedTime = $this->cache->get('lastRoutesFileModifiedTime');
285                 $forceRecompute = false;
286
287                 if ($this->baseRoutesFilepath) {
288                         $routesFileModifiedTime = filemtime($this->baseRoutesFilepath);
289                         $forceRecompute = $lastRoutesFileModifiedTime != $routesFileModifiedTime;
290                 }
291
292                 if (!$forceRecompute && $routerDispatchData) {
293                         return $routerDispatchData;
294                 }
295
296                 $routerDispatchData = $this->getDispatchData();
297
298                 $this->cache->set('routerDispatchData', $routerDispatchData, Duration::DAY);
299                 if (!empty($routesFileModifiedTime)) {
300                         $this->cache->set('lastRoutesFileMtime', $routesFileModifiedTime, Duration::MONTH);
301                 }
302
303                 return $routerDispatchData;
304         }
305 }