Commit ae18caad authored by Matthias Vogel's avatar Matthias Vogel Committed by Benni Mack
Browse files

[!!!][TASK] Migrate modules to regular backend routing

This patch removes the separate request handler for backend modules,
which was accessed via "&M=moduleName" GET parameter. This is now
migrated into the RouteDispatcher which can dispatch modules as well.

Now, modules are called via the "&route" parameter like all other routes.

Additionally, the requested URLs for modules were requested with the additional
"moduleToken" which is now called "token".

This way, special treatment for modules when dispatching is removed,
however the security checks are still in place so this is kept as is.

All places where URLs are generated can now still be accessed via
`BackendUtility::getModuleUrl()` which can deal with routes, module names
and routePaths (from the URL) to keep backwards-compatibility.

Next Steps:
- Migration wizard for bookmarks + Streamline bookmarks code (see todos)
- Check what needs to be added in ExtensionManagementUtility
- Introduce slugs in routes for BE, e.g. /file-edit/{fileId}/ and /module/page/view/{id}
- Document reserved GET parameters "id", "route" and "token"
- Cleanup usage of determineScriptId and getModuleUrl to use new API

Resolves: #82406
Releases: master
Change-Id: If11c3d5289e14bc9ea766468b8e94cce95c23c71
Reviewed-on: https://review.typo3.org/53881

Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: default avatarStefan Neufeind <typo3.neufeind@speedpartner.de>
Tested-by: default avatarStefan Neufeind <typo3.neufeind@speedpartner.de>
Reviewed-by: Matthias Vogel's avatarMatthias Vogel <typo3@kanti.de>
Tested-by: Matthias Vogel's avatarMatthias Vogel <typo3@kanti.de>
Tested-by: default avatarTYPO3com <no-reply@typo3.com>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent b8de7cf8
......@@ -307,6 +307,7 @@ class ShortcutToolbarItem implements ToolbarItemInterface
/**
* Adds the correct token, if the url is an index.php script
* @todo: this needs love
*
* @param string $url
* @return string
......@@ -320,16 +321,20 @@ class ShortcutToolbarItem implements ToolbarItemInterface
if (isset($parameters['returnUrl'])) {
$parsedReturnUrl = parse_url($parameters['returnUrl']);
parse_str($parsedReturnUrl['query'], $returnUrlParameters);
if (strpos($parsedReturnUrl['path'], 'index.php') !== false && isset($returnUrlParameters['M'])) {
$module = $returnUrlParameters['M'];
if (strpos($parsedReturnUrl['path'], 'index.php') !== false && !empty($returnUrlParameters['route'])) {
$module = $returnUrlParameters['route'];
$returnUrl = BackendUtility::getModuleUrl($module, $returnUrlParameters);
$parameters['returnUrl'] = $returnUrl;
$url = $parsedUrl['path'] . '?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
}
}
if (isset($parameters['M']) && empty($parameters['route'])) {
$parameters['route'] = $parameters['M'];
unset($parameters['M']);
}
if (strpos($parsedUrl['path'], 'index.php') !== false && isset($parameters['M'])) {
$module = $parameters['M'];
if (strpos($parsedUrl['path'], 'index.php') !== false && isset($parameters['route'])) {
$module = $parameters['route'];
$url = BackendUtility::getModuleUrl($module, $parameters);
} elseif (strpos($parsedUrl['path'], 'index.php') !== false && isset($parameters['route'])) {
$routePath = $parameters['route'];
......
......@@ -1287,13 +1287,13 @@ class EditDocumentController extends AbstractModule
$returnUrl = $this->retUrl;
if ($this->firstEl['table'] === 'pages') {
parse_str((string)parse_url($returnUrl, PHP_URL_QUERY), $queryParams);
if (isset($queryParams['M'])
if (isset($queryParams['route'])
&& isset($queryParams['id'])
&& (string)$this->firstEl['uid'] === (string)$queryParams['id']
) {
// TODO: Use the page's pid instead of 0, this requires a clean API to manipulate the page
// tree from the outside to be able to mark the pid as active
$returnUrl = BackendUtility::getModuleUrl($queryParams['M'], ['id' => 0]);
$returnUrl = BackendUtility::getModuleUrl($queryParams['route'], ['id' => 0]);
}
}
$deleteButton = $buttonBar->makeLinkButton()
......
......@@ -975,7 +975,7 @@ class PageLayoutController
->setModuleName($this->moduleName)
->setGetVariables([
'id',
'M',
'route',
'edit_record',
'pointer',
'new_unique_uid',
......
......@@ -33,18 +33,12 @@ class Application implements ApplicationInterface
*/
protected $entryPointLevel = 1;
/**
* @var \Psr\Http\Message\ServerRequestInterface
*/
protected $request;
/**
* All available request handlers that can handle backend requests (non-CLI)
* @var array
*/
protected $availableRequestHandlers = [
\TYPO3\CMS\Backend\Http\RequestHandler::class,
\TYPO3\CMS\Backend\Http\BackendModuleRequestHandler::class,
\TYPO3\CMS\Backend\Http\AjaxRequestHandler::class
];
......@@ -81,12 +75,7 @@ class Application implements ApplicationInterface
*/
public function run(callable $execute = null)
{
$this->request = \TYPO3\CMS\Core\Http\ServerRequestFactory::fromGlobals();
if (isset($this->request->getQueryParams()['M'])) {
$this->request = $this->request->withAttribute('isModuleRequest', true);
}
$this->bootstrap->handleRequest($this->request);
$this->bootstrap->handleRequest(\TYPO3\CMS\Core\Http\ServerRequestFactory::fromGlobals());
if ($execute !== null) {
call_user_func($execute);
......
<?php
namespace TYPO3\CMS\Backend\Http;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\Dispatcher;
use TYPO3\CMS\Core\Http\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Handles the request for backend modules and wizards
* Juggles with $GLOBALS['TBE_MODULES']
*/
class BackendModuleRequestHandler implements RequestHandlerInterface
{
/**
* @var Bootstrap
*/
protected $bootstrap;
/**
* @var array
*/
protected $moduleRegistry = [];
/**
* @var BackendUserAuthentication
*/
protected $backendUserAuthentication;
/**
* Instance of the current Http Request
* @var ServerRequestInterface
*/
protected $request;
/**
* Constructor handing over the bootstrap and the original request
*
* @param Bootstrap $bootstrap
*/
public function __construct(Bootstrap $bootstrap)
{
$this->bootstrap = $bootstrap;
}
/**
* Handles the request, evaluating the configuration and executes the module accordingly
*
* @param ServerRequestInterface $request
* @return NULL|\Psr\Http\Message\ResponseInterface
* @throws Exception
*/
public function handleRequest(ServerRequestInterface $request)
{
$this->request = $request;
$this->boot();
$this->moduleRegistry = $GLOBALS['TBE_MODULES'];
if (!$this->isValidModuleRequest()) {
throw new Exception('The CSRF protection token for the requested module is missing or invalid', 1417988921);
}
$this->backendUserAuthentication = $GLOBALS['BE_USER'];
$moduleName = (string)$this->request->getQueryParams()['M'];
return $this->dispatchModule($moduleName);
}
/**
* Execute TYPO3 bootstrap
*/
protected function boot()
{
$this->bootstrap->checkLockedBackendAndRedirectOrDie()
->checkBackendIpOrDie()
->checkSslBackendAndRedirectIfNeeded()
->initializeBackendRouter()
->loadExtTables()
->initializeBackendUser()
->initializeBackendAuthentication()
->initializeLanguageObject()
->initializeBackendTemplate()
->endOutputBufferingAndCleanPreviousOutput()
->initializeOutputCompression()
->sendHttpHeaders();
}
/**
* This request handler can handle any backend request coming from index.php
*
* @param ServerRequestInterface $request
* @return bool
*/
public function canHandleRequest(ServerRequestInterface $request)
{
return $request->getAttribute('isModuleRequest', false);
}
/**
* Checks if all parameters are met.
*
* @return bool
*/
protected function isValidModuleRequest()
{
return $this->getFormProtection() instanceof BackendFormProtection
&& $this->getFormProtection()->validateToken((string)$this->request->getQueryParams()['moduleToken'], 'moduleCall', (string)$this->request->getQueryParams()['M']);
}
/**
* Executes the modules configured via Extbase
*
* @param string $moduleName
* @return Response A PSR-7 response object
* @throws \RuntimeException
*/
protected function dispatchModule($moduleName)
{
$moduleConfiguration = $this->getModuleConfiguration($moduleName);
$response = GeneralUtility::makeInstance(Response::class);
// Check permissions and exit if the user has no permission for entry
$this->backendUserAuthentication->modAccess($moduleConfiguration, true);
$id = isset($this->request->getQueryParams()['id']) ? $this->request->getQueryParams()['id'] : $this->request->getParsedBody()['id'];
if ($id && MathUtility::canBeInterpretedAsInteger($id)) {
$permClause = $this->backendUserAuthentication->getPagePermsClause(Permission::PAGE_SHOW);
// Check page access
$access = is_array(BackendUtility::readPageAccess((int)$id, $permClause));
if (!$access) {
// Check if page has been deleted
$deleteField = $GLOBALS['TCA']['pages']['ctrl']['delete'];
$pageInfo = BackendUtility::getRecord('pages', (int)$id, $deleteField, $permClause ? ' AND ' . $permClause : '', false);
if (!$pageInfo[$deleteField]) {
throw new \RuntimeException('You don\'t have access to this page', 1289917924);
}
}
}
// Use Core Dispatching
if (isset($moduleConfiguration['routeTarget'])) {
$dispatcher = GeneralUtility::makeInstance(Dispatcher::class);
$this->request = $this->request->withAttribute('target', $moduleConfiguration['routeTarget']);
$response = $dispatcher->dispatch($this->request, $response);
} else {
// extbase module
$configuration = [
'extensionName' => $moduleConfiguration['extensionName'],
'pluginName' => $moduleName
];
if (isset($moduleConfiguration['vendorName'])) {
$configuration['vendorName'] = $moduleConfiguration['vendorName'];
}
// Run Extbase
$bootstrap = GeneralUtility::makeInstance(\TYPO3\CMS\Extbase\Core\Bootstrap::class);
$content = $bootstrap->run('', $configuration);
$response->getBody()->write($content);
}
return $response;
}
/**
* Returns the module configuration which is provided during module registration
*
* @param string $moduleName
* @return array
* @throws \RuntimeException
*/
protected function getModuleConfiguration($moduleName)
{
if (!isset($this->moduleRegistry['_configuration'][$moduleName])) {
throw new \RuntimeException('Module ' . $moduleName . ' is not configured.', 1289918325);
}
return $this->moduleRegistry['_configuration'][$moduleName];
}
/**
* Returns the priority - how eager the handler is to actually handle the request.
*
* @return int The priority of the request handler.
*/
public function getPriority()
{
return 90;
}
/**
* Wrapper method for static form protection utility
*
* @return \TYPO3\CMS\Core\FormProtection\AbstractFormProtection
*/
protected function getFormProtection()
{
return FormProtectionFactory::get();
}
}
......@@ -59,14 +59,22 @@ class RequestHandler implements RequestHandlerInterface
*/
public function handleRequest(ServerRequestInterface $request)
{
// Check if a module URL is requested and deprecate this call
$moduleName = $request->getQueryParams()['M'] ?? $request->getParsedBody()['M'] ?? null;
// Allow the login page to be displayed if routing is not used and on index.php
$pathToRoute = (string)$request->getQueryParams()['route'] ?: '/login';
$pathToRoute = $request->getQueryParams()['route'] ?? $request->getParsedBody()['route'] ?? $moduleName ?? '/login';
$request = $request->withAttribute('routePath', $pathToRoute);
// skip the BE user check on the login page
// should be handled differently in the future by checking the Bootstrap directly
$this->boot($pathToRoute === '/login');
if ($moduleName !== null) {
trigger_error('Calling the TYPO3 Backend with "M" GET parameter will be removed in TYPO3 v10,'
. ' the calling code calls this script with "&M=' . $moduleName . '" and needs to be adapted'
. ' to use the TYPO3 API.', E_USER_DEPRECATED);
}
// Check if the router has the available route and dispatch.
try {
return $this->dispatch($request);
......
......@@ -19,9 +19,12 @@ use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Routing\Exception\InvalidRequestTokenException;
use TYPO3\CMS\Backend\Routing\Route;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\Dispatcher;
use TYPO3\CMS\Core\Http\DispatcherInterface;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
......@@ -45,10 +48,14 @@ class RouteDispatcher extends Dispatcher implements DispatcherInterface
/** @var Route $route */
$route = $router->matchRequest($request);
$request = $request->withAttribute('route', $route);
$request = $request->withAttribute('target', $route->getOption('target'));
if (!$this->isValidRequest($request)) {
throw new InvalidRequestTokenException('Invalid request for route "' . $route->getPath() . '"', 1425389455);
}
if ($route->getOption('module')) {
return $this->dispatchModule($request, $response);
}
$targetIdentifier = $route->getOption('target');
$target = $this->getCallableFromTarget($targetIdentifier);
return call_user_func_array($target, [$request, $response]);
......@@ -82,4 +89,84 @@ class RouteDispatcher extends Dispatcher implements DispatcherInterface
$token = (string)(isset($request->getParsedBody()['token']) ? $request->getParsedBody()['token'] : $request->getQueryParams()['token']);
return $this->getFormProtection()->validateToken($token, 'route', $route->getOption('_identifier'));
}
/**
* Executes the modules configured via Extbase
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface A PSR-7 response object
* @throws \RuntimeException
*/
protected function dispatchModule(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$route = $request->getAttribute('route');
$moduleName = $route->getOption('moduleName');
$moduleConfiguration = $this->getModuleConfiguration($moduleName);
$backendUserAuthentication = $GLOBALS['BE_USER'];
// Check permissions and exit if the user has no permission for entry
// @todo please do not use "true" here, what a bad coding paradigm
$backendUserAuthentication->modAccess($moduleConfiguration, true);
$id = (int)$request->getQueryParams()['id'] ?? $request->getParsedBody()['id'];
if ($id) {
$permClause = $backendUserAuthentication->getPagePermsClause(Permission::PAGE_SHOW);
// Check page access
if (!is_array(BackendUtility::readPageAccess($id, $permClause))) {
// Check if page has been deleted
$deleteField = $GLOBALS['TCA']['pages']['ctrl']['delete'];
$pageInfo = BackendUtility::getRecord('pages', $id, $deleteField, $permClause ? ' AND ' . $permClause : '', false);
if (!$pageInfo[$deleteField]) {
throw new \RuntimeException('You don\'t have access to this page', 1289917924);
}
}
}
// Use regular Dispatching
// @todo: unify with the code above
$targetIdentifier = $route->getOption('target');
if (!empty($targetIdentifier)) {
// @internal routeParameters are a helper construct for the install tool only.
// @todo: remove this, after sub-actions in install tool can be addressed directly
if (!empty($moduleConfiguration['routeParameters'])) {
$request = $request->withQueryParams(array_merge_recursive(
$request->getQueryParams(),
$moduleConfiguration['routeParameters']
));
}
return parent::dispatch($request, $response);
}
// extbase module
$configuration = [
'extensionName' => $moduleConfiguration['extensionName'],
'pluginName' => $moduleName
];
if (isset($moduleConfiguration['vendorName'])) {
$configuration['vendorName'] = $moduleConfiguration['vendorName'];
}
// Run Extbase
$bootstrap = GeneralUtility::makeInstance(\TYPO3\CMS\Extbase\Core\Bootstrap::class);
$content = $bootstrap->run('', $configuration);
$response->getBody()->write($content);
return $response;
}
/**
* Returns the module configuration which is provided during module registration
*
* @param string $moduleName
* @return array
* @throws \RuntimeException
*/
protected function getModuleConfiguration($moduleName)
{
if (!isset($GLOBALS['TBE_MODULES']['_configuration'][$moduleName])) {
throw new \RuntimeException('Module ' . $moduleName . ' is not configured.', 1289918325);
}
return $GLOBALS['TBE_MODULES']['_configuration'][$moduleName];
}
}
......@@ -15,9 +15,7 @@ namespace TYPO3\CMS\Backend\RecordList;
*/
use TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
......@@ -215,12 +213,8 @@ abstract class AbstractRecordList
protected function determineScriptUrl()
{
if ($routePath = GeneralUtility::_GP('route')) {
$router = GeneralUtility::makeInstance(Router::class);
$route = $router->match($routePath);
$uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$this->thisScript = (string)$uriBuilder->buildUriFromRoute($route->getOption('_identifier'));
} elseif ($moduleName = GeneralUtility::_GP('M')) {
$this->thisScript = BackendUtility::getModuleUrl($moduleName);
$this->thisScript = (string)$uriBuilder->buildUriFromRoutePath($routePath);
} else {
$this->thisScript = GeneralUtility::getIndpEnv('SCRIPT_NAME');
}
......
......@@ -54,6 +54,25 @@ class UriBuilder
$this->routes = $router->getRoutes();
}
/**
* Generates a URL or path for a specific route based on the given rout.
* Currently used to link to the current script, it is encouraged to use "buildUriFromRoute" if possible.
*
* If there is no route with the given name, the generator throws the RouteNotFoundException.
*
* @param string $pathInfo The path to the route
* @param array $parameters An array of parameters
* @param string $referenceType The type of reference to be generated (one of the constants)
* @return Uri The generated Uri
* @throws RouteNotFoundException If the named route doesn't exist
*/
public function buildUriFromRoutePath($pathInfo, $parameters = [], $referenceType = self::ABSOLUTE_PATH)
{
$router = GeneralUtility::makeInstance(Router::class);
$route = $router->match($pathInfo);
return $this->buildUriFromRoute($route->getOption('_identifier'), $parameters, $referenceType);
}
/**
* Generates a URL or path for a specific route based on the given parameters.
* When the route is configured with "access=public" then the token generation is left out.
......@@ -106,8 +125,8 @@ class UriBuilder
public function buildUriFromModule($moduleName, $parameters = [], $referenceType = self::ABSOLUTE_PATH)
{
$parameters = [
'M' => $moduleName,
'moduleToken' => FormProtectionFactory::get('backend')->generateToken('moduleCall', $moduleName)
'route' => $moduleName,
'token' => FormProtectionFactory::get('backend')->generateToken('route', $moduleName)
] + $parameters;
return $this->buildUri($parameters, $referenceType);
}
......
......@@ -239,7 +239,7 @@ class ShortcutButton implements ButtonInterface, PositionInterface
// Set default GET parameters
if ($emptyGetVariables) {
$this->getVariables = ['id', 'M'];
$this->getVariables = ['id', 'route'];
}
// Automatically determine module name in Extbase context
......
......@@ -251,7 +251,7 @@ function jumpToUrl(URL) {
$this->templateService = GeneralUtility::makeInstance(MarkerBasedTemplateService::class);
// Setting default scriptID, trim forward slash from route
$this->scriptID = GeneralUtility::_GET('M') !== null ? GeneralUtility::_GET('M') : ltrim(GeneralUtility::_GET('route'), '/');
$this->scriptID = ltrim(GeneralUtility::_GET('route'), '/');
$this->bodyTagId = preg_replace('/[^A-Za-z0-9-]/', '-', $this->scriptID);
// Individual configuration per script? If so, make a recursive merge of the arrays:
if (is_array($GLOBALS['TBE_STYLES']['scriptIDindex'][$this->scriptID])) {
......
......@@ -510,8 +510,9 @@ class ModuleTemplate
// since this is used for icons.
$moduleName = $modName === 'xMOD_alt_doc.php' ? 'record_edit' : $modName;
// Add the module identifier automatically if typo3/index.php is used:
if (GeneralUtility::_GET('M') !== null) {
$storeUrl = '&M=' . $moduleName . $storeUrl;
// @todo: routing
if (GeneralUtility::_GET('route') !== null) {
$storeUrl = '&route=' . $moduleName . $storeUrl;
}
if ((int)$motherModName === 1) {
$motherModule = 'top.currentModuleLoaded';
......
......@@ -14,7 +14,6 @@ namespace TYPO3\CMS\Backend\Tree\View;
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Tree\Pagetree\Commands;
use TYPO3\CMS\Backend\Utility\BackendUtility;
......@@ -289,12 +288,8 @@ abstract class AbstractTreeView
protected function determineScriptUrl()
{
if ($routePath = GeneralUtility::_GP('route')) {
$router = GeneralUtility::makeInstance(Router::class);
$route = $router->match($routePath);
$uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$this->thisScript = (string)$uriBuilder->buildUriFromRoute($route->getOption('_identifier'));
} elseif ($moduleName = GeneralUtility::_GP('M')) {
$this->thisScript = BackendUtility::getModuleUrl($moduleName);
$this->thisScript = (string)$uriBuilder->buildUriFromRoutePath($routePath);
} else {
$this->thisScript = GeneralUtility::getIndpEnv('SCRIPT_NAME');
}
......
......@@ -2950,14 +2950,10 @@ class BackendUtility
$script = basename(PATH_thisScript);