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