--- /dev/null
+<?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;
+ }
+}
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;
$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'],
$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
<?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.
* @category Community Framework
*/
class ControllerMap extends SingletonFactory {
+ protected $ciControllers;
+
+ protected $customUrls;
+
/**
* list of <ControllerName> to <controller-name> mappings
* @var array<string>
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) {
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;
* 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 [];
}
/**
* 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;
}
}
$parameters['controller'] = $controller;
- $routeURL = RouteHandler::getInstance()->buildRoute($parameters, $isACP);
+ $routeURL = RouteHandler::getInstance()->buildRoute($abbreviation, $parameters, $isACP);
if (!$isRaw && !empty($url)) {
$routeURL .= (strpos($routeURL, '?') === false) ? '?' : '&';
}
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';
$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']);
}
// 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;
}
* 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()) {
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.
*
* @return string
*/
protected function getControllerName($application, $controller) {
- return ControllerMap::getInstance()->lookup($controller);
+ return ControllerMap::getInstance()->lookup($application, $controller);
}
}
* @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]+)
$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;
}
/**