Added proper support for custom urls
authorAlexander Ebert <ebert@woltlab.com>
Sat, 28 Nov 2015 12:54:52 +0000 (13:54 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Sat, 28 Nov 2015 12:54:52 +0000 (13:54 +0100)
wcfsetup/install/files/lib/system/cache/builder/RoutingCacheBuilder.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/package/plugin/PagePackageInstallationPlugin.class.php
wcfsetup/install/files/lib/system/request/ControllerMap.class.php
wcfsetup/install/files/lib/system/request/LinkHandler.class.php
wcfsetup/install/files/lib/system/request/RequestHandler.class.php
wcfsetup/install/files/lib/system/request/RouteHandler.class.php
wcfsetup/install/files/lib/system/request/route/DynamicRequestRoute.class.php
wcfsetup/install/files/lib/system/request/route/LookupRequestRoute.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 (file)
index 0000000..d9dde74
--- /dev/null
@@ -0,0 +1,148 @@
+<?php
+namespace wcf\system\cache\builder;
+use wcf\data\application\Application;
+use wcf\system\application\ApplicationHandler;
+use wcf\system\WCF;
+use wcf\util\FileUtil;
+
+/**
+ * Caches routing data.
+ * 
+ * @author     Alexander Ebert
+ * @copyright  2001-2015 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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;
+       }
+}
index 98806f9288a415c8a7c74e26737d35d31f166db4..22c9c6b3c4974ba353d6fea1701d091f3e136f8d 100644 (file)
@@ -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
index 4184a5b13d64dfa322cd28b0d9068c0bd4ef3309..3a7a662c1fc518339c8088b317d92d032577f8bf 100644 (file)
@@ -1,7 +1,9 @@
 <?php
 namespace wcf\system\request;
+use wcf\system\cache\builder\RoutingCacheBuilder;
 use wcf\system\exception\SystemException;
 use wcf\system\SingletonFactory;
+use wcf\util\StringUtil;
 
 /**
  * Resolves incoming requests and performs lookups for controller to url mappings.
@@ -14,6 +16,10 @@ use wcf\system\SingletonFactory;
  * @category   Community Framework
  */
 class ControllerMap extends SingletonFactory {
+       protected $ciControllers;
+       
+       protected $customUrls;
+       
        /**
         * list of <ControllerName> to <controller-name> mappings
         * @var array<string>
@@ -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<string>   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<pageID>\d+)-(?P<languageID>\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;
        }
index dc1e07e16ef70fc539dfc33b84b4a1c04de285ac..0b53908a162f0753467738b14189b5f78d1888ac 100644 (file)
@@ -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) ? '?' : '&';
                }
index b09b857ee9f8caf30cf0723f91dd28a3a119b92e..c30085a1f52f82ed802599b0701d146562d70e8c 100644 (file)
@@ -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;
                }
index 9238d0ba5368bd9001a4f10efd474345e564f96c..85b60aeac41028658c02ebc2e784d41cf7b8516f 100644 (file)
@@ -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.
         * 
index 96192b1eabbf391d736b04d4fb2b0acb34baf935..e6f5963bb2eb9c791ebec9127e5daf8351dd4a5a 100644 (file)
@@ -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);
        }
 }
index 25d6fc95faa22fc5ed83810ac07156f6c06b8425..73f2ac60c3f0e99a2e29507cd00b005b6a11456e 100644 (file)
@@ -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<controller>.+?)
                        (?:
                                (?P<id>[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;
        }
        
        /**