Commit | Line | Data |
---|---|---|
d71e5a29 | 1 | <?php |
a9229942 | 2 | |
d71e5a29 | 3 | namespace wcf\system\request; |
a9229942 | 4 | |
d2864e2b | 5 | use wcf\system\event\EventHandler; |
6b51ca9c | 6 | use wcf\system\exception\SystemException; |
e3530f10 | 7 | use wcf\system\request\route\DynamicRequestRoute; |
7faef021 | 8 | use wcf\system\request\route\IRequestRoute; |
2d38f346 | 9 | use wcf\system\request\route\LookupRequestRoute; |
d71e5a29 | 10 | use wcf\system\SingletonFactory; |
3dcfb497 | 11 | use wcf\util\FileUtil; |
d71e5a29 AE |
12 | |
13 | /** | |
14 | * Handles routes for HTTP requests. | |
a9229942 | 15 | * |
d71e5a29 AE |
16 | * Inspired by routing mechanism used by ASP.NET MVC and released under the terms of |
17 | * the Microsoft Public License (MS-PL) http://www.opensource.org/licenses/ms-pl.html | |
a9229942 TD |
18 | * |
19 | * @author Alexander Ebert | |
20 | * @copyright 2001-2019 WoltLab GmbH | |
21 | * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> | |
d71e5a29 | 22 | */ |
84b1ab27 | 23 | final class RouteHandler extends SingletonFactory |
a9229942 TD |
24 | { |
25 | /** | |
26 | * current host and protocol | |
27 | * @var string | |
28 | */ | |
7d0367c5 | 29 | private static $host = ''; |
a9229942 TD |
30 | |
31 | /** | |
32 | * current absolute path | |
33 | * @var string | |
34 | */ | |
7d0367c5 | 35 | private static $path = ''; |
a9229942 TD |
36 | |
37 | /** | |
38 | * current path info component | |
39 | * @var string | |
40 | */ | |
7d0367c5 | 41 | private static $pathInfo; |
a9229942 TD |
42 | |
43 | /** | |
44 | * HTTP protocol, either 'http://' or 'https://' | |
45 | * @var string | |
46 | */ | |
7d0367c5 | 47 | private static $protocol = ''; |
a9229942 TD |
48 | |
49 | /** | |
50 | * HTTP encryption | |
51 | * @var bool | |
52 | */ | |
7d0367c5 | 53 | private static $secure; |
a9229942 | 54 | |
a9229942 TD |
55 | /** |
56 | * true if the default controller is used (support for custom landing page) | |
a9229942 | 57 | */ |
0ca2dd42 | 58 | private bool $isDefaultController = false; |
a9229942 TD |
59 | |
60 | /** | |
61 | * true if the controller was renamed and has already been transformed | |
a9229942 | 62 | */ |
0ca2dd42 | 63 | private bool $isRenamedController = false; |
a9229942 TD |
64 | |
65 | /** | |
66 | * list of available routes | |
67 | * @var IRequestRoute[] | |
68 | */ | |
0ca2dd42 | 69 | private array $routes = []; |
a9229942 TD |
70 | |
71 | /** | |
72 | * parsed route data | |
73 | * @var array | |
74 | */ | |
7d0367c5 | 75 | private $routeData; |
a9229942 TD |
76 | |
77 | /** | |
78 | * Sets default routes. | |
79 | */ | |
80 | protected function init() | |
81 | { | |
82 | $route = new DynamicRequestRoute(); | |
83 | $route->setIsACP(true); | |
84 | $this->addRoute($route); | |
85 | ||
86 | $route = new DynamicRequestRoute(); | |
87 | $this->addRoute($route); | |
88 | ||
89 | $route = new LookupRequestRoute(); | |
90 | $this->addRoute($route); | |
91 | ||
92 | // fire event | |
93 | EventHandler::getInstance()->fireAction($this, 'didInit'); | |
94 | } | |
95 | ||
96 | /** | |
97 | * Adds a new route to the beginning of all routes. | |
98 | * | |
99 | * @param IRequestRoute $route | |
100 | */ | |
101 | public function addRoute(IRequestRoute $route) | |
102 | { | |
103 | \array_unshift($this->routes, $route); | |
104 | } | |
105 | ||
106 | /** | |
107 | * Returns all registered routes. | |
108 | * | |
109 | * @return IRequestRoute[] | |
110 | **/ | |
111 | public function getRoutes() | |
112 | { | |
113 | return $this->routes; | |
114 | } | |
115 | ||
116 | /** | |
117 | * Returns true if a route matches. Please bear in mind, that the | |
118 | * first route that is able to consume all path components is used, | |
119 | * even if other routes may fit better. Route order is crucial! | |
a9229942 | 120 | */ |
0ca2dd42 | 121 | public function matches(): bool |
a9229942 TD |
122 | { |
123 | foreach ($this->routes as $route) { | |
124 | if (RequestHandler::getInstance()->isACPRequest() != $route->isACP()) { | |
125 | continue; | |
126 | } | |
127 | ||
128 | if ($route->matches(self::getPathInfo())) { | |
129 | $this->routeData = $route->getRouteData(); | |
130 | ||
131 | $this->isDefaultController = $this->routeData['isDefaultController']; | |
132 | unset($this->routeData['isDefaultController']); | |
133 | ||
b551810a TD |
134 | $hasController = isset($this->routeData['controller']) && $this->routeData['controller'] !== ''; |
135 | if ( | |
136 | ($hasController && $this->isDefaultController()) | |
137 | || (!$hasController && !$this->isDefaultController()) | |
138 | ) { | |
139 | throw new \DomainException(\sprintf( | |
140 | "Route implementation '%s' is buggy: Matched route must either be the default controller or a controller must be returned.", | |
141 | $route::class | |
142 | )); | |
5da310cc TD |
143 | } |
144 | ||
a9229942 TD |
145 | if (isset($this->routeData['isRenamedController'])) { |
146 | $this->isRenamedController = $this->routeData['isRenamedController']; | |
147 | unset($this->routeData['isRenamedController']); | |
148 | } | |
149 | ||
150 | $this->registerRouteData(); | |
151 | ||
152 | return true; | |
153 | } | |
154 | } | |
155 | ||
156 | return false; | |
157 | } | |
158 | ||
159 | /** | |
160 | * Returns true if route uses default controller. | |
a9229942 | 161 | */ |
0ca2dd42 | 162 | public function isDefaultController(): bool |
a9229942 TD |
163 | { |
164 | return $this->isDefaultController; | |
165 | } | |
166 | ||
167 | /** | |
168 | * Returns true if the controller was renamed and has already been transformed. | |
a9229942 | 169 | */ |
0ca2dd42 | 170 | public function isRenamedController(): bool |
a9229942 TD |
171 | { |
172 | return $this->isRenamedController; | |
173 | } | |
174 | ||
175 | /** | |
176 | * Returns parsed route data | |
177 | * | |
178 | * @return array | |
179 | */ | |
180 | public function getRouteData() | |
181 | { | |
182 | return $this->routeData; | |
183 | } | |
184 | ||
185 | /** | |
186 | * Registers route data within $_GET and $_REQUEST. | |
187 | */ | |
0ca2dd42 | 188 | private function registerRouteData(): void |
a9229942 TD |
189 | { |
190 | foreach ($this->routeData as $key => $value) { | |
191 | $_GET[$key] = $value; | |
192 | $_REQUEST[$key] = $value; | |
193 | } | |
194 | } | |
195 | ||
196 | /** | |
197 | * Builds a route based upon route components, this is nothing | |
198 | * but a reverse lookup. | |
199 | * | |
200 | * @param string $application application identifier | |
201 | * @param array $components | |
202 | * @param bool $isACP | |
203 | * @return string | |
204 | * @throws SystemException | |
205 | */ | |
206 | public function buildRoute($application, array $components, $isACP = null) | |
207 | { | |
208 | if ($isACP === null) { | |
209 | $isACP = RequestHandler::getInstance()->isACPRequest(); | |
210 | } | |
211 | $components['application'] = $application; | |
212 | ||
213 | foreach ($this->routes as $route) { | |
214 | if ($isACP != $route->isACP()) { | |
215 | continue; | |
216 | } | |
217 | ||
218 | if ($route->canHandle($components)) { | |
219 | return $route->buildLink($components); | |
220 | } | |
221 | } | |
222 | ||
223 | throw new SystemException("Unable to build route, no available route is satisfied."); | |
224 | } | |
225 | ||
226 | /** | |
227 | * Returns true if `$customUrl` contains only the letters a-z/A-Z, numbers, dashes, | |
228 | * underscores and forward slashes. | |
229 | * | |
230 | * All other characters including those from the unicode range are potentially unsafe, | |
231 | * especially when dealing with url rewriting and resulting encoding issues with some | |
232 | * webservers. | |
233 | * | |
234 | * This heavily limits the abilities for end-users to define appealing urls, but at | |
235 | * the same time this ensures a sufficient level of stability. | |
236 | * | |
237 | * @param string $customUrl url to perform sanity checks on | |
238 | * @return bool true if `$customUrl` passes the sanity check | |
239 | * @since 3.0 | |
240 | */ | |
0ca2dd42 | 241 | public static function isValidCustomUrl($customUrl): bool |
a9229942 TD |
242 | { |
243 | return \preg_match('~^[a-z0-9\-_/]+$~', $customUrl) === 1; | |
244 | } | |
245 | ||
246 | /** | |
247 | * Returns true if this is a secure connection. | |
a9229942 | 248 | */ |
0ca2dd42 | 249 | public static function secureConnection(): bool |
a9229942 TD |
250 | { |
251 | if (self::$secure === null) { | |
252 | self::$secure = false; | |
253 | ||
254 | if ( | |
255 | (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') | |
256 | || $_SERVER['SERVER_PORT'] == 443 | |
257 | || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') | |
258 | ) { | |
259 | self::$secure = true; | |
260 | } | |
261 | } | |
262 | ||
263 | return self::$secure; | |
264 | } | |
265 | ||
7554a87a AE |
266 | /** |
267 | * Returns true if the current environment is treated as a secure context by | |
268 | * browsers. | |
269 | * | |
270 | * @see https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure | |
271 | * @since 6.1 | |
272 | */ | |
273 | public static function secureContext(): bool | |
274 | { | |
275 | static $secureContext = null; | |
276 | if ($secureContext === null) { | |
277 | $secureContext = self::secureConnection(); | |
278 | ||
279 | // The connection is considered as secure if it is encrypted with | |
280 | // TLS, or if the target host is a local address. | |
281 | if (!$secureContext) { | |
282 | $host = $_SERVER['HTTP_HOST']; | |
283 | ||
284 | // @see https://datatracker.ietf.org/doc/html/draft-ietf-dnsop-let-localhost-be-localhost-02 | |
285 | if ($host === '127.0.0.1' || $host === 'localhost' || \str_ends_with($host, '.localhost')) { | |
286 | $secureContext = true; | |
287 | } | |
288 | } | |
289 | } | |
290 | ||
291 | return $secureContext; | |
292 | } | |
293 | ||
a9229942 TD |
294 | /** |
295 | * Returns HTTP protocol, either 'http://' or 'https://'. | |
a9229942 | 296 | */ |
0ca2dd42 | 297 | public static function getProtocol(): string |
a9229942 TD |
298 | { |
299 | if (empty(self::$protocol)) { | |
300 | self::$protocol = 'http' . (self::secureConnection() ? 's' : '') . '://'; | |
301 | } | |
302 | ||
303 | return self::$protocol; | |
304 | } | |
305 | ||
306 | /** | |
307 | * Returns protocol and domain name. | |
a9229942 | 308 | */ |
0ca2dd42 | 309 | public static function getHost(): string |
a9229942 TD |
310 | { |
311 | if (empty(self::$host)) { | |
312 | self::$host = self::getProtocol() . $_SERVER['HTTP_HOST']; | |
313 | } | |
314 | ||
315 | return self::$host; | |
316 | } | |
317 | ||
318 | /** | |
319 | * Returns absolute domain path. | |
a9229942 | 320 | */ |
0ca2dd42 | 321 | public static function getPath(array $removeComponents = []): string |
a9229942 TD |
322 | { |
323 | if (empty(self::$path)) { | |
324 | // dirname return a single backslash on Windows if there are no parent directories | |
325 | $dir = \dirname($_SERVER['SCRIPT_NAME']); | |
326 | self::$path = ($dir === '\\') ? '/' : FileUtil::addTrailingSlash($dir); | |
327 | } | |
328 | ||
329 | if (!empty($removeComponents)) { | |
330 | $path = \explode('/', self::$path); | |
331 | foreach ($path as $index => $component) { | |
332 | if (empty($path[$index])) { | |
333 | unset($path[$index]); | |
334 | } | |
335 | ||
336 | if (\in_array($component, $removeComponents)) { | |
337 | unset($path[$index]); | |
338 | } | |
339 | } | |
340 | ||
341 | return FileUtil::addTrailingSlash('/' . \implode('/', $path)); | |
342 | } | |
343 | ||
344 | return self::$path; | |
345 | } | |
346 | ||
347 | /** | |
348 | * Returns current path info component. | |
a9229942 | 349 | */ |
0ca2dd42 | 350 | public static function getPathInfo(): string |
a9229942 TD |
351 | { |
352 | if (self::$pathInfo === null) { | |
353 | self::$pathInfo = ''; | |
354 | ||
355 | if (!empty($_SERVER['QUERY_STRING'])) { | |
356 | // don't use parse_str as it replaces dots with underscores | |
357 | $components = \explode('&', $_SERVER['QUERY_STRING']); | |
358 | for ($i = 0, $length = \count($components); $i < $length; $i++) { | |
359 | $component = $components[$i]; | |
360 | ||
361 | $pos = \mb_strpos($component, '='); | |
362 | if ($pos !== false && $pos + 1 === \mb_strlen($component)) { | |
363 | $component = \mb_substr($component, 0, -1); | |
364 | $pos = false; | |
365 | } | |
366 | ||
367 | if ($pos === false) { | |
368 | self::$pathInfo = \urldecode($component); | |
369 | break; | |
370 | } | |
371 | } | |
372 | } | |
373 | ||
374 | // translate legacy controller names | |
375 | if (\preg_match('~^(?P<controller>(?:[A-Z]+[a-z0-9]+)+)(?:/|$)~', self::$pathInfo, $matches)) { | |
376 | $parts = \preg_split( | |
377 | '~([A-Z]+[a-z0-9]+)~', | |
378 | $matches['controller'], | |
379 | -1, | |
380 | \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY | |
381 | ); | |
382 | $parts = \array_map('strtolower', $parts); | |
383 | ||
384 | self::$pathInfo = \implode('-', $parts) . \mb_substr( | |
385 | self::$pathInfo, | |
386 | \mb_strlen($matches['controller']) | |
387 | ); | |
388 | } | |
389 | } | |
390 | ||
391 | return self::$pathInfo; | |
392 | } | |
d71e5a29 | 393 | } |