]> git.mxchange.org Git - friendica.git/blob - src/App/Router.php
Merge pull request #10421 from annando/ap-photo
[friendica.git] / src / App / Router.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2021, the Friendica project
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 DELETE  = 'DELETE';
48         const GET     = 'GET';
49         const PATCH   = 'PATCH';
50         const POST    = 'POST';
51         const PUT     = 'PUT';
52         const OPTIONS = 'OPTIONS';
53
54         const ALLOWED_METHODS = [
55                 self::DELETE,
56                 self::GET,
57                 self::PATCH,
58                 self::POST,
59                 self::PUT,
60                 self::OPTIONS
61         ];
62
63         /** @var RouteCollector */
64         protected $routeCollector;
65
66         /**
67          * @var string The HTTP method
68          */
69         private $httpMethod;
70
71         /**
72          * @var array Module parameters
73          */
74         private $parameters = [];
75
76         /** @var L10n */
77         private $l10n;
78
79         /** @var ICache */
80         private $cache;
81
82         /** @var string */
83         private $baseRoutesFilepath;
84
85         /**
86          * @param array               $server             The $_SERVER variable
87          * @param string              $baseRoutesFilepath The path to a base routes file to leverage cache, can be empty
88          * @param L10n                $l10n
89          * @param ICache              $cache
90          * @param RouteCollector|null $routeCollector
91          */
92         public function __construct(array $server, string $baseRoutesFilepath, L10n $l10n, ICache $cache, RouteCollector $routeCollector = null)
93         {
94                 $this->baseRoutesFilepath = $baseRoutesFilepath;
95                 $this->l10n = $l10n;
96                 $this->cache = $cache;
97
98                 $httpMethod = $server['REQUEST_METHOD'] ?? self::GET;
99                 $this->httpMethod = in_array($httpMethod, self::ALLOWED_METHODS) ? $httpMethod : self::GET;
100
101                 $this->routeCollector = isset($routeCollector) ?
102                         $routeCollector :
103                         new RouteCollector(new Std(), new GroupCountBased());
104
105                 if ($this->baseRoutesFilepath && !file_exists($this->baseRoutesFilepath)) {
106                         throw new HTTPException\InternalServerErrorException('Routes file path does\'n exist.');
107                 }
108         }
109
110         /**
111          * This will be called either automatically if a base routes file path was submitted,
112          * or can be called manually with a custom route array.
113          *
114          * @param array $routes The routes to add to the Router
115          *
116          * @return self The router instance with the loaded routes
117          *
118          * @throws HTTPException\InternalServerErrorException In case of invalid configs
119          */
120         public function loadRoutes(array $routes)
121         {
122                 $routeCollector = (isset($this->routeCollector) ?
123                         $this->routeCollector :
124                         new RouteCollector(new Std(), new GroupCountBased()));
125
126                 $this->addRoutes($routeCollector, $routes);
127
128                 $this->routeCollector = $routeCollector;
129
130                 // Add routes from addons
131                 Hook::callAll('route_collection', $this->routeCollector);
132
133                 return $this;
134         }
135
136         private function addRoutes(RouteCollector $routeCollector, array $routes)
137         {
138                 foreach ($routes as $route => $config) {
139                         if ($this->isGroup($config)) {
140                                 $this->addGroup($route, $config, $routeCollector);
141                         } elseif ($this->isRoute($config)) {
142                                 $routeCollector->addRoute($config[1], $route, $config[0]);
143                         } else {
144                                 throw new HTTPException\InternalServerErrorException("Wrong route config for route '" . print_r($route, true) . "'");
145                         }
146                 }
147         }
148
149         /**
150          * Adds a group of routes to a given group
151          *
152          * @param string         $groupRoute     The route of the group
153          * @param array          $routes         The routes of the group
154          * @param RouteCollector $routeCollector The route collector to add this group
155          */
156         private function addGroup(string $groupRoute, array $routes, RouteCollector $routeCollector)
157         {
158                 $routeCollector->addGroup($groupRoute, function (RouteCollector $routeCollector) use ($routes) {
159                         $this->addRoutes($routeCollector, $routes);
160                 });
161         }
162
163         /**
164          * Returns true in case the config is a group config
165          *
166          * @param array $config
167          *
168          * @return bool
169          */
170         private function isGroup(array $config)
171         {
172                 return
173                         is_array($config) &&
174                         is_string(array_keys($config)[0]) &&
175                         // This entry should NOT be a BaseModule
176                         (substr(array_keys($config)[0], 0, strlen('Friendica\Module')) !== 'Friendica\Module') &&
177                         // The second argument is an array (another routes)
178                         is_array(array_values($config)[0]);
179         }
180
181         /**
182          * Returns true in case the config is a route config
183          *
184          * @param array $config
185          *
186          * @return bool
187          */
188         private function isRoute(array $config)
189         {
190                 return
191                         // The config array should at least have one entry
192                         !empty($config[0]) &&
193                         // This entry should be a BaseModule
194                         (substr($config[0], 0, strlen('Friendica\Module')) === 'Friendica\Module') &&
195                         // Either there is no other argument
196                         (empty($config[1]) ||
197                          // Or the second argument is an array (HTTP-Methods)
198                          is_array($config[1]));
199         }
200
201         /**
202          * The current route collector
203          *
204          * @return RouteCollector|null
205          */
206         public function getRouteCollector()
207         {
208                 return $this->routeCollector;
209         }
210
211         /**
212          * Returns the relevant module class name for the given page URI or NULL if no route rule matched.
213          *
214          * @param string $cmd The path component of the request URL without the query string
215          *
216          * @return string A Friendica\BaseModule-extending class name if a route rule matched
217          *
218          * @throws HTTPException\InternalServerErrorException
219          * @throws HTTPException\MethodNotAllowedException    If a rule matched but the method didn't
220          * @throws HTTPException\NotFoundException            If no rule matched
221          */
222         public function getModuleClass($cmd)
223         {
224                 $cmd = '/' . ltrim($cmd, '/');
225
226                 $dispatcher = new Dispatcher\GroupCountBased($this->getCachedDispatchData());
227
228                 $moduleClass = null;
229                 $this->parameters = [];
230
231                 $routeInfo  = $dispatcher->dispatch($this->httpMethod, $cmd);
232                 if ($routeInfo[0] === Dispatcher::FOUND) {
233                         $moduleClass = $routeInfo[1];
234                         $this->parameters = $routeInfo[2];
235                 } elseif ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
236                         throw new HTTPException\MethodNotAllowedException($this->l10n->t('Method not allowed for this module. Allowed method(s): %s', implode(', ', $routeInfo[1])));
237                 } else {
238                         throw new HTTPException\NotFoundException($this->l10n->t('Page not found.'));
239                 }
240
241                 return $moduleClass;
242         }
243
244         /**
245          * Returns the module parameters.
246          *
247          * @return array parameters
248          */
249         public function getModuleParameters()
250         {
251                 return $this->parameters;
252         }
253
254         /**
255          * If a base routes file path has been provided, we can load routes from it if the cache misses.
256          *
257          * @return array
258          * @throws HTTPException\InternalServerErrorException
259          */
260         private function getDispatchData()
261         {
262                 $dispatchData = [];
263
264                 if ($this->baseRoutesFilepath) {
265                         $dispatchData = require $this->baseRoutesFilepath;
266                         if (!is_array($dispatchData)) {
267                                 throw new HTTPException\InternalServerErrorException('Invalid base routes file');
268                         }
269                 }
270
271                 $this->loadRoutes($dispatchData);
272
273                 return $this->routeCollector->getData();
274         }
275
276         /**
277          * We cache the dispatch data for speed, as computing the current routes (version 2020.09)
278          * takes about 850ms for each requests.
279          *
280          * The cached "routerDispatchData" lasts for a day, and must be cleared manually when there
281          * is any changes in the enabled addons list.
282          *
283          * Additionally, we check for the base routes file last modification time to automatically
284          * trigger re-computing the dispatch data.
285          *
286          * @return array|mixed
287          * @throws HTTPException\InternalServerErrorException
288          */
289         private function getCachedDispatchData()
290         {
291                 $routerDispatchData = $this->cache->get('routerDispatchData');
292                 $lastRoutesFileModifiedTime = $this->cache->get('lastRoutesFileModifiedTime');
293                 $forceRecompute = false;
294
295                 if ($this->baseRoutesFilepath) {
296                         $routesFileModifiedTime = filemtime($this->baseRoutesFilepath);
297                         $forceRecompute = $lastRoutesFileModifiedTime != $routesFileModifiedTime;
298                 }
299
300                 if (!$forceRecompute && $routerDispatchData) {
301                         return $routerDispatchData;
302                 }
303
304                 $routerDispatchData = $this->getDispatchData();
305
306                 $this->cache->set('routerDispatchData', $routerDispatchData, Duration::DAY);
307                 if (!empty($routesFileModifiedTime)) {
308                         $this->cache->set('lastRoutesFileMtime', $routesFileModifiedTime, Duration::MONTH);
309                 }
310
311                 return $routerDispatchData;
312         }
313 }