diff --git a/typo3/sysext/core/Documentation/Changelog/master/Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst new file mode 100644 index 0000000000000000000000000000000000000000..b994d30391d8965fa5355335da3e26ba1aec96b4 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst @@ -0,0 +1,46 @@ +.. include:: ../../Includes.txt + +=================================================================== +Deprecation: #83883 - Page Not Found And Error handling in Frontend +=================================================================== + +See :issue:`83883` + +Description +=========== + +The following methods have been marked as deprecated: + +* php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageUnavailableAndExit()` +* php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageNotFoundAndExit()` +* php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkPageUnavailableHandler()` +* php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageUnavailableHandler()` +* php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageNotFoundHandler()` +* php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageErrorHandler()` + +These methods have been commonly used by third-party extensions to show that a page is not found, or +a page is unavailable due to misconfiguration, or the access to a page was denied. + + +Impact +====== + +Calling any of the methods above will trigger a deprecation error. + + +Affected Installations +====================== + +Any installation with third-party PHP extension code calling these methods. + + +Migration +========= + +Use the new `ErrorController` with its custom actions `unavailableAction()`, `pageNotFoundAction()` and +`accessDeniedAction()`. + +Instead of exiting the currently running script, a proposed PSR-7 compliant response is returned which can be +handled by the third-party extension to enrich, return or customly exiting the script. + +.. index:: Frontend, PHP-API, FullyScanned \ No newline at end of file diff --git a/typo3/sysext/frontend/Classes/Controller/ErrorController.php b/typo3/sysext/frontend/Classes/Controller/ErrorController.php new file mode 100644 index 0000000000000000000000000000000000000000..6d0f1b5d160a85c1cc3979114eb5186c0c869a95 --- /dev/null +++ b/typo3/sysext/frontend/Classes/Controller/ErrorController.php @@ -0,0 +1,278 @@ +<?php +declare(strict_types = 1); +namespace TYPO3\CMS\Frontend\Controller; + +/* + * 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\ResponseInterface; +use TYPO3\CMS\Core\Controller\ErrorPageController; +use TYPO3\CMS\Core\Error\Http\PageNotFoundException; +use TYPO3\CMS\Core\Error\Http\ServiceUnavailableException; +use TYPO3\CMS\Core\Http\HtmlResponse; +use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Handles "Page Not Found" or "Page Unavailable" requests, + * returns a response object. + */ +class ErrorController +{ + /** + * Used for creating a 500 response ("Page unavailable"), usually due some misconfiguration + * but if configured, a RedirectResponse could be returned as well. + * + * @param string $message + * @param array $reasons + * @return ResponseInterface + * @throws ServiceUnavailableException + */ + public function unavailableAction(string $message, array $reasons = []): ResponseInterface + { + if (!$this->isPageUnavailableHandlerConfigured()) { + throw new ServiceUnavailableException($message, 1518472181); + } + return $this->handlePageError( + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'], + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling_statheader'], + $message, + $reasons + ); + } + + /** + * Used for creating a 404 response ("Page Not Found"), + * but if configured, a RedirectResponse could be returned as well. + * + * @param string $message + * @param array $reasons + * @return ResponseInterface + * @throws PageNotFoundException + */ + public function pageNotFoundAction(string $message, array $reasons = []): ResponseInterface + { + if (!$GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling']) { + throw new PageNotFoundException($message, 1518472189); + } + return $this->handlePageError( + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling'], + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_statheader'], + $message, + $reasons + ); + } + + /** + * Used for creating a 403 response ("Access denied"), + * but if configured, a RedirectResponse could be returned as well. + * + * @param string $message + * @param array $reasons + * @return ResponseInterface + */ + public function accessDeniedAction(string $message, array $reasons = []): ResponseInterface + { + return $this->handlePageError( + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling'], + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_accessdeniedheader'], + $message, + $reasons + ); + } + + /** + * Checks whether the pageUnavailableHandler should be used. To be used, pageUnavailable_handling must be set + * and devIPMask must not match the current visitor's IP address. + * + * @return bool TRUE/FALSE whether the pageUnavailable_handler should be used. + */ + protected function isPageUnavailableHandlerConfigured(): bool + { + return + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'] + && !GeneralUtility::cmpIP( + GeneralUtility::getIndpEnv('REMOTE_ADDR'), + $GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask'] + ) + ; + } + + /** + * Generic error page handler. + * + * @param mixed $errorHandler See docs of ['FE']['pageNotFound_handling'] and ['FE']['pageUnavailable_handling'] for all possible values + * @param string $header If set, this is passed directly to the PHP function, header() + * @param string $reason If set, error messages will also mention this as the reason for the page-not-found. + * @param array $pageAccessFailureReasons + * @return ResponseInterface + * @throws \RuntimeException + */ + protected function handlePageError($errorHandler, string $header = '', string $reason = '', array $pageAccessFailureReasons = []): ResponseInterface + { + $response = null; + $content = ''; + // Simply boolean; Just shows TYPO3 error page with reason: + if (gettype($errorHandler) === 'boolean' || strtolower($errorHandler) === 'true' || (string)$errorHandler === '1') { + $content = GeneralUtility::makeInstance(ErrorPageController::class)->errorAction( + 'Page Not Found', + 'The page did not exist or was inaccessible.' . ($reason ? ' Reason: ' . $reason : '') + ); + } elseif (GeneralUtility::isFirstPartOfStr($errorHandler, 'USER_FUNCTION:')) { + $funcRef = trim(substr($errorHandler, 14)); + $params = [ + 'currentUrl' => GeneralUtility::getIndpEnv('REQUEST_URI'), + 'reasonText' => $reason, + 'pageAccessFailureReasons' => $pageAccessFailureReasons + ]; + try { + $content = GeneralUtility::callUserFunction($funcRef, $params, $this); + } catch (\Exception $e) { + throw new \RuntimeException('Error: 404 page by USER_FUNCTION "' . $funcRef . '" failed.', 1518472235, $e); + } + } elseif (GeneralUtility::isFirstPartOfStr($errorHandler, 'READFILE:')) { + $readFile = GeneralUtility::getFileAbsFileName(trim(substr($errorHandler, 9))); + if (@is_file($readFile)) { + $content = str_replace( + [ + '###CURRENT_URL###', + '###REASON###' + ], + [ + GeneralUtility::getIndpEnv('REQUEST_URI'), + htmlspecialchars($reason) + ], + file_get_contents($readFile) + ); + } else { + throw new \RuntimeException('Configuration Error: 404 page "' . $readFile . '" could not be found.', 1518472245); + } + } elseif (GeneralUtility::isFirstPartOfStr($errorHandler, 'REDIRECT:')) { + $response = new RedirectResponse(substr($errorHandler, 9)); + } elseif ($errorHandler !== '') { + // Check if URL is relative + $urlParts = parse_url($errorHandler); + // parse_url could return an array without the key "host", the empty check works better than strict check + if (empty($urlParts['host'])) { + $urlParts['host'] = GeneralUtility::getIndpEnv('HTTP_HOST'); + if ($errorHandler[0] === '/') { + $errorHandler = GeneralUtility::getIndpEnv('TYPO3_REQUEST_HOST') . $errorHandler; + } else { + $errorHandler = GeneralUtility::getIndpEnv('TYPO3_REQUEST_DIR') . $errorHandler; + } + $checkBaseTag = false; + } else { + $checkBaseTag = true; + } + // Check recursion + if ($errorHandler === GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL')) { + $reason = $reason ?: 'Page cannot be found.'; + $reason .= LF . LF . 'Additionally, ' . $errorHandler . ' was not found while trying to retrieve the error document.'; + throw new \RuntimeException(nl2br(htmlspecialchars($reason)), 1518472252); + } + // Prepare headers + $requestHeaders = [ + 'User-agent: ' . GeneralUtility::getIndpEnv('HTTP_USER_AGENT'), + 'Referer: ' . GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL') + ]; + $report = []; + $res = GeneralUtility::getUrl($errorHandler, 1, $requestHeaders, $report); + if ((int)$report['error'] !== 0 && (int)$report['error'] !== 200) { + throw new \RuntimeException('Failed to fetch error page "' . $errorHandler . '", reason: ' . $report['message'], 1518472257); + } + if ($res === false) { + // Last chance -- redirect + $response = new RedirectResponse($errorHandler); + } else { + // Header and content are separated by an empty line + list($returnedHeaders, $content) = explode(CRLF . CRLF, $res, 2); + $content .= CRLF; + // Forward these response headers to the client + $forwardHeaders = [ + 'Content-Type:' + ]; + $headerArr = preg_split('/\\r|\\n/', $returnedHeaders, -1, PREG_SPLIT_NO_EMPTY); + foreach ($headerArr as $headerLine) { + foreach ($forwardHeaders as $h) { + if (preg_match('/^' . $h . '/', $headerLine)) { + $header .= CRLF . $headerLine; + } + } + } + // Put <base> if necessary + if ($checkBaseTag) { + // If content already has <base> tag, we do not need to do anything + if (false === stristr($content, '<base ')) { + // Generate href for base tag + $base = $urlParts['scheme'] . '://'; + if ($urlParts['user'] != '') { + $base .= $urlParts['user']; + if ($urlParts['pass'] != '') { + $base .= ':' . $urlParts['pass']; + } + $base .= '@'; + } + $base .= $urlParts['host']; + // Add path portion skipping possible file name + $base .= preg_replace('/(.*\\/)[^\\/]*/', '${1}', $urlParts['path']); + // Put it into content (generate also <head> if necessary) + $replacement = LF . '<base href="' . htmlentities($base) . '" />' . LF; + if (stristr($content, '<head>')) { + $content = preg_replace('/(<head>)/i', '\\1' . $replacement, $content); + } else { + $content = preg_replace('/(<html[^>]*>)/i', '\\1<head>' . $replacement . '</head>', $content); + } + } + } + } + } else { + $content = GeneralUtility::makeInstance(ErrorPageController::class)->errorAction( + 'Page Not Found', + $reason ? 'Reason: ' . $reason : 'Page cannot be found.' + ); + } + + if (!$response) { + $response = new HtmlResponse($content); + } + return $this->applySanitizedHeadersToResponse($response, $header); + } + + /** + * Headers which have been requested, will be added to the response object. + * If a header is part of the HTTP Repsonse code, the response object will be annotated as well. + * + * @param ResponseInterface $response + * @param string $headers + * @return ResponseInterface + */ + protected function applySanitizedHeadersToResponse(ResponseInterface $response, string $headers): ResponseInterface + { + if (!empty($headers)) { + $headerArr = preg_split('/\\r|\\n/', $headers, -1, PREG_SPLIT_NO_EMPTY); + foreach ($headerArr as $headerLine) { + if (strpos($headerLine, 'HTTP/') === 0 && strpos($headerLine, ':') === false) { + list($protocolVersion, $statusCode, $reasonPhrase) = explode(' ', $headerLine, 3); + list(, $protocolVersion) = explode('/', $protocolVersion, 2); + $response = $response + ->withProtocolVersion((int)$protocolVersion) + ->withStatus($statusCode, $reasonPhrase); + } else { + list($headerName, $value) = GeneralUtility::trimExplode(':', $headerLine, 2); + $response = $response->withHeader($headerName, $value); + } + } + } + return $response; + } +} diff --git a/typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php b/typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php index 9a239adc9b7dc8878489d6c46026455cc24a6bed..fa4b80cac588ad328cc3e868c11ed08a48b656db 100644 --- a/typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php +++ b/typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php @@ -847,10 +847,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface } catch (ConnectionException $exception) { // Cannot connect to current database $message = 'Cannot connect to the configured database "' . $connection->getDatabase() . '"'; - if ($this->checkPageUnavailableHandler()) { - $this->pageUnavailableAndExit($message); - } else { - $this->logger->emergency($message, ['exception' => $exception]); + $this->logger->emergency($message, ['exception' => $exception]); + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->unavailableAction($message); + $this->sendResponseAndExit($response); + } catch (ServiceUnavailableException $e) { throw new ServiceUnavailableException($message, 1301648782); } } @@ -1310,10 +1311,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface $this->id = $theFirstPage['uid']; } else { $message = 'No pages are found on the rootlevel!'; - if ($this->checkPageUnavailableHandler()) { - $this->pageUnavailableAndExit($message); - } else { - $this->logger->alert($message); + $this->logger->alert($message); + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->unavailableAction($message); + $this->sendResponseAndExit($response); + } catch (ServiceUnavailableException $e) { throw new ServiceUnavailableException($message, 1301648975); } } @@ -1325,6 +1327,8 @@ class TypoScriptFrontendController implements LoggerAwareInterface $this->requestedId = $this->id; $this->getPageAndRootlineWithDomain($this->domainStartPage); $timeTracker->pull(); + // @todo: in the future, the check if "pageNotFound_handling" is configured should go away, but this breaks + // Functional tests in workspaces currently if ($this->pageNotFound && $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling']) { $pNotFoundMsg = [ 1 => 'ID was not an accessible page', @@ -1332,11 +1336,13 @@ class TypoScriptFrontendController implements LoggerAwareInterface 3 => 'ID was outside the domain', 4 => 'The requested page alias does not exist' ]; - $header = ''; + $message = $pNotFoundMsg[$this->pageNotFound]; if ($this->pageNotFound === 1 || $this->pageNotFound === 2) { - $header = $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_accessdeniedheader']; + $response = GeneralUtility::makeInstance(ErrorController::class)->accessDeniedAction($message, $this->getPageAccessFailureReasons()); + } else { + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction($message, $this->getPageAccessFailureReasons()); } - $this->pageNotFoundAndExit($pNotFoundMsg[$this->pageNotFound], $header); + $this->sendResponseAndExit($response); } // Init SYS_LASTCHANGED $this->register['SYS_LASTCHANGED'] = (int)$this->page['tstamp']; @@ -1423,10 +1429,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface // If still no page... if (empty($this->page)) { $message = 'The requested page does not exist!'; - if ($GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling']) { - $this->pageNotFoundAndExit($message); - } else { - $this->logger->error($message); + $this->logger->error($message); + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction($message, $this->getPageAccessFailureReasons()); + $this->sendResponseAndExit($response); + } catch (PageNotFoundException $e) { throw new PageNotFoundException($message, 1301648780); } } @@ -1434,10 +1441,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface // Spacer is not accessible in frontend if ($this->page['doktype'] == PageRepository::DOKTYPE_SPACER) { $message = 'The requested page does not exist!'; - if ($GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling']) { - $this->pageNotFoundAndExit($message); - } else { - $this->logger->error($message); + $this->logger->error($message); + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction($message, $this->getPageAccessFailureReasons()); + $this->sendResponseAndExit($response); + } catch (PageNotFoundException $e) { throw new PageNotFoundException($message, 1301648781); } } @@ -1472,10 +1480,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface // If not rootline we're off... if (empty($this->rootLine)) { $message = 'The requested page didn\'t have a proper connection to the tree-root!'; - if ($this->checkPageUnavailableHandler()) { - $this->pageUnavailableAndExit($message); - } else { - $this->logger->error($message); + $this->logger->error($message); + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->unavailableAction($message, $this->getPageAccessFailureReasons()); + $this->sendResponseAndExit($response); + } catch (ServiceUnavailableException $e) { throw new ServiceUnavailableException($message, 1301648167); } } @@ -1483,9 +1492,10 @@ class TypoScriptFrontendController implements LoggerAwareInterface if ($this->checkRootlineForIncludeSection()) { if (empty($this->rootLine)) { $message = 'The requested page was not accessible!'; - if ($this->checkPageUnavailableHandler()) { - $this->pageUnavailableAndExit($message); - } else { + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->unavailableAction($message, $this->getPageAccessFailureReasons()); + $this->sendResponseAndExit($response); + } catch (ServiceUnavailableException $e) { $this->logger->warning($message); throw new ServiceUnavailableException($message, 1301648234); } @@ -1871,9 +1881,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface * * @param string $reason Reason text * @param string $header HTTP header to send + * @deprecated */ public function pageUnavailableAndExit($reason = '', $header = '') { + trigger_error('This method will be removed in TYPO3 v10. Use TYPO3\'s ErrorController with Request/Response objects instead.', E_USER_DEPRECATED); $header = $header ?: $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling_statheader']; $this->pageUnavailableHandler($GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'], $header, $reason); die; @@ -1884,9 +1896,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface * * @param string $reason Reason text * @param string $header HTTP header to send + * @deprecated */ public function pageNotFoundAndExit($reason = '', $header = '') { + trigger_error('This method will be removed in TYPO3 v10. Use TYPO3\'s ErrorController with Request/Response objects instead.', E_USER_DEPRECATED); $header = $header ?: $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_statheader']; $this->pageNotFoundHandler($GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling'], $header, $reason); die; @@ -1897,9 +1911,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface * and devIPMask must not match the current visitor's IP address. * * @return bool TRUE/FALSE whether the pageUnavailable_handler should be used. + * @deprecated */ public function checkPageUnavailableHandler() { + trigger_error('This method will be removed in TYPO3 v10. Use TYPO3\'s ErrorController with Request/Response objects instead.', E_USER_DEPRECATED); if ( $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'] && !GeneralUtility::cmpIP( @@ -1920,9 +1936,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface * @param mixed $code See ['FE']['pageUnavailable_handling'] for possible values * @param string $header If set, this is passed directly to the PHP function, header() * @param string $reason If set, error messages will also mention this as the reason for the page-not-found. + * @deprecated */ public function pageUnavailableHandler($code, $header, $reason) { + trigger_error('This method will be removed in TYPO3 v10. Use TYPO3\'s ErrorController with Request/Response objects instead.', E_USER_DEPRECATED); $this->pageErrorHandler($code, $header, $reason); } @@ -1932,9 +1950,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface * @param mixed $code See docs of ['FE']['pageNotFound_handling'] for possible values * @param string $header If set, this is passed directly to the PHP function, header() * @param string $reason If set, error messages will also mention this as the reason for the page-not-found. + * @deprecated */ public function pageNotFoundHandler($code, $header = '', $reason = '') { + trigger_error('This method will be removed in TYPO3 v10. Use TYPO3\'s ErrorController with Request/Response objects instead.', E_USER_DEPRECATED); $this->pageErrorHandler($code, $header, $reason); } @@ -1946,9 +1966,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface * @param string $header If set, this is passed directly to the PHP function, header() * @param string $reason If set, error messages will also mention this as the reason for the page-not-found. * @throws \RuntimeException + * @deprecated */ public function pageErrorHandler($code, $header = '', $reason = '') { + trigger_error('This method will be removed in TYPO3 v10. Use TYPO3\'s ErrorController with Request/Response objects instead.', E_USER_DEPRECATED); // Issue header in any case: if ($header) { $headerArr = preg_split('/\\r|\\n/', $header, -1, PREG_SPLIT_NO_EMPTY); @@ -2161,7 +2183,8 @@ class TypoScriptFrontendController implements LoggerAwareInterface $cHash_calc = $this->cacheHash->calculateCacheHash($this->cHash_array); if (!hash_equals($cHash_calc, $this->cHash)) { if ($GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFoundOnCHashError']) { - $this->pageNotFoundAndExit('Request parameters could not be validated (&cHash comparison failed)'); + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction('Request parameters could not be validated (&cHash comparison failed)'); + $this->sendResponseAndExit($response); } else { $this->disableCache(); $this->getTimeTracker()->setTSlogMessage('The incoming cHash "' . $this->cHash . '" and calculated cHash "' . $cHash_calc . '" did not match, so caching was disabled. The fieldlist used was "' . implode(',', array_keys($this->cHash_array)) . '"', 2); @@ -2188,7 +2211,8 @@ class TypoScriptFrontendController implements LoggerAwareInterface if ($this->tempContent) { $this->clearPageCacheContent(); } - $this->pageNotFoundAndExit('Request parameters could not be validated (&cHash empty)'); + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction('Request parameters could not be validated (&cHash empty)'); + $this->sendResponseAndExit($response); } else { $this->disableCache(); $this->getTimeTracker()->setTSlogMessage('TSFE->reqCHash(): No &cHash parameter was sent for GET vars though required so caching is disabled', 2); @@ -2451,11 +2475,12 @@ class TypoScriptFrontendController implements LoggerAwareInterface $this->pSetup = $this->tmpl->setup[$this->sPre . '.']; if (!is_array($this->pSetup)) { $message = 'The page is not configured! [type=' . $this->type . '][' . $this->sPre . '].'; - if ($this->checkPageUnavailableHandler()) { - $this->pageUnavailableAndExit($message); - } else { + $this->logger->alert($message); + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->unavailableAction($message); + $this->sendResponseAndExit($response); + } catch (ServiceUnavailableException $e) { $explanation = 'This means that there is no TypoScript object of type PAGE with typeNum=' . $this->type . ' configured.'; - $this->logger->alert($message); throw new ServiceUnavailableException($message . ' ' . $explanation, 1294587217); } } else { @@ -2498,11 +2523,12 @@ class TypoScriptFrontendController implements LoggerAwareInterface } $timeTracker->pull(); } else { - if ($this->checkPageUnavailableHandler()) { - $this->pageUnavailableAndExit('No TypoScript template found!'); - } else { - $message = 'No TypoScript template found!'; - $this->logger->alert($message); + $message = 'No TypoScript template found!'; + $this->logger->alert($message); + try { + $response = GeneralUtility::makeInstance(ErrorController::class)->unavailableAction($message); + $this->sendResponseAndExit($response); + } catch (ServiceUnavailableException $e) { throw new ServiceUnavailableException($message, 1294587218); } } @@ -2574,11 +2600,13 @@ class TypoScriptFrontendController implements LoggerAwareInterface if ($this->sys_language_uid) { // If requested translation is not available: if (GeneralUtility::hideIfNotTranslated($this->page['l18n_cfg'])) { - $this->pageNotFoundAndExit('Page is not available in the requested language.'); + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction('Page is not available in the requested language.'); + $this->sendResponseAndExit($response); } else { switch ((string)$this->sys_language_mode) { case 'strict': - $this->pageNotFoundAndExit('Page is not available in the requested language (strict).'); + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction('Page is not available in the requested language (strict).'); + $this->sendResponseAndExit($response); break; case 'content_fallback': // Setting content uid (but leaving the sys_language_uid) when a content_fallback @@ -2597,7 +2625,8 @@ class TypoScriptFrontendController implements LoggerAwareInterface // The existing fallbacks have not been found, but instead of continuing // page rendering with default language, a "page not found" message should be shown // instead. - $this->pageNotFoundAndExit('Page is not available in the requested language (fallbacks did not apply).'); + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction('Page is not available in the requested language (fallbacks did not apply).'); + $this->sendResponseAndExit($response); } } break; @@ -2621,7 +2650,8 @@ class TypoScriptFrontendController implements LoggerAwareInterface if ((!$this->sys_language_uid || !$this->sys_language_content) && GeneralUtility::hideIfDefaultLanguage($this->page['l18n_cfg'])) { $message = 'Page is not available in default language.'; $this->logger->error($message); - $this->pageNotFoundAndExit($message); + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction($message); + $this->sendResponseAndExit($response); } $this->updateRootLinesWithTranslations(); @@ -4675,6 +4705,30 @@ class TypoScriptFrontendController implements LoggerAwareInterface } } + /** + * Helper method to kill the request. Exits. + * Should not be used from the outside, rather return the response object + * Ideally, this method will be dropped by TYPO3 v9 LTS. + * + * @param ResponseInterface $response + */ + protected function sendResponseAndExit(ResponseInterface $response) + { + // If the response code was not changed by legacy code (still is 200) + // then allow the PSR-7 response object to explicitly set it. + // Otherwise let legacy code take precedence. + // This code path can be deprecated once we expose the response object to third party code + if (http_response_code() === 200) { + header('HTTP/' . $response->getProtocolVersion() . ' ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + + foreach ($response->getHeaders() as $name => $values) { + header($name . ': ' . implode(', ', $values)); + } + echo $response->getBody()->__toString(); + die; + } + /** * Returns the current BE user. * diff --git a/typo3/sysext/frontend/Classes/Http/RequestHandler.php b/typo3/sysext/frontend/Classes/Http/RequestHandler.php index 521ba2b3ebb11d705792cbd19c3d07a124edd575..8d8d7efbdbf30fc917656af5c8fcd04adf007f10 100644 --- a/typo3/sysext/frontend/Classes/Http/RequestHandler.php +++ b/typo3/sysext/frontend/Classes/Http/RequestHandler.php @@ -26,6 +26,7 @@ use TYPO3\CMS\Core\Http\RequestHandlerInterface; use TYPO3\CMS\Core\TimeTracker\TimeTracker; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Frontend\Controller\ErrorController; use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; use TYPO3\CMS\Frontend\Page\PageGenerator; use TYPO3\CMS\Frontend\Utility\CompressionUtility; @@ -107,7 +108,7 @@ class RequestHandler implements RequestHandlerInterface, PsrRequestHandlerInterf $GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask'] ) ) { - $this->controller->pageUnavailableAndExit('This page is temporarily unavailable.'); + return GeneralUtility::makeInstance(ErrorController::class)->unavailableAction('This page is temporarily unavailable.'); } $this->controller->connectToDB(); diff --git a/typo3/sysext/frontend/Tests/Unit/Controller/ErrorControllerTest.php b/typo3/sysext/frontend/Tests/Unit/Controller/ErrorControllerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..71ee6fd4b4f589849a0ee8187378d7408ee9b388 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Unit/Controller/ErrorControllerTest.php @@ -0,0 +1,406 @@ +<?php +declare(strict_types = 1); +namespace TYPO3\CMS\Frontend\Tests\Unit\Controller; + +/* + * 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 TYPO3\CMS\Core\Http\HtmlResponse; +use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Frontend\Controller\ErrorController; + +/** + * Testcase for \TYPO3\CMS\Frontend\Controller\ErrorController + */ +class ErrorControllerTest extends \TYPO3\TestingFramework\Core\Unit\UnitTestCase +{ + + /** + * Tests concerning pageNotFound handling + */ + + /** + * @test + */ + public function pageNotFoundHandlingThrowsExceptionIfNotConfigured() + { + $this->expectExceptionMessage('This test page was not found!'); + $this->expectExceptionCode(1518472189); + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling'] = false; + $subject = new ErrorController(); + $subject->pageNotFoundAction('This test page was not found!'); + } + + /** + * Data Provider for 404 + * + * @return array + */ + public function errorPageHandlingDataProvider() + { + return [ + '404 with default errorpage' => [ + 'handler' => true, + 'header' => 'HTTP/1.0 404 Not Found', + 'message' => 'Custom message', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 404, + 'reasonPhrase' => 'Not Found', + 'content' => 'Reason: Custom message', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '404 with default errorpage setting the handler to legacy value' => [ + 'handler' => '1', + 'header' => 'HTTP/1.0 404 This is a dead end', + 'message' => 'Come back tomorrow', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 404, + 'reasonPhrase' => 'This is a dead end', + 'content' => 'Reason: Come back tomorrow', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '404 with custom userfunction' => [ + 'handler' => 'USER_FUNCTION:' . ErrorControllerTest::class . '->mockedUserFunctionCall', + 'header' => 'HTTP/1.0 404 Not Found', + 'message' => 'Custom message', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 404, + 'reasonPhrase' => 'Not Found', + 'content' => 'It\'s magic, Michael: Custom message', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '404 with a readfile functionality' => [ + 'handler' => 'READFILE:LICENSE.txt', + 'header' => 'HTTP/1.0 404 Not Found', + 'message' => 'Custom message', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 404, + 'reasonPhrase' => 'Not Found', + 'content' => 'GNU GENERAL PUBLIC LICENSE', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '404 with a readfile functionality with an invalid file' => [ + 'handler' => 'READFILE:does_not_exist.php6', + 'header' => 'HTTP/1.0 404 Not Found', + 'message' => 'Custom message', + 'response' => null, + 'exceptionCode' => 1518472245, + ], + '404 with a redirect - never do that in production - it is bad for SEO. But with custom headers as well...' => [ + 'handler' => 'REDIRECT:www.typo3.org', + 'header' => 'HTTP/1.0 404 Not Found +X-TYPO3-Additional-Header: Banana Stand', + 'message' => 'Custom message', + 'response' => [ + 'type' => RedirectResponse::class, + 'statusCode' => 404, + 'reasonPhrase' => 'Not Found', + 'headers' => [ + 'location' => ['www.typo3.org'], + 'X-TYPO3-Additional-Header' => ['Banana Stand'], + ] + ] + ], + 'Custom path, no prefix' => [ + 'handler' => '/404/', + 'header' => 'HTTP/1.0 404 Not Found +X-TYPO3-Additional-Header: Banana Stand', + 'message' => 'Custom message', + 'response' => [ + 'type' => RedirectResponse::class, + 'statusCode' => 404, + 'reasonPhrase' => 'Not Found', + 'headers' => [ + 'location' => ['https://localhost/404/'], + 'X-TYPO3-Additional-Header' => ['Banana Stand'], + ] + ] + ], + ]; + } + + /** + * @test + * @dataProvider errorPageHandlingDataProvider + */ + public function pageNotFoundHandlingReturnsConfiguredResponseObject($handler, $header, $message, $expectedResponseDetails, $expectedExceptionCode = null) + { + if ($expectedExceptionCode !== null) { + $this->expectExceptionCode($expectedExceptionCode); + } + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling'] = $handler; + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_statheader'] = $header; + // faking getIndpEnv() variables + $_SERVER['REQUEST_URI'] = '/unit-test/'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['HTTP_HOST'] = 'localhost'; + $_SERVER['SSL_SESSION_ID'] = true; + $subject = new ErrorController(); + $response = $subject->pageNotFoundAction($message); + if (is_array($expectedResponseDetails)) { + $this->assertInstanceOf($expectedResponseDetails['type'], $response); + $this->assertEquals($expectedResponseDetails['statusCode'], $response->getStatusCode()); + $this->assertEquals($expectedResponseDetails['reasonPhrase'], $response->getReasonPhrase()); + if (isset($expectedResponseDetails['content'])) { + $this->assertContains($expectedResponseDetails['content'], $response->getBody()->getContents()); + } + $this->assertEquals($expectedResponseDetails['headers'], $response->getHeaders()); + } + } + + /** + * Tests concerning accessDenied handling + */ + + /** + * Data Provider for 403 + * + * @return array + */ + public function accessDeniedDataProvider() + { + return [ + '403 with default errorpage' => [ + 'handler' => true, + 'header' => 'HTTP/1.0 403 Who are you', + 'message' => 'Be nice, do good', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 403, + 'reasonPhrase' => 'Who are you', + 'content' => 'Reason: Be nice, do good', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + ]; + } + + /** + * @test + * @dataProvider accessDeniedDataProvider + */ + public function accessDeniedReturnsProperHeaders($handler, $header, $message, $expectedResponseDetails) + { + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling'] = $handler; + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_accessdeniedheader'] = $header; + // faking getIndpEnv() variables + $_SERVER['REQUEST_URI'] = '/unit-test/'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['HTTP_HOST'] = 'localhost'; + $_SERVER['SSL_SESSION_ID'] = true; + $subject = new ErrorController(); + $response = $subject->accessDeniedAction($message); + if (is_array($expectedResponseDetails)) { + $this->assertInstanceOf($expectedResponseDetails['type'], $response); + $this->assertEquals($expectedResponseDetails['statusCode'], $response->getStatusCode()); + $this->assertEquals($expectedResponseDetails['reasonPhrase'], $response->getReasonPhrase()); + if (isset($expectedResponseDetails['content'])) { + $this->assertContains($expectedResponseDetails['content'], $response->getBody()->getContents()); + } + $this->assertEquals($expectedResponseDetails['headers'], $response->getHeaders()); + } + } + + /** + * Tests concerning unavailable handling + */ + + /** + * @test + */ + public function unavailableHandlingThrowsExceptionIfNotConfigured() + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask'] = '*'; + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'] = true; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $this->expectExceptionMessage('All your system are belong to us!'); + $this->expectExceptionCode(1518472181); + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'] = false; + $subject = new ErrorController(); + $subject->unavailableAction('All your system are belong to us!'); + } + + /** + * @test + */ + public function unavailableHandlingDoesNotTriggerDueToDevIpMask() + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask'] = '*'; + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'] = true; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + $this->expectExceptionMessage('All your system are belong to us!'); + $this->expectExceptionCode(1518472181); + $subject = new ErrorController(); + $subject->unavailableAction('All your system are belong to us!'); + } + /** + * Data Provider for 503 + * + * @return array + */ + public function unavailableHandlingDataProvider() + { + return [ + '503 with default errorpage' => [ + 'handler' => true, + 'header' => 'HTTP/1.0 503 Service Temporarily Unavailable', + 'message' => 'Custom message', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 503, + 'reasonPhrase' => 'Not Found', + 'content' => 'Reason: Custom message', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '503 with default errorpage setting the handler to legacy value' => [ + 'handler' => '1', + 'header' => 'HTTP/1.0 503 This is a dead end', + 'message' => 'Come back tomorrow', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 503, + 'reasonPhrase' => 'This is a dead end', + 'content' => 'Reason: Come back tomorrow', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '503 with custom userfunction' => [ + 'handler' => 'USER_FUNCTION:' . ErrorControllerTest::class . '->mockedUserFunctionCall', + 'header' => 'HTTP/1.0 503 Service Temporarily Unavailable', + 'message' => 'Custom message', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 503, + 'reasonPhrase' => 'Not Found', + 'content' => 'It\'s magic, Michael: Custom message', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '503 with a readfile functionality' => [ + 'handler' => 'READFILE:LICENSE.txt', + 'header' => 'HTTP/1.0 503 Service Temporarily Unavailable', + 'message' => 'Custom message', + 'response' => [ + 'type' => HtmlResponse::class, + 'statusCode' => 503, + 'reasonPhrase' => 'Not Found', + 'content' => 'GNU GENERAL PUBLIC LICENSE', + 'headers' => [ + 'Content-Type' => ['text/html; charset=utf-8'] + ] + ] + ], + '503 with a readfile functionality with an invalid file' => [ + 'handler' => 'READFILE:does_not_exist.php6', + 'header' => 'HTTP/1.0 503 Service Temporarily Unavailable', + 'message' => 'Custom message', + 'response' => null, + 'exceptionCode' => 1518472245, + ], + '503 with a redirect - never do that in production - it is bad for SEO. But with custom headers as well...' => [ + 'handler' => 'REDIRECT:www.typo3.org', + 'header' => 'HTTP/1.0 503 Service Temporarily Unavailable +X-TYPO3-Additional-Header: Banana Stand', + 'message' => 'Custom message', + 'response' => [ + 'type' => RedirectResponse::class, + 'statusCode' => 503, + 'reasonPhrase' => 'Not Found', + 'headers' => [ + 'location' => ['www.typo3.org'], + 'X-TYPO3-Additional-Header' => ['Banana Stand'], + ] + ] + ], + 'Custom path, no prefix' => [ + 'handler' => '/fail/', + 'header' => 'HTTP/1.0 503 Service Temporarily Unavailable +X-TYPO3-Additional-Header: Banana Stand', + 'message' => 'Custom message', + 'response' => [ + 'type' => RedirectResponse::class, + 'statusCode' => 503, + 'reasonPhrase' => 'Not Found', + 'headers' => [ + 'location' => ['https://localhost/fail/'], + 'X-TYPO3-Additional-Header' => ['Banana Stand'], + ] + ] + ], + ]; + } + + /** + * @test + * @dataProvider errorPageHandlingDataProvider + */ + public function pageUnavailableHandlingReturnsConfiguredResponseObject($handler, $header, $message, $expectedResponseDetails, $expectedExceptionCode = null) + { + if ($expectedExceptionCode !== null) { + $this->expectExceptionCode($expectedExceptionCode); + } + $GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask'] = '-1'; + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'] = $handler; + $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling_statheader'] = $header; + // faking getIndpEnv() variables + $_SERVER['REQUEST_URI'] = '/unit-test/'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['HTTP_HOST'] = 'localhost'; + $_SERVER['SSL_SESSION_ID'] = true; + $subject = new ErrorController(); + $response = $subject->unavailableAction($message); + if (is_array($expectedResponseDetails)) { + $this->assertInstanceOf($expectedResponseDetails['type'], $response); + $this->assertEquals($expectedResponseDetails['statusCode'], $response->getStatusCode()); + $this->assertEquals($expectedResponseDetails['reasonPhrase'], $response->getReasonPhrase()); + if (isset($expectedResponseDetails['content'])) { + $this->assertContains($expectedResponseDetails['content'], $response->getBody()->getContents()); + } + $this->assertEquals($expectedResponseDetails['headers'], $response->getHeaders()); + } + } + + /** + * Callback function when testing "USER_FUNCTION:" prefix + */ + public function mockedUserFunctionCall($params) + { + return '<p>It\'s magic, Michael: ' . $params['reasonText'] . '</p>'; + } +} diff --git a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php index 06eb3e7ad5c1960c75f41a2b46b22fe0ee7e1415..d84c2104b468abed8a7fd30c4481ca753858ce4e 100644 --- a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php +++ b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php @@ -1549,4 +1549,46 @@ return [ 'Deprecation-83252-Link-tagSyntaxProcesssing.rst', ], ], + 'TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageUnavailableAndExit' => [ + 'numberOfMandatoryArguments' => 0, + 'maximumNumberOfArguments' => 0, + 'restFiles' => [ + 'Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst', + ], + ], + 'TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageNotFoundAndExit' => [ + 'numberOfMandatoryArguments' => 0, + 'maximumNumberOfArguments' => 0, + 'restFiles' => [ + 'Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst', + ], + ], + 'TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkPageUnavailableHandler' => [ + 'numberOfMandatoryArguments' => 0, + 'maximumNumberOfArguments' => 0, + 'restFiles' => [ + 'Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst', + ], + ], + 'TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageUnavailableHandler' => [ + 'numberOfMandatoryArguments' => 0, + 'maximumNumberOfArguments' => 0, + 'restFiles' => [ + 'Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst', + ], + ], + 'TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageNotFoundHandler' => [ + 'numberOfMandatoryArguments' => 0, + 'maximumNumberOfArguments' => 0, + 'restFiles' => [ + 'Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst', + ], + ], + 'TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageErrorHandler' => [ + 'numberOfMandatoryArguments' => 0, + 'maximumNumberOfArguments' => 0, + 'restFiles' => [ + 'Deprecation-83883-PageNotFoundAndErrorHandlingInFrontend.rst', + ], + ], ];