Add explicit `return null;` statements
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / request / ControllerMap.class.php
CommitLineData
b9f49efd 1<?php
a9229942 2
b9f49efd 3namespace wcf\system\request;
a9229942 4
3295fb92 5use wcf\page\CmsPage;
c2de61fb 6use wcf\system\cache\builder\RoutingCacheBuilder;
b9f49efd 7use wcf\system\exception\SystemException;
849e943d 8use wcf\system\language\LanguageFactory;
f341086b 9use wcf\system\SingletonFactory;
39abe192 10use wcf\system\WCF;
ed73f35d 11use wcf\system\WCFACP;
b9f49efd
AE
12
13/**
14 * Resolves incoming requests and performs lookups for controller to url mappings.
a9229942
TD
15 *
16 * @author Alexander Ebert
17 * @copyright 2001-2019 WoltLab GmbH
18 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
19 * @package WoltLabSuite\Core\System\Request
20 * @since 3.0
b9f49efd 21 */
a9229942
TD
22class ControllerMap extends SingletonFactory
23{
24 /**
25 * @var array
26 * @since 5.2
27 */
28 protected $applicationOverrides;
29
30 /**
31 * @var array
32 */
33 protected $ciControllers;
34
35 /**
36 * @var array
37 */
38 protected $customUrls;
39
40 /**
41 * @var string[]
42 */
43 protected $landingPages;
44
45 /**
46 * list of <ControllerName> to <controller-name> mappings
47 * @var string[]
48 */
49 protected $lookupCache = [];
50
51 /**
52 * @inheritDoc
53 */
54 protected function init()
55 {
56 $this->applicationOverrides = RoutingCacheBuilder::getInstance()->getData([], 'applicationOverrides');
57 $this->ciControllers = RoutingCacheBuilder::getInstance()->getData([], 'ciControllers');
58 $this->customUrls = RoutingCacheBuilder::getInstance()->getData([], 'customUrls');
59 $this->landingPages = RoutingCacheBuilder::getInstance()->getData([], 'landingPages');
60 }
61
62 /**
63 * Resolves class data for given controller.
64 *
65 * URL -> Controller
66 *
67 * @param string $application application identifier
68 * @param string $controller url controller
69 * @param bool $isAcpRequest true if this is an ACP request
70 * @param bool $skipCustomUrls true if custom url resolution should be suppressed, is always true for ACP requests
71 * @return mixed array containing className, controller and pageType or a string containing the controller name for aliased controllers
72 * @throws SystemException
73 */
74 public function resolve($application, $controller, $isAcpRequest, $skipCustomUrls = false)
75 {
76 // validate controller
77 if (!\preg_match('~^[a-z][a-z0-9]+(?:\-[a-z][a-z0-9]+)*$~', $controller)) {
78 throw new SystemException("Malformed controller name '" . $controller . "'");
79 }
80
81 $classData = $this->getLegacyClassData($application, $controller, $isAcpRequest);
82 if ($classData === null) {
83 $parts = \explode('-', $controller);
84 $parts = \array_map('ucfirst', $parts);
85 $controller = \implode('', $parts);
86
87 // work-around for legacy action controllers for upgrade and CORS avoidance
88 if ($controller === 'AjaxProxy') {
89 $controller = 'AJAXProxy';
90 } elseif ($controller === 'AjaxUpload') {
91 $controller = 'AJAXUpload';
92 } elseif ($controller === 'AjaxInvoke') {
93 $controller = 'AJAXInvoke';
94 } elseif ($controller === 'AjaxFileUpload') {
95 $controller = 'AJAXFileUpload';
96 } elseif ($controller === 'AjaxFileDelete') {
97 $controller = 'AJAXFileDelete';
98 }
99
100 // work-around for package installation during the upgrade 2.1 -> 3.0
101 if ($isAcpRequest && $controller === 'InstallPackage') {
102 $application = 'wcf';
103 }
104
105 // Map virtual controllers to their true application
106 if (isset($this->applicationOverrides['lookup'][$application][$controller])) {
107 $application = $this->applicationOverrides['lookup'][$application][$controller];
108 }
109
110 $classData = $this->getClassData($application, $controller, $isAcpRequest, 'page');
111 if ($classData === null) {
112 $classData = $this->getClassData($application, $controller, $isAcpRequest, 'form');
113 }
114 if ($classData === null) {
115 $classData = $this->getClassData($application, $controller, $isAcpRequest, 'action');
116 }
117 }
118
119 if ($classData === null) {
120 throw new SystemException("Unknown controller '" . $controller . "'");
121 } else {
122 // the ACP does not support custom urls at all
123 if ($isAcpRequest) {
124 $skipCustomUrls = true;
125 }
126
127 if (!$skipCustomUrls) {
128 // handle controllers with a custom url
129 $controller = $classData['controller'];
130
131 if (
132 isset($this->customUrls['reverse'][$application])
133 && isset($this->customUrls['reverse'][$application][$controller])
134 ) {
135 return $this->customUrls['reverse'][$application][$controller];
136 } elseif ($application !== 'wcf') {
137 if (
138 isset($this->customUrls['reverse']['wcf'])
139 && isset($this->customUrls['reverse']['wcf'][$controller])
140 ) {
141 return $this->customUrls['reverse']['wcf'][$controller];
142 }
143 }
144 }
145 }
146
147 return $classData;
148 }
149
150 /**
151 * Attempts to resolve a custom controller, will return an empty array
152 * regardless if given controller would match an actual controller class.
153 *
154 * URL -> Controller
155 *
156 * @param string $application application identifier
157 * @param string $controller url controller
158 * @return array empty array if there is no exact match
159 */
160 public function resolveCustomController($application, $controller)
161 {
162 if (isset($this->applicationOverrides['lookup'][$application][$controller])) {
163 $application = $this->applicationOverrides['lookup'][$application][$controller];
164 }
165
166 if (
167 isset($this->customUrls['lookup'][$application])
168 && isset($this->customUrls['lookup'][$application][$controller])
169 ) {
170 $data = $this->customUrls['lookup'][$application][$controller];
171 if (\preg_match('~^__WCF_CMS__(?P<pageID>\d+)-(?P<languageID>\d+)$~', $data, $matches)) {
172 return [
173 'className' => CmsPage::class,
174 'controller' => 'cms',
175 'pageType' => 'page',
176
177 // CMS page meta data
178 'cmsPageID' => $matches['pageID'],
179 'cmsPageLanguageID' => $matches['languageID'],
180 ];
181 } else {
182 \preg_match('~([^\\\]+)(Action|Form|Page)$~', $data, $matches);
183
184 return [
185 'className' => $data,
186 'controller' => $matches[1],
187 'pageType' => \strtolower($matches[2]),
188 ];
189 }
190 }
191
192 return [];
193 }
194
195 /**
196 * Transforms given controller into its url representation.
197 *
198 * Controller -> URL
199 *
200 * @param string $application application identifier
201 * @param string $controller controller class, e.g. 'MembersList'
202 * @param bool $forceFrontend force transformation for frontend
203 * @return string url representation of controller, e.g. 'members-list'
204 */
205 public function lookup($application, $controller, $forceFrontend = null)
206 {
207 if ($forceFrontend === null) {
208 $forceFrontend = !\class_exists(WCFACP::class, false);
209 }
210
211 $lookupKey = ($forceFrontend ? '' : 'acp-') . $application . '-' . $controller;
212
213 if (isset($this->lookupCache[$lookupKey])) {
214 return $this->lookupCache[$lookupKey];
215 }
216
217 if (
218 $forceFrontend
219 && isset($this->customUrls['reverse'][$application])
220 && isset($this->customUrls['reverse'][$application][$controller])
221 ) {
222 $urlController = $this->customUrls['reverse'][$application][$controller];
223 } else {
224 $urlController = self::transformController($controller);
225 }
226
227 $this->lookupCache[$lookupKey] = $urlController;
228
229 return $urlController;
230 }
231
232 /**
233 * Looks up a cms page URL, returns an array containing the application identifier
234 * and url controller name or null if there was no match.
235 *
236 * @param int $pageID page id
237 * @param int $languageID content language id
238 * @return string[]|null
239 */
240 public function lookupCmsPage($pageID, $languageID)
241 {
242 $key = '__WCF_CMS__' . $pageID . '-' . ($languageID ?: 0);
243 foreach ($this->customUrls['reverse'] as $application => $reverseURLs) {
244 if (isset($reverseURLs[$key])) {
245 return [
246 'application' => $application,
247 'controller' => $reverseURLs[$key],
248 ];
249 }
250 }
5227ebc7
MS
251
252 return null;
a9229942
TD
253 }
254
255 /**
256 * Lookups default controller for given application.
257 *
258 * @param string $application application identifier
259 * @return null|string[] default controller
260 * @throws SystemException
261 */
262 public function lookupDefaultController($application)
263 {
264 $data = $this->landingPages[$application];
265 $controller = $data[1];
266
267 if ($application === 'wcf' && empty($controller)) {
c0b28aa2 268 return null;
a9229942
TD
269 } elseif (\preg_match('~^__WCF_CMS__(?P<pageID>\d+)$~', $controller, $matches)) {
270 $cmsPageData = $this->lookupCmsPage($matches['pageID'], 0);
271 if ($cmsPageData === null) {
272 // page is multilingual, use the language id that matches the URL
273 // do *not* use the client language id, Google's bot is stubborn
274
275 $languageID = null;
276 // use a reverse search to find the page
277 if (
278 isset($this->customUrls['lookup']['wcf'])
279 && isset($this->customUrls['lookup']['wcf'][''])
280 && \preg_match(
281 '~^__WCF_CMS__\d+\-(?P<languageID>\d+)$~',
282 $this->customUrls['lookup']['wcf'][''],
283 $match
284 )
285 ) {
286 $languageID = $match['languageID'];
287 }
288
289 if ($languageID === null) {
290 // something went wrong, use the current language id
291 $languageID = WCF::getLanguage()->languageID;
292 }
293
294 $cmsPageData = $this->lookupCmsPage($matches['pageID'], $languageID);
295 if ($cmsPageData === null) {
296 throw new SystemException("Unable to resolve CMS page");
297 }
298 }
299
300 // different application, redirect instead
301 if (
302 $cmsPageData['application'] !== $application
303 && $this->getApplicationOverride($application, $cmsPageData['controller']) !== $application
304 ) {
305 return ['redirect' => LinkHandler::getInstance()->getCmsLink($matches['pageID'])];
306 } else {
307 return $this->resolveCustomController($cmsPageData['application'], $cmsPageData['controller']);
308 }
309 }
310
311 return [
312 'application' => \mb_substr($data[2], 0, \mb_strpos($data[2], '\\')),
313 'controller' => $controller,
314 ];
315 }
316
317 /**
318 * Returns true if given controller is the application's default.
319 *
320 * @param string $application application identifier
321 * @param string $controller url controller name
322 * @return bool true if controller is the application's default
323 */
324 public function isDefaultController($application, $controller)
325 {
326 // lookup custom urls first
327 if (isset($this->customUrls['lookup'][$application], $this->customUrls['lookup'][$application][$controller])) {
328 $controller = $this->customUrls['lookup'][$application][$controller];
329 if (\preg_match('~^(?P<controller>__WCF_CMS__\d+)(?:-(?P<languageID>\d+))?$~', $controller, $matches)) {
330 if (
331 $matches['languageID']
332 && $matches['languageID'] != LanguageFactory::getInstance()->getDefaultLanguageID()
333 ) {
334 return false;
335 } else {
336 if (
337 $matches['controller'] == $this->landingPages[$application][0]
338 && isset($this->customUrls['lookup'][$application][''])
339 && $this->customUrls['lookup'][$application][''] !== $controller
340 ) {
341 return false;
342 }
343
344 $controller = $matches['controller'];
345 }
346 }
347
348 if (\strpos($controller, '__WCF_CMS__') !== false) {
349 // remove language id component
350 $controller = \preg_replace('~\-\d+$~', '', $controller);
351 }
352 }
353
354 if ($this->landingPages[$application][0] === $controller) {
355 return true;
356 }
357
358 return false;
359 }
360
361 /**
362 * Returns true if currently active request represents the landing page.
363 *
364 * @param string[] $classData
365 * @param array $metaData
366 * @return bool
367 */
368 public function isLandingPage(array $classData, array $metaData)
369 {
370 if ($classData['className'] !== $this->landingPages['wcf'][2]) {
371 return false;
372 }
373
374 if ($classData['className'] === CmsPage::class) {
375 // check if page id matches
376 if ($this->landingPages['wcf'][1] !== '__WCF_CMS__' . $metaData['cms']['pageID']) {
377 return false;
378 }
379 }
380
381 return true;
382 }
383
384 /**
385 * Returns the virtual application abbreviation for the provided controller.
386 *
387 * @param string $application
388 * @param string $controller
389 * @return string
390 */
391 public function getApplicationOverride($application, $controller)
392 {
393 if (isset($this->applicationOverrides['reverse'][$application][$controller])) {
394 return $this->applicationOverrides['reverse'][$application][$controller];
395 }
396
397 return $application;
398 }
399
400 /**
401 * Lookups the list of legacy controller names that violate the name
402 * schema, e.g. are named 'BBCodeList' instead of `BbCodeList`.
403 *
404 * @param string $application application identifier
405 * @param string $controller controller name
406 * @param bool $isAcpRequest true if this is an ACP request
407 * @return string[]|null className, controller and pageType, or null if this is not a legacy controller name
408 */
409 protected function getLegacyClassData($application, $controller, $isAcpRequest)
410 {
411 $environment = $isAcpRequest ? 'acp' : 'frontend';
412 if (isset($this->ciControllers['lookup'][$application][$environment][$controller])) {
413 $className = $this->ciControllers['lookup'][$application][$environment][$controller];
414
415 if (\preg_match('~\\\\(?P<controller>[^\\\\]+)(?P<pageType>Action|Form|Page)$~', $className, $matches)) {
416 return [
417 'className' => $className,
418 'controller' => $matches['controller'],
419 'pageType' => \strtolower($matches['pageType']),
420 ];
421 }
422 }
5227ebc7
MS
423
424 return null;
a9229942
TD
425 }
426
427 /**
428 * Returns the class data for the active request or `null` if no proper class exists
429 * for the given configuration.
430 *
431 * @param string $application application identifier
432 * @param string $controller controller name
433 * @param bool $isAcpRequest true if this is an ACP request
434 * @param string $pageType page type, e.g. 'form' or 'action'
435 * @return string[]|null className, controller and pageType
436 */
437 protected function getClassData($application, $controller, $isAcpRequest, $pageType)
438 {
439 $className = $application . '\\' . ($isAcpRequest ? 'acp\\' : '') . $pageType . '\\' . $controller . \ucfirst($pageType);
440 if (!\class_exists($className)) {
441 // avoid CORS by allowing action classes invoked form every application domain
442 if ($pageType === 'action' && $application !== 'wcf') {
443 $className = 'wcf\\' . ($isAcpRequest ? 'acp\\' : '') . $pageType . '\\' . $controller . \ucfirst($pageType);
444 if (!\class_exists($className)) {
c0b28aa2 445 return null;
a9229942
TD
446 }
447 } else {
c0b28aa2 448 return null;
a9229942
TD
449 }
450 }
451
452 // check for abstract classes
453 $reflectionClass = new \ReflectionClass($className);
454 if ($reflectionClass->isAbstract()) {
c0b28aa2 455 return null;
a9229942
TD
456 }
457
458 return [
459 'className' => $className,
460 'controller' => $controller,
461 'pageType' => $pageType,
462 ];
463 }
464
465 /**
466 * Transforms a controller into its URL representation.
467 *
468 * @param string $controller controller, e.g. 'BoardList'
469 * @return string url representation, e.g. 'board-list'
470 */
471 public static function transformController($controller)
472 {
473 // work-around for broken controllers that violate the strict naming rules
474 if (\preg_match('~[A-Z]{2,}~', $controller)) {
475 $parts = \preg_split(
476 '~([A-Z][a-z0-9]+)~',
477 $controller,
478 -1,
479 \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY
480 );
481
482 // fix for invalid pages that would cause single character fragments
483 $sanitizedParts = [];
484 $tmp = '';
485 foreach ($parts as $part) {
486 if (\strlen($part) === 1) {
487 $tmp .= $part;
488 continue;
489 }
490
491 $sanitizedParts[] = $tmp . $part;
492 $tmp = '';
493 }
494 if ($tmp) {
495 $sanitizedParts[] = $tmp;
496 }
497 $parts = $sanitizedParts;
498 } else {
499 $parts = \preg_split(
500 '~([A-Z][a-z0-9]+)~',
501 $controller,
502 -1,
503 \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY
504 );
505 }
506
507 $parts = \array_map('strtolower', $parts);
508
509 return \implode('-', $parts);
510 }
b9f49efd 511}