3 namespace wcf\system\request
;
6 use wcf\system\cache\builder\RoutingCacheBuilder
;
7 use wcf\system\exception\SystemException
;
8 use wcf\system\language\LanguageFactory
;
9 use wcf\system\SingletonFactory
;
11 use wcf\system\WCFACP
;
14 * Resolves incoming requests and performs lookups for controller to url mappings.
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
22 class ControllerMap
extends SingletonFactory
28 protected $applicationOverrides;
33 protected $ciControllers;
38 protected $customUrls;
43 protected $landingPages;
46 * list of <ControllerName> to <controller-name> mappings
49 protected $lookupCache = [];
54 protected function init()
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');
63 * Resolves class data for given controller.
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
74 public function resolve($application, $controller, $isAcpRequest, $skipCustomUrls = false)
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 . "'");
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);
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';
100 // work-around for package installation during the upgrade 2.1 -> 3.0
101 if ($isAcpRequest && $controller === 'InstallPackage') {
102 $application = 'wcf';
105 // Map virtual controllers to their true application
106 if (isset($this->applicationOverrides
['lookup'][$application][$controller])) {
107 $application = $this->applicationOverrides
['lookup'][$application][$controller];
110 $classData = $this->getClassData($application, $controller, $isAcpRequest, 'page');
111 if ($classData === null) {
112 $classData = $this->getClassData($application, $controller, $isAcpRequest, 'form');
114 if ($classData === null) {
115 $classData = $this->getClassData($application, $controller, $isAcpRequest, 'action');
119 if ($classData === null) {
120 throw new SystemException("Unknown controller '" . $controller . "'");
122 // the ACP does not support custom urls at all
124 $skipCustomUrls = true;
127 if (!$skipCustomUrls) {
128 // handle controllers with a custom url
129 $controller = $classData['controller'];
132 isset($this->customUrls
['reverse'][$application])
133 && isset($this->customUrls
['reverse'][$application][$controller])
135 return $this->customUrls
['reverse'][$application][$controller];
136 } elseif ($application !== 'wcf') {
138 isset($this->customUrls
['reverse']['wcf'])
139 && isset($this->customUrls
['reverse']['wcf'][$controller])
141 return $this->customUrls
['reverse']['wcf'][$controller];
151 * Attempts to resolve a custom controller, will return an empty array
152 * regardless if given controller would match an actual controller class.
156 * @param string $application application identifier
157 * @param string $controller url controller
158 * @return array empty array if there is no exact match
160 public function resolveCustomController($application, $controller)
162 if (isset($this->applicationOverrides
['lookup'][$application][$controller])) {
163 $application = $this->applicationOverrides
['lookup'][$application][$controller];
167 isset($this->customUrls
['lookup'][$application])
168 && isset($this->customUrls
['lookup'][$application][$controller])
170 $data = $this->customUrls
['lookup'][$application][$controller];
171 if (\
preg_match('~^__WCF_CMS__(?P<pageID>\d+)-(?P<languageID>\d+)$~', $data, $matches)) {
173 'className' => CmsPage
::class,
174 'controller' => 'cms',
175 'pageType' => 'page',
177 // CMS page meta data
178 'cmsPageID' => $matches['pageID'],
179 'cmsPageLanguageID' => $matches['languageID'],
182 \
preg_match('~([^\\\]+)(Action|Form|Page)$~', $data, $matches);
185 'className' => $data,
186 'controller' => $matches[1],
187 'pageType' => \
strtolower($matches[2]),
196 * Transforms given controller into its url representation.
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'
205 public function lookup($application, $controller, $forceFrontend = null)
207 if ($forceFrontend === null) {
208 $forceFrontend = !\
class_exists(WCFACP
::class, false);
211 $lookupKey = ($forceFrontend ?
'' : 'acp-') . $application . '-' . $controller;
213 if (isset($this->lookupCache
[$lookupKey])) {
214 return $this->lookupCache
[$lookupKey];
219 && isset($this->customUrls
['reverse'][$application])
220 && isset($this->customUrls
['reverse'][$application][$controller])
222 $urlController = $this->customUrls
['reverse'][$application][$controller];
224 $urlController = self
::transformController($controller);
227 $this->lookupCache
[$lookupKey] = $urlController;
229 return $urlController;
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.
236 * @param int $pageID page id
237 * @param int $languageID content language id
238 * @return string[]|null
240 public function lookupCmsPage($pageID, $languageID)
242 $key = '__WCF_CMS__' . $pageID . '-' . ($languageID ?
: 0);
243 foreach ($this->customUrls
['reverse'] as $application => $reverseURLs) {
244 if (isset($reverseURLs[$key])) {
246 'application' => $application,
247 'controller' => $reverseURLs[$key],
256 * Lookups default controller for given application.
258 * @param string $application application identifier
259 * @return null|string[] default controller
260 * @throws SystemException
262 public function lookupDefaultController($application)
264 $data = $this->landingPages
[$application];
265 $controller = $data[1];
267 if ($application === 'wcf' && empty($controller)) {
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
276 // use a reverse search to find the page
278 isset($this->customUrls
['lookup']['wcf'])
279 && isset($this->customUrls
['lookup']['wcf'][''])
281 '~^__WCF_CMS__\d+\-(?P<languageID>\d+)$~',
282 $this->customUrls
['lookup']['wcf'][''],
286 $languageID = $match['languageID'];
289 if ($languageID === null) {
290 // something went wrong, use the current language id
291 $languageID = WCF
::getLanguage()->languageID
;
294 $cmsPageData = $this->lookupCmsPage($matches['pageID'], $languageID);
295 if ($cmsPageData === null) {
296 throw new SystemException("Unable to resolve CMS page");
300 // different application, redirect instead
302 $cmsPageData['application'] !== $application
303 && $this->getApplicationOverride($application, $cmsPageData['controller']) !== $application
305 return ['redirect' => LinkHandler
::getInstance()->getCmsLink($matches['pageID'])];
307 return $this->resolveCustomController($cmsPageData['application'], $cmsPageData['controller']);
312 'application' => \
mb_substr($data[2], 0, \
mb_strpos($data[2], '\\')),
313 'controller' => $controller,
318 * Returns true if given controller is the application's default.
320 * @param string $application application identifier
321 * @param string $controller url controller name
322 * @return bool true if controller is the application's default
324 public function isDefaultController($application, $controller)
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)) {
331 $matches['languageID']
332 && $matches['languageID'] != LanguageFactory
::getInstance()->getDefaultLanguageID()
337 $matches['controller'] == $this->landingPages
[$application][0]
338 && isset($this->customUrls
['lookup'][$application][''])
339 && $this->customUrls
['lookup'][$application][''] !== $controller
344 $controller = $matches['controller'];
348 if (\
strpos($controller, '__WCF_CMS__') !== false) {
349 // remove language id component
350 $controller = \
preg_replace('~\-\d+$~', '', $controller);
354 if ($this->landingPages
[$application][0] === $controller) {
362 * Returns true if currently active request represents the landing page.
364 * @param string[] $classData
365 * @param array $metaData
368 public function isLandingPage(array $classData, array $metaData)
370 if ($classData['className'] !== $this->landingPages
['wcf'][2]) {
374 if ($classData['className'] === CmsPage
::class) {
375 // check if page id matches
376 if ($this->landingPages
['wcf'][1] !== '__WCF_CMS__' . $metaData['cms']['pageID']) {
385 * Returns the virtual application abbreviation for the provided controller.
387 * @param string $application
388 * @param string $controller
391 public function getApplicationOverride($application, $controller)
393 if (isset($this->applicationOverrides
['reverse'][$application][$controller])) {
394 return $this->applicationOverrides
['reverse'][$application][$controller];
401 * Lookups the list of legacy controller names that violate the name
402 * schema, e.g. are named 'BBCodeList' instead of `BbCodeList`.
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
409 protected function getLegacyClassData($application, $controller, $isAcpRequest)
411 $environment = $isAcpRequest ?
'acp' : 'frontend';
412 if (isset($this->ciControllers
['lookup'][$application][$environment][$controller])) {
413 $className = $this->ciControllers
['lookup'][$application][$environment][$controller];
415 if (\
preg_match('~\\\\(?P<controller>[^\\\\]+)(?P<pageType>Action|Form|Page)$~', $className, $matches)) {
417 'className' => $className,
418 'controller' => $matches['controller'],
419 'pageType' => \
strtolower($matches['pageType']),
428 * Returns the class data for the active request or `null` if no proper class exists
429 * for the given configuration.
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
437 protected function getClassData($application, $controller, $isAcpRequest, $pageType)
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)) {
452 // check for abstract classes
453 $reflectionClass = new \
ReflectionClass($className);
454 if ($reflectionClass->isAbstract()) {
459 'className' => $className,
460 'controller' => $controller,
461 'pageType' => $pageType,
466 * Transforms a controller into its URL representation.
468 * @param string $controller controller, e.g. 'BoardList'
469 * @return string url representation, e.g. 'board-list'
471 public static function transformController($controller)
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]+)~',
479 \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY
482 // fix for invalid pages that would cause single character fragments
483 $sanitizedParts = [];
485 foreach ($parts as $part) {
486 if (\
strlen($part) === 1) {
491 $sanitizedParts[] = $tmp . $part;
495 $sanitizedParts[] = $tmp;
497 $parts = $sanitizedParts;
499 $parts = \
preg_split(
500 '~([A-Z][a-z0-9]+)~',
503 \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY
507 $parts = \array_map
('strtolower', $parts);
509 return \
implode('-', $parts);