From c2de61fb187cf357cd9653693a8fa7cad39ca6ef Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 28 Nov 2015 13:54:52 +0100 Subject: [PATCH] Added proper support for custom urls --- .../builder/RoutingCacheBuilder.class.php | 148 ++++++++++++++++++ .../PagePackageInstallationPlugin.class.php | 12 +- .../system/request/ControllerMap.class.php | 78 +++++++-- .../lib/system/request/LinkHandler.class.php | 2 +- .../system/request/RequestHandler.class.php | 54 ++----- .../lib/system/request/RouteHandler.class.php | 22 ++- .../route/DynamicRequestRoute.class.php | 2 +- .../route/LookupRequestRoute.class.php | 39 ++++- 8 files changed, 292 insertions(+), 65 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/cache/builder/RoutingCacheBuilder.class.php diff --git a/wcfsetup/install/files/lib/system/cache/builder/RoutingCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/RoutingCacheBuilder.class.php new file mode 100644 index 0000000000..d9dde74b38 --- /dev/null +++ b/wcfsetup/install/files/lib/system/cache/builder/RoutingCacheBuilder.class.php @@ -0,0 +1,148 @@ + + * @package com.woltlab.wcf + * @subpackage system.cache.builder + * @category Community Framework + */ +class RoutingCacheBuilder extends AbstractCacheBuilder { + /** + * @inheritDoc + */ + protected function rebuild(array $parameters) { + return [ + 'ciControllers' => $this->getCaseInsensitiveControllers(), + 'customUrls' => $this->getCustomUrls() + ]; + } + + /** + * Builds the list of controllers violating the camcel-case schema by having more than + * two consecutive upper-case letters in the name. The list is divided on an application + * and environment level to prevent any issues with controllers with the same name but + * correct spelling to be incorrectly handled. + * + * @return array + */ + protected function getCaseInsensitiveControllers() { + $data = [ + 'lookup' => [], + 'reverse' => [] + ]; + + $applications = ApplicationHandler::getInstance()->getApplications(); + $applications[1] = ApplicationHandler::getInstance()->getWCF(); + foreach ($applications as $application) { + $abbreviation = $application->getAbbreviation(); + $directory = Application::getDirectory($abbreviation); + foreach (['lib', 'lib/acp'] as $libDirectory) { + foreach (['action', 'form', 'page'] as $pageType) { + $path = $directory . $libDirectory . '/' . $pageType; + if (!is_dir($path)) { + continue; + } + + $di = new \DirectoryIterator($path); + foreach ($di as $file) { + if ($file->isDir() || $file->isDot()) { + continue; + } + + $filename = $file->getBasename('.class.php'); + + // search for files with two consecutive upper-case letters but ignore interfaces such as `IPage` + if (!preg_match('~^I[A-Z][a-z]~', $filename) && preg_match('~[A-Z]{2,}~', $filename)) { + $parts = preg_split('~([A-Z][a-z0-9]+)~', $filename, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + // drop the last part containing `Action` or `Page` + array_pop($parts); + + $ciController = implode('-', array_map('strtolower', $parts)); + $className = $abbreviation . '\\' . ($libDirectory === 'lib/acp' ? 'acp\\' : '') . $pageType . '\\' . $filename; + + if (!isset($data['lookup'][$abbreviation])) $data['lookup'][$abbreviation] = ['acp' => [], 'frontend' => []]; + $data['lookup'][$abbreviation][($libDirectory === 'lib' ? 'frontend' : 'acp')][$ciController] = $className; + $data['reverse'][$filename] = $ciController; + } + } + } + } + } + + return $data; + } + + /** + * Builds up a lookup and a reverse lookup list per application in order to resolve + * custom page mappings. + * + * @return array + */ + protected function getCustomUrls() { + $data = [ + 'lookup' => [], + 'reverse' => [] + ]; + + // fetch pages with a controller and a custom url + $sql = "SELECT controller, controllerCustomURL, packageID + FROM wcf".WCF_N."_page + WHERE controller <> '' + AND controllerCustomURL <> ''"; + $statement = WCF::getDB()->prepareStatement($sql); + $statement->execute(); + $rows = []; + while ($row = $statement->fetchArray()) { + $rows[] = $row; + } + + // fetch content pages using the common page controller + $sql = "SELECT page_content.customURL AS controllerCustomURL, page_content.pageID, page_content.languageID, page.packageID + FROM wcf".WCF_N."_page_content page_content + LEFT JOIN wcf".WCF_N."_page page + ON (page.pageID = page_content.pageID)"; + $statement = WCF::getDB()->prepareStatement($sql); + $statement->execute(); + while ($row = $statement->fetchArray()) { + $rows[] = $row; + } + + $abbreviations = []; + foreach ($rows as $row) { + $customUrl = FileUtil::removeLeadingSlash(FileUtil::removeTrailingSlash($row['controllerCustomURL'])); + $packageID = $row['packageID']; + if (!isset($abbreviations[$packageID])) { + $abbreviations[$packageID] = ApplicationHandler::getInstance()->getAbbreviation($packageID); + } + + if (!isset($data['lookup'][$abbreviations[$packageID]])) { + $data['lookup'][$abbreviations[$packageID]] = []; + $data['reverse'][$abbreviations[$packageID]] = []; + } + + if (isset($row['controller'])) { + $data['lookup'][$abbreviations[$packageID]][$customUrl] = $row['controller']; + $data['reverse'][$abbreviations[$packageID]][preg_replace('~^.*?([A-Za-z0-9]+)(?:Action|Form|Page)~', '$1', $row['controller'])] = $customUrl; + } + else { + $cmsIdentifier = '__WCF_CMS__' . $row['pageID'] . '-' . $row['languageID']; + $data['lookup'][$abbreviations[$packageID]][$customUrl] = $cmsIdentifier; + $data['reverse'][$abbreviations[$packageID]][$cmsIdentifier] = $customUrl; + } + + + } + + return $data; + } +} diff --git a/wcfsetup/install/files/lib/system/package/plugin/PagePackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/PagePackageInstallationPlugin.class.php index 98806f9288..22c9c6b3c4 100644 --- a/wcfsetup/install/files/lib/system/package/plugin/PagePackageInstallationPlugin.class.php +++ b/wcfsetup/install/files/lib/system/package/plugin/PagePackageInstallationPlugin.class.php @@ -3,6 +3,7 @@ namespace wcf\system\package\plugin; use wcf\data\page\PageEditor; use wcf\system\exception\SystemException; use wcf\system\language\LanguageFactory; +use wcf\system\request\RouteHandler; use wcf\system\WCF; use wcf\util\StringUtil; @@ -91,6 +92,10 @@ class PagePackageInstallationPlugin extends AbstractXMLPackageInstallationPlugin $content = []; foreach ($data['elements']['content'] as $language => $contentData) { + if (!RouteHandler::isValidCustomUrl($contentData['customurl'])) { + throw new SystemException("Invalid custom url for page content '" . $language . "', page identifier '" . $data['attributes']['name'] . "'"); + } + $content[$language] = [ 'content' => $contentData['content'], 'customURL' => $contentData['customurl'], @@ -133,10 +138,15 @@ class PagePackageInstallationPlugin extends AbstractXMLPackageInstallationPlugin $parentPageID = $row['pageID']; } + $customUrl = ($isStatic || empty($data['elements']['customurl'])) ? '' : $data['elements']['customurl']; + if ($customUrl && !RouteHandler::isValidCustomUrl($customUrl)) { + throw new SystemException("Invalid custom url for page identifier '" . $data['attributes']['name'] . "'"); + } + return [ 'content' => ($isStatic) ? $data['elements']['content'] : [], 'controller' => ($isStatic) ? '' : $data['elements']['controller'], - 'controllerCustomURL' => ($isStatic || empty($data['elements']['customurl'])) ? '' : $data['elements']['customurl'], + 'controllerCustomURL' => $customUrl, 'displayName' => $displayName, 'name' => $data['attributes']['name'], 'parentPageID' => $parentPageID diff --git a/wcfsetup/install/files/lib/system/request/ControllerMap.class.php b/wcfsetup/install/files/lib/system/request/ControllerMap.class.php index 4184a5b13d..3a7a662c1f 100644 --- a/wcfsetup/install/files/lib/system/request/ControllerMap.class.php +++ b/wcfsetup/install/files/lib/system/request/ControllerMap.class.php @@ -1,7 +1,9 @@ to mappings * @var array @@ -21,16 +27,19 @@ class ControllerMap extends SingletonFactory { protected $lookupCache = []; protected function init() { - // TODO: initialize custom controller mappings + $this->ciControllers = RoutingCacheBuilder::getInstance()->getData([], 'ciControllers'); + $this->customUrls = RoutingCacheBuilder::getInstance()->getData([], 'customUrls'); } /** * Resolves class data for given controller. * + * URL -> Controller + * * @param string $application application identifier * @param string $controller url controller * @param boolean $isAcpRequest true if this is an ACP request - * @return array className, controller and pageType + * @return mixed array containing className, controller and pageType or a string containing the controller name for aliased controllers * @throws SystemException */ public function resolve($application, $controller, $isAcpRequest) { @@ -50,12 +59,20 @@ class ControllerMap extends SingletonFactory { if ($classData === null) $classData = $this->getClassData($application, $controller, $isAcpRequest, 'action'); if ($classData === null) { - // TODO: check custom controller mappings - throw new SystemException("Unknown controller '" . $controller . "'"); } else { - // TODO: check if controller was aliased and force a redirect + // handle controllers with a custom url + $controller = $classData['controller']; + + if (isset($this->customUrls['reverse'][$application]) && isset($this->customUrls['reverse'][$application][$controller])) { + return $this->customUrls['reverse'][$application][$controller]; + } + else if ($application !== 'wcf') { + if (isset($this->customUrls['reverse']['wcf']) && isset($this->customUrls['reverse']['wcf'][$controller])) { + return $this->customUrls['reverse']['wcf'][$controller]; + } + } } return $classData; @@ -65,12 +82,33 @@ class ControllerMap extends SingletonFactory { * Attempts to resolve a custom controller, will return an empty array * regardless if given controller would match an actual controller class. * + * URL -> Controller + * * @param string $application application identifier * @param string $controller url controller * @return array empty array if there is no exact match */ public function resolveCustomController($application, $controller) { - // TODO: check custom controller mappings + if (isset($this->customUrls['lookup'][$application]) && isset($this->customUrls['lookup'][$application][$controller])) { + $data = $this->customUrls['lookup'][$application][$controller]; + if (preg_match('~^__WCF_CMS__(?P\d+)-(?P\d+)$~', $data, $matches)) { + // TODO: this does not work, it should match the returned array below + return [ + 'controller' => '\\wcf\\page\\CmsPage', + 'languageID' => $matches['languageID'], + 'pageID' => $matches['pageID'] + ]; + } + else { + preg_match('~([^\\\]+)(Action|Form|Page)$~', $data, $matches); + + return [ + 'className' => $data, + 'controller' => $matches[1], + 'pageType' => strtolower($matches[2]) + ]; + } + } return []; } @@ -78,22 +116,30 @@ class ControllerMap extends SingletonFactory { /** * Transforms given controller into its url representation. * + * Controller -> URL + * + * @param string $application application identifier * @param string $controller controller class, e.g. 'MembersList' * @return string url representation of controller, e.g. 'members-list' */ - public function lookup($controller) { - if (isset($this->lookupCache[$controller])) { - return $this->lookupCache[$controller]; - } - - $parts = preg_split('~([A-Z][a-z0-9]+)~', $controller, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - $parts = array_map('strtolower', $parts); + public function lookup($application, $controller) { + $lookupKey = $application . '-' . $controller; - $urlController = implode('-', $parts); + if (isset($this->lookupCache[$lookupKey])) { + return $this->lookupCache[$lookupKey]; + } - // TODO: lookup custom controller mappings + if (isset($this->customUrls['reverse'][$application]) && isset($this->customUrls['reverse'][$application][$controller])) { + $urlController = $this->customUrls['reverse'][$application][$controller]; + } + else { + $parts = preg_split('~([A-Z][a-z0-9]+)~', $controller, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + $parts = array_map('strtolower', $parts); + + $urlController = implode('-', $parts); + } - $this->lookupCache[$controller] = $urlController; + $this->lookupCache[$lookupKey] = $urlController; return $urlController; } diff --git a/wcfsetup/install/files/lib/system/request/LinkHandler.class.php b/wcfsetup/install/files/lib/system/request/LinkHandler.class.php index dc1e07e16e..0b53908a16 100644 --- a/wcfsetup/install/files/lib/system/request/LinkHandler.class.php +++ b/wcfsetup/install/files/lib/system/request/LinkHandler.class.php @@ -163,7 +163,7 @@ class LinkHandler extends SingletonFactory { } $parameters['controller'] = $controller; - $routeURL = RouteHandler::getInstance()->buildRoute($parameters, $isACP); + $routeURL = RouteHandler::getInstance()->buildRoute($abbreviation, $parameters, $isACP); if (!$isRaw && !empty($url)) { $routeURL .= (strpos($routeURL, '?') === false) ? '?' : '&'; } diff --git a/wcfsetup/install/files/lib/system/request/RequestHandler.class.php b/wcfsetup/install/files/lib/system/request/RequestHandler.class.php index b09b857ee9..c30085a1f5 100644 --- a/wcfsetup/install/files/lib/system/request/RequestHandler.class.php +++ b/wcfsetup/install/files/lib/system/request/RequestHandler.class.php @@ -145,31 +145,6 @@ class RequestHandler extends SingletonFactory { exit; } } - - // handle controller aliasing - /* - if (empty($routeData['isImplicitController']) && isset($routeData['controller'])) { - $ciController = mb_strtolower($routeData['controller']); - - // aliased controller, redirect to new URL - $alias = $this->getAliasByController($ciController); - if ($alias !== null) { - $this->redirect($routeData, $application); - } - - $controller = $this->getControllerByAlias($ciController); - if ($controller !== null) { - // check if controller was provided explicitly as it should - $alias = $this->getAliasByController($controller); - if ($alias != $routeData['controller']) { - $routeData['controller'] = $controller; - $this->redirect($routeData, $application); - } - - $routeData['controller'] = $controller; - } - } - */ } else if (empty($routeData['controller'])) { $routeData['controller'] = 'index'; @@ -177,20 +152,23 @@ class RequestHandler extends SingletonFactory { $controller = $routeData['controller']; - $classData = ControllerMap::getInstance()->resolve($application, $controller, $this->isACPRequest()); - - // check if controller was provided exactly as it should - /* - if (!URL_LEGACY_MODE && !$this->isACPRequest()) { - if (preg_match('~([A-Za-z0-9]+)(?:Action|Form|Page)$~', $classData['className'], $matches)) { - $realController = self::getTokenizedController($matches[1]); - - if ($controller != $realController) { - $this->redirect($routeData, $application, $matches[1]); - } + if (isset($routeData['className'])) { + $classData = [ + 'className' => $routeData['className'], + 'controller' => $routeData['controller'], + 'pageType' => $routeData['pageType'] + ]; + + unset($routeData['className']); + unset($routeData['controller']); + unset($routeData['pageType']); + } + else { + $classData = ControllerMap::getInstance()->resolve($application, $controller, $this->isACPRequest()); + if (is_string($classData)) { + $this->redirect($routeData, $application, $classData); } } - */ $this->activeRequest = new Request($classData['className'], $classData['controller'], $classData['pageType']); } @@ -251,7 +229,7 @@ class RequestHandler extends SingletonFactory { // check if currently invoked application matches the landing page if ($landingPageApplication == $application) { $routeData['controller'] = $landingPage->getController(); - $routeData['controller'] = ControllerMap::getInstance()->lookup($routeData['controller']); + $routeData['controller'] = ControllerMap::getInstance()->lookup($application, $routeData['controller']); return; } diff --git a/wcfsetup/install/files/lib/system/request/RouteHandler.class.php b/wcfsetup/install/files/lib/system/request/RouteHandler.class.php index 9238d0ba53..85b60aeac4 100644 --- a/wcfsetup/install/files/lib/system/request/RouteHandler.class.php +++ b/wcfsetup/install/files/lib/system/request/RouteHandler.class.php @@ -172,13 +172,15 @@ class RouteHandler extends SingletonFactory { * Builds a route based upon route components, this is nothing * but a reverse lookup. * + * @param string $application application identifier * @param array $components * @param boolean $isACP * @return string * @throws SystemException */ - public function buildRoute(array $components, $isACP = null) { + public function buildRoute($application, array $components, $isACP = null) { if ($isACP === null) $isACP = RequestHandler::getInstance()->isACPRequest(); + $components['application'] = $application; foreach ($this->routes as $route) { if ($isACP != $route->isACP()) { @@ -193,6 +195,24 @@ class RouteHandler extends SingletonFactory { throw new SystemException("Unable to build route, no available route is satisfied."); } + /** + * Returns true if `$customUrl` contains only the letters a-z/A-Z, numbers, dashes, + * underscores and forward slashes. + * + * All other characters including those from the unicode range are potentially unsafe, + * especially when dealing with url rewriting and resulting encoding issues with some + * webservers. + * + * This heavily limits the abilities for end-users to define appealing urls, but at + * the same time this ensures a sufficient level of stability. + * + * @param string $customUrl url to perform sanitiy checks on + * @return bool true if `$customUrl` passes the sanity check + */ + public static function isValidCustomUrl($customUrl) { + return preg_match('~^[a-zA-Z0-9\-_/]+$~', $customUrl) === 1; + } + /** * Returns true if this is a secure connection. * diff --git a/wcfsetup/install/files/lib/system/request/route/DynamicRequestRoute.class.php b/wcfsetup/install/files/lib/system/request/route/DynamicRequestRoute.class.php index 96192b1eab..e6f5963bb2 100644 --- a/wcfsetup/install/files/lib/system/request/route/DynamicRequestRoute.class.php +++ b/wcfsetup/install/files/lib/system/request/route/DynamicRequestRoute.class.php @@ -319,6 +319,6 @@ class DynamicRequestRoute implements IRequestRoute { * @return string */ protected function getControllerName($application, $controller) { - return ControllerMap::getInstance()->lookup($controller); + return ControllerMap::getInstance()->lookup($application, $controller); } } diff --git a/wcfsetup/install/files/lib/system/request/route/LookupRequestRoute.class.php b/wcfsetup/install/files/lib/system/request/route/LookupRequestRoute.class.php index 25d6fc95fa..73f2ac60c3 100644 --- a/wcfsetup/install/files/lib/system/request/route/LookupRequestRoute.class.php +++ b/wcfsetup/install/files/lib/system/request/route/LookupRequestRoute.class.php @@ -27,15 +27,14 @@ class LookupRequestRoute implements IRequestRoute { * @inheritDoc */ public function matches($requestURL) { - $requestURL = FileUtil::addLeadingSlash($requestURL); + $requestURL = FileUtil::removeLeadingSlash($requestURL); - if ($requestURL === '/') { + if ($requestURL === '') { // ignore empty urls and let them be handled by regular routes return false; } $regex = '~^ - / (?P.+?) (?: (?P[0-9]+) @@ -51,23 +50,49 @@ class LookupRequestRoute implements IRequestRoute { $application = ApplicationHandler::getInstance()->getActiveApplication()->getAbbreviation(); if (!empty($matches['id'])) { // check for static controller URLs - $this->routeData = ControllerMap::getInstance()->resolveCustomController($application, $matches['controller']); + $this->routeData = ControllerMap::getInstance()->resolveCustomController($application, FileUtil::removeTrailingSlash($matches['controller'])); + + // lookup WCF controllers unless initial request targeted WCF itself + if (empty($this->routeData) && $application !== 'wcf') { + $this->routeData = ControllerMap::getInstance()->resolveCustomController('wcf', FileUtil::removeTrailingSlash($matches['controller'])); + } + + if (!empty($this->routeData)) { + if (!empty($matches['id'])) { + $this->routeData['id'] = $matches['id']; + + if (!empty($matches['title'])) { + $this->routeData['title'] = $matches['title']; + } + } + } } if (empty($this->routeData)) { // try to match the entire url - $this->routeData = ControllerMap::getInstance()->resolveCustomController($application, $requestURL); + $this->routeData = ControllerMap::getInstance()->resolveCustomController($application, FileUtil::removeTrailingSlash($requestURL)); + + // lookup WCF controllers unless initial request targeted WCF itself + if (empty($this->routeData) && $application !== 'wcf') { + $this->routeData = ControllerMap::getInstance()->resolveCustomController('wcf', FileUtil::removeTrailingSlash($requestURL)); + } } } - return (!empty($this->routeData)); + if (!empty($this->routeData)) { + $this->routeData['isDefaultController'] = false; + + return true; + } + + return false; } /** * @inheritDoc */ public function getRouteData() { - return $this->getRouteData(); + return $this->routeData; } /** -- 2.20.1