Merge pull request #6006 from WoltLab/file-processor-can-adopt
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / request / RouteHandler.class.php
CommitLineData
d71e5a29 1<?php
a9229942 2
d71e5a29 3namespace wcf\system\request;
a9229942 4
d2864e2b 5use wcf\system\event\EventHandler;
6b51ca9c 6use wcf\system\exception\SystemException;
e3530f10 7use wcf\system\request\route\DynamicRequestRoute;
7faef021 8use wcf\system\request\route\IRequestRoute;
2d38f346 9use wcf\system\request\route\LookupRequestRoute;
d71e5a29 10use wcf\system\SingletonFactory;
3dcfb497 11use 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 23final 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}