Overhauled WCF 2.1 route system
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / request / Route.class.php
CommitLineData
d71e5a29
AE
1<?php
2namespace wcf\system\request;
be1101a0 3use wcf\system\application\ApplicationHandler;
d71e5a29 4use wcf\system\exception\SystemException;
e6e54af2 5use wcf\system\menu\page\PageMenu;
be1101a0 6use wcf\system\WCF;
d71e5a29
AE
7
8/**
9 * Route implementation to resolve HTTP requests.
10 *
11 * Inspired by routing mechanism used by ASP.NET MVC and released under the terms of
12 * the Microsoft Public License (MS-PL) http://www.opensource.org/licenses/ms-pl.html
13 *
9f959ced 14 * @author Alexander Ebert
f3aa5021 15 * @copyright 2001-2015 WoltLab GmbH
d71e5a29
AE
16 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
17 * @package com.woltlab.wcf
18 * @subpackage system.request
9f959ced 19 * @category Community Framework
d71e5a29 20 */
f3aa5021 21class Route implements IRoute {
2a74eea2 22 /**
3df09842 23 * route controller if controller is no part of the route schema
2a74eea2
MS
24 * @var string
25 */
26 protected $controller = null;
27
d71e5a29
AE
28 /**
29 * route is restricted to ACP
30 * @var boolean
31 */
32 protected $isACP = false;
33
34 /**
35 * schema component options
36 * @var array
37 */
38 protected $parameterOptions = array();
39
40 /**
41 * route name
42 * @var string
43 */
44 protected $routeName = '';
45
46 /**
47 * route schema data
48 * @var array
49 */
50 protected $routeSchema = array();
51
52 /**
53 * parsed route data
54 * @var array
55 */
56 protected $routeData = null;
57
be1101a0
AE
58 /**
59 * list of application abbreviation and default controller name
60 * @var array<string>
61 */
62 protected static $defaultControllers = null;
63
d71e5a29
AE
64 /**
65 * Creates a new route.
66 *
67 * @param string $routeName
68 * @param boolean $isACP
69 */
70 public function __construct($routeName, $isACP = false) {
71 $this->isACP = $isACP;
72 $this->routeName = $routeName;
73 }
74
75 /**
3df09842 76 * Sets route schema, e.g. /{controller}/{id}.
d71e5a29
AE
77 *
78 * @param string $routeSchema
2a74eea2 79 * @param string $controller
d71e5a29 80 */
2a74eea2 81 public function setSchema($routeSchema, $controller = null) {
d71e5a29
AE
82 $schemaParts = $this->getParts($routeSchema);
83 $hasController = false;
84 $pattern = '~^{[a-zA-Z]+}$~';
2a74eea2
MS
85
86 if ($controller !== null) {
87 $this->controller = $controller;
88 $hasController = true;
89 }
90
d71e5a29
AE
91 foreach ($schemaParts as &$part) {
92 if (!preg_match($pattern, $part)) {
93 throw new SystemException("Placeholder expected, but invalid string '" . $part . "' given.");
94 }
95
96 $part = str_replace(array('{', '}'), '', $part);
97 if ($part == 'controller') {
2a74eea2 98 if ($this->controller !== null) {
3df09842 99 throw new SystemException('Controller may not be part of the scheme if a route controller is given.');
2a74eea2 100 }
3df09842
MS
101
102 $hasController = true;
d71e5a29
AE
103 }
104 }
105
106 // each route must define a controller
107 if (!$hasController) {
108 throw new SystemException('Route schema does not provide a valid placeholder for controller.');
109 }
110
111 $this->routeSchema = $schemaParts;
112 }
113
114 /**
115 * Sets options for a route parameter.
116 *
117 * @param string $key
118 * @param string $default
119 * @param string $regexPattern
120 * @param boolean $isOptional
121 */
122 public function setParameterOption($key, $default = null, $regexPattern = null, $isOptional = false) {
d71e5a29
AE
123 $this->parameterOptions[$key] = array(
124 'default' => $default,
125 'isOptional' => $isOptional,
126 'regexPattern' => $regexPattern
127 );
128 }
129
130 /**
f3aa5021 131 * @see \wcf\system\request\IRoute::matches()
d71e5a29
AE
132 */
133 public function matches($requestURL) {
134 $urlParts = $this->getParts($requestURL);
135 $data = array();
136
137 // handle each route schema component
138 for ($i = 0, $size = count($this->routeSchema); $i < $size; $i++) {
139 $schemaPart = $this->routeSchema[$i];
140
141 if (isset($urlParts[$i])) {
142 if (isset($this->parameterOptions[$schemaPart])) {
143 // validate parameter against a regex pattern
144 if ($this->parameterOptions[$schemaPart]['regexPattern'] !== null) {
145 $pattern = '~^' . $this->parameterOptions[$schemaPart]['regexPattern'] . '$~';
146 if (!preg_match($pattern, $urlParts[$i])) {
147 return false;
148 }
149 }
150 }
151
152 // url component passed previous validation
153 $data[$schemaPart] = $urlParts[$i];
154 }
155 else {
156 if (isset($this->parameterOptions[$schemaPart])) {
157 // default value is provided
158 if ($this->parameterOptions[$schemaPart]['default'] !== null) {
b07d3301
AE
159 if ($schemaPart == 'controller') {
160 $data['isDefaultController'] = true;
161 }
162
d71e5a29
AE
163 $data[$schemaPart] = $this->parameterOptions[$schemaPart]['default'];
164 continue;
165 }
166
167 // required parameter is missing
168 if (!$this->parameterOptions[$schemaPart]['isOptional']) {
169 return false;
170 }
171 }
172 }
173 }
174
b07d3301 175 if (!isset($data['isDefaultController'])) {
5bded211 176 $data['isDefaultController'] = false;
b07d3301
AE
177 }
178
d71e5a29 179 $this->routeData = $data;
2a74eea2 180
3df09842 181 // adds route controller if given
2a74eea2
MS
182 if ($this->controller !== null) {
183 $this->routeData['controller'] = $this->controller;
184 }
185
5bded211
AE
186 if (!isset($this->routeData['controller'])) {
187 $this->routeData['isDefaultController'] = true;
188 }
189
d71e5a29
AE
190 return true;
191 }
192
193 /**
f3aa5021 194 * @see \wcf\system\request\IRoute::getRouteData()
d71e5a29
AE
195 */
196 public function getRouteData() {
197 return $this->routeData;
198 }
199
200 /**
201 * Returns non-empty URL components.
202 *
203 * @param string $requestURL
204 * @return array
205 */
206 protected function getParts($requestURL) {
207 $urlParts = preg_split('~(\/|\-|\_|\.)~', $requestURL);
208 foreach ($urlParts as $index => $part) {
30178358 209 if (!mb_strlen($part)) {
d71e5a29
AE
210 unset($urlParts[$index]);
211 }
212 }
213
214 // re-index parts
215 return array_values($urlParts);
216 }
217
f98e7cf2 218 /**
f3aa5021 219 * @see \wcf\system\request\IRoute::canHandle()
f98e7cf2
AE
220 */
221 public function canHandle(array $components) {
222 foreach ($this->routeSchema as $schemaPart) {
223 if (isset($components[$schemaPart])) {
224 // validate parameter against a regex pattern
225 if ($this->parameterOptions[$schemaPart]['regexPattern'] !== null) {
226 $pattern = '~^' . $this->parameterOptions[$schemaPart]['regexPattern'] . '$~';
227 if (!preg_match($pattern, $components[$schemaPart])) {
228 return false;
229 }
230 }
231 }
232 else {
233 if (isset($this->parameterOptions[$schemaPart])) {
234 // default value is provided
235 if ($this->parameterOptions[$schemaPart]['default'] !== null) {
236 continue;
237 }
238
239 // required parameter is missing
240 if (!$this->parameterOptions[$schemaPart]['isOptional']) {
241 return false;
242 }
243 }
244 }
245 }
246
247 return true;
248 }
249
250 /**
f3aa5021 251 * @see \wcf\system\request\IRoute::buildLink()
f98e7cf2
AE
252 */
253 public function buildLink(array $components) {
9bfbe274 254 $application = (isset($components['application'])) ? $components['application'] : null;
be1101a0
AE
255 self::loadDefaultControllers();
256
2ef8b744
AE
257 // drop application component to avoid being appended as query string
258 unset($components['application']);
259
6dcaf901
AE
260 $link = '';
261
262 // handle default values for controller
263 $buildRoute = true;
5bded211
AE
264 if (count($components) == 1 && isset($components['controller'])) {
265 $ignoreController = false;
6dcaf901
AE
266 if (isset($this->parameterOptions['controller']) && strcasecmp($this->parameterOptions['controller']['default'], $components['controller']) == 0) {
267 // only the controller was given and matches default, omit routing
5bded211
AE
268 $ignoreController = true;
269 }
e6e54af2 270 else if (!RequestHandler::getInstance()->isACPRequest()) {
5bded211 271 $landingPage = PageMenu::getInstance()->getLandingPage();
a97905ff 272 if ($landingPage !== null && strcasecmp($landingPage->getController(), $components['controller']) == 0) {
5bded211
AE
273 $ignoreController = true;
274 }
9bfbe274
AE
275
276 // check if this is the default controller of the requested application
277 if (!URL_LEGACY_MODE && !$ignoreController && $application !== null) {
278 if (isset(self::$defaultControllers[$application]) && self::$defaultControllers[$application] == $components['controller']) {
279 // check if this is the primary application and the landing page originates to the same application
280 $primaryApplication = ApplicationHandler::getInstance()->getPrimaryApplication();
281 $abbreviation = ApplicationHandler::getInstance()->getAbbreviation($primaryApplication->packageID);
282 if ($abbreviation != $application || $landingPage === null || $landingPage->getApplication() != 'wcf') {
283 $ignoreController = true;
284 }
285 }
be1101a0
AE
286 }
287 }
288
5bded211
AE
289 // drops controller from route
290 if ($ignoreController) {
6dcaf901 291 $buildRoute = false;
ba6982ae 292
be1101a0 293 // unset the controller, since it would otherwise be added with http_build_query()
ba6982ae 294 unset($components['controller']);
490a2ce9 295 }
6dcaf901
AE
296 }
297
298 if ($buildRoute) {
299 foreach ($this->routeSchema as $component) {
300 if (!isset($components[$component])) {
301 continue;
302 }
fd4213fd 303
97b6b000
AE
304 // handle built-in SEO
305 if ($component === 'id' && isset($components['title'])) {
306 $link .= $components[$component] . '-' . $components['title'] . '/';
307 unset($components['title']);
308 }
309 else {
310 $link .= $components[$component] . '/';
311 }
6dcaf901
AE
312 unset($components[$component]);
313 }
f98e7cf2
AE
314 }
315
7f9d94ab
AE
316 // enforce non-legacy URLs for ACP and disregard rewrite settings
317 if ($this->isACP()) {
6d61eb12
AE
318 if (!empty($link)) {
319 $link = '?' . $link;
320 }
5bded211 321 }
7f9d94ab 322 else if (!URL_OMIT_INDEX_PHP) {
be1101a0 323 $link = (URL_LEGACY_MODE ? 'index.php/' : (!empty($link) ? '?' : '')) . $link;
7f9d94ab 324 }
6dcaf901 325
f98e7cf2 326 if (!empty($components)) {
6d61eb12 327 if (strpos($link, '?') === false) $link .= '?';
d1d83d36
AE
328 else $link .= '&';
329
6d61eb12 330 $link .= http_build_query($components, '', '&');
f98e7cf2
AE
331 }
332
333 return $link;
334 }
335
d71e5a29 336 /**
f3aa5021 337 * @see \wcf\system\request\IRoute::isACP()
d71e5a29
AE
338 */
339 public function isACP() {
340 return $this->isACP;
341 }
be1101a0
AE
342
343 /**
344 * Loads the default controllers for each active application.
345 */
346 protected static function loadDefaultControllers() {
347 if (self::$defaultControllers === null) {
348 self::$defaultControllers = array();
349
350 foreach (ApplicationHandler::getInstance()->getApplications() as $application) {
22736672
MM
351 $app = WCF::getApplicationObject($application);
352
353 if (!$app) {
354 continue;
355 }
356
357 $controller = $app->getPrimaryController();
358
be1101a0
AE
359 if (!$controller) {
360 continue;
361 }
362
363 $controller = explode('\\', $controller);
364 $controllerName = preg_replace('~(Action|Form|Page)$~', '', array_pop($controller));
365 if (URL_TO_LOWERCASE) $controllerName = mb_strtolower($controllerName);
366
367 self::$defaultControllers[$controller[0]] = $controllerName;
368 }
369 }
370 }
d71e5a29 371}