Commit | Line | Data |
---|---|---|
b9f49efd | 1 | <?php |
a9229942 | 2 | |
b9f49efd | 3 | namespace wcf\system\request; |
a9229942 | 4 | |
3295fb92 | 5 | use wcf\page\CmsPage; |
c2de61fb | 6 | use wcf\system\cache\builder\RoutingCacheBuilder; |
b9f49efd | 7 | use wcf\system\exception\SystemException; |
849e943d | 8 | use wcf\system\language\LanguageFactory; |
f341086b | 9 | use wcf\system\SingletonFactory; |
39abe192 | 10 | use wcf\system\WCF; |
ed73f35d | 11 | use 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 |
22 | class 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 | } |