diff --git a/composer.json b/composer.json index 9bc716f7a5d2cf3a197872de840853a4fbcfd4c4..fa55dde35f446ab8160bb4282a47111376a10a15 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "symfony/finder": "2.6.9", "doctrine/instantiator": "1.0.4", "helhum/class-alias-loader": "1.1.9", - "typo3/cms-composer-installers": "1.2.1" + "typo3/cms-composer-installers": "1.2.1", + "psr/http-message": "1.0" }, "require-dev": { "mikey179/vfsStream": "1.4.*@dev", diff --git a/typo3/index.php b/typo3/index.php index 387119829f1914b67e5d1ae8f97e98e713f95070..69514c7b1a674b9f06f10293476740378d390d65 100644 --- a/typo3/index.php +++ b/typo3/index.php @@ -19,9 +19,5 @@ */ call_user_func(function() { $classLoader = require __DIR__ . '/contrib/vendor/autoload.php'; - (new \TYPO3\CMS\Backend\Http\Application($classLoader))->run(function() { - // currently implemented as a closure as there is no Request/Response implementation or routing in the backend - $loginController = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Controller\LoginController::class); - $loginController->main(); - }); + (new \TYPO3\CMS\Backend\Http\Application($classLoader))->run(); }); diff --git a/typo3/sysext/backend/Classes/Console/CliRequestHandler.php b/typo3/sysext/backend/Classes/Console/CliRequestHandler.php index 62d4a39a3879a70cc8d40fcde80257e4d1b2e60b..3e9c24f3f2606f303cb7bea8c79bea86227af732 100644 --- a/typo3/sysext/backend/Classes/Console/CliRequestHandler.php +++ b/typo3/sysext/backend/Classes/Console/CliRequestHandler.php @@ -14,6 +14,7 @@ namespace TYPO3\CMS\Backend\Console; * The TYPO3 project - inspiring people to share! */ +use Psr\Http\Message\ServerRequestInterface; use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Core\RequestHandlerInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -44,9 +45,10 @@ class CliRequestHandler implements RequestHandlerInterface { /** * Handles any commandline request * + * @param ServerRequestInterface $request * @return void */ - public function handleRequest() { + public function handleRequest(ServerRequestInterface $request) { $commandLineKey = $this->getCommandLineKeyOrDie(); $commandLineScript = $this->getIncludeScriptByCommandLineKey($commandLineKey); @@ -137,9 +139,10 @@ class CliRequestHandler implements RequestHandlerInterface { /** * This request handler can handle any CLI request . * + * @param ServerRequestInterface $request * @return bool If the request is a CLI request, TRUE otherwise FALSE */ - public function canHandleRequest() { + public function canHandleRequest(ServerRequestInterface $request) { return defined('TYPO3_cliMode') && (TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_BE) && (TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_CLI); } diff --git a/typo3/sysext/backend/Classes/Controller/LoginController.php b/typo3/sysext/backend/Classes/Controller/LoginController.php index 460ab45936dd0795d87d4dcad8bfafcfdc3f928f..ee522e9a21d42fa7a5f24d46bfcee3a31529bf50 100644 --- a/typo3/sysext/backend/Classes/Controller/LoginController.php +++ b/typo3/sysext/backend/Classes/Controller/LoginController.php @@ -31,7 +31,7 @@ use TYPO3\CMS\Fluid\View\StandaloneView; * @author Kasper Skårhøj <kasperYYYY@typo3.com> * @author Frank Nägler <typo3@naegler.net> */ -class LoginController { +class LoginController implements \TYPO3\CMS\Core\Http\ControllerInterface { /** * The URL to redirect to after login. @@ -121,11 +121,27 @@ class LoginController { $this->view = $this->getFluidTemplateObject(); } + /** + * Injects the request object for the current request or subrequest + * As this controller goes only through the main() method, it is rather simple for now + * This will be split up in an abstract controller once proper routing/dispatcher is in place. + * + * @param \Psr\Http\Message\RequestInterface $request + * @return \Psr\Http\Message\ResponseInterface $response + */ + public function processRequest(\Psr\Http\Message\RequestInterface $request) { + $content = $this->main(); + /** @var \TYPO3\CMS\Core\Http\Response $response */ + $response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\Response::class); + $response->getBody()->write($content); + return $response; + } + /** * Main function - creating the login/logout form * * @throws Exception - * @return void + * @return string The content to output */ public function main() { /** @var $pageRenderer \TYPO3\CMS\Core\Page\PageRenderer */ @@ -221,7 +237,7 @@ class LoginController { $content .= $this->view->render(); $content .= $this->getDocumentTemplate()->endPage(); - echo $content; + return $content; } /** diff --git a/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php b/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php index ac11642e187faf627de227a924f4de9ea14e5eeb..306d7d2d501f5fae9bf5e63b14f60812d6ef5535 100644 --- a/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php +++ b/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php @@ -17,6 +17,7 @@ namespace TYPO3\CMS\Backend\Http; use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Core\RequestHandlerInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; +use Psr\Http\Message\ServerRequestInterface; /** * Base class for all AJAX-related calls for the TYPO3 Backend run through typo3/ajax.php. @@ -49,7 +50,7 @@ class AjaxRequestHandler implements RequestHandlerInterface { ); /** - * Constructor handing over the bootstrap + * Constructor handing over the bootstrap and the original request * * @param Bootstrap $bootstrap */ @@ -60,15 +61,14 @@ class AjaxRequestHandler implements RequestHandlerInterface { /** * Handles any AJAX request in the TYPO3 Backend * - * @return void + * @param ServerRequestInterface $request + * @return NULL|\Psr\Http\Message\ResponseInterface */ - public function handleRequest() { + public function handleRequest(ServerRequestInterface $request) { // First get the ajaxID - $ajaxID = isset($_POST['ajaxID']) ? $_POST['ajaxID'] : $_GET['ajaxID']; - if (isset($ajaxID)) { - $ajaxID = (string)stripslashes($ajaxID); - } + $ajaxID = isset($request->getParsedBody()['ajaxID']) ? $request->getParsedBody()['ajaxID'] : $request->getQueryParams()['ajaxID']; + // used for backwards-compatibility $GLOBALS['ajaxID'] = $ajaxID; $this->boot($ajaxID); @@ -94,7 +94,8 @@ class AjaxRequestHandler implements RequestHandlerInterface { $success = TRUE; $tokenIsValid = TRUE; if ($csrfTokenCheck) { - $tokenIsValid = \TYPO3\CMS\Core\FormProtection\FormProtectionFactory::get()->validateToken(GeneralUtility::_GP('ajaxToken'), 'ajaxCall', $ajaxID); + $ajaxToken = $request->getParsedBody()['ajaxToken'] ?: $request->getQueryParams()['ajaxToken']; + $tokenIsValid = \TYPO3\CMS\Core\FormProtection\FormProtectionFactory::get()->validateToken($ajaxToken, 'ajaxCall', $ajaxID); } if ($tokenIsValid) { // Cleanup global variable space @@ -110,14 +111,17 @@ class AjaxRequestHandler implements RequestHandlerInterface { // Outputting the content (and setting the X-JSON-Header) $ajaxObj->render(); + + return NULL; } /** * This request handler can handle any backend request coming from ajax.php * + * @param ServerRequestInterface $request * @return bool If the request is an AJAX backend request, TRUE otherwise FALSE */ - public function canHandleRequest() { + public function canHandleRequest(ServerRequestInterface $request) { return TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_AJAX; } diff --git a/typo3/sysext/backend/Classes/Http/BackendModuleRequestHandler.php b/typo3/sysext/backend/Classes/Http/BackendModuleRequestHandler.php index c06b092d094d9977949cc54856857be2fb9aec8b..e31d852aad95b6697a2fb4e80289ff14a42e6d27 100644 --- a/typo3/sysext/backend/Classes/Http/BackendModuleRequestHandler.php +++ b/typo3/sysext/backend/Classes/Http/BackendModuleRequestHandler.php @@ -20,6 +20,7 @@ use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; use TYPO3\CMS\Core\Exception; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Object\ObjectManager; +use Psr\Http\Message\ServerRequestInterface; /** * Handles the request for backend modules and wizards @@ -42,7 +43,15 @@ class BackendModuleRequestHandler implements \TYPO3\CMS\Core\Core\RequestHandler protected $backendUserAuthentication; /** - * @param Bootstrap $bootstrap The TYPO3 core bootstrap + * 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; @@ -51,9 +60,12 @@ class BackendModuleRequestHandler implements \TYPO3\CMS\Core\Core\RequestHandler /** * 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() { + public function handleRequest(ServerRequestInterface $request) { + $this->request = $request; $this->boot(); $this->moduleRegistry = $GLOBALS['TBE_MODULES']; @@ -67,7 +79,7 @@ class BackendModuleRequestHandler implements \TYPO3\CMS\Core\Core\RequestHandler $this->backendUserAuthentication = $GLOBALS['BE_USER']; - $moduleName = (string)GeneralUtility::_GET('M'); + $moduleName = (string)$this->request->getQueryParams()['M']; if ($this->isDispatchedModule($moduleName)) { $isDispatched = $this->dispatchModule($moduleName); } else { @@ -107,10 +119,11 @@ class BackendModuleRequestHandler implements \TYPO3\CMS\Core\Core\RequestHandler /** * This request handler can handle any backend request coming from mod.php * + * @param ServerRequestInterface $request * @return bool */ - public function canHandleRequest() { - return (TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_BE) && !empty((string)GeneralUtility::_GET('M')); + public function canHandleRequest(ServerRequestInterface $request) { + return (TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_BE) && !empty((string)$request->getQueryParams()['M']); } /** @@ -119,7 +132,7 @@ class BackendModuleRequestHandler implements \TYPO3\CMS\Core\Core\RequestHandler * @return bool */ protected function isValidModuleRequest() { - return $this->getFormProtection()->validateToken((string)GeneralUtility::_GP('moduleToken'), 'moduleCall', (string)GeneralUtility::_GET('M')); + return $this->getFormProtection()->validateToken((string)$this->request->getQueryParams()['moduleToken'], 'moduleCall', (string)$this->request->getQueryParams()['M']); } /** diff --git a/typo3/sysext/backend/Classes/Http/RequestHandler.php b/typo3/sysext/backend/Classes/Http/RequestHandler.php index bf4ed837cbd0d01ee8ca8148c525083cf23ffe21..489616e4d63e1bc7735a27ffbf05855319a6b411 100644 --- a/typo3/sysext/backend/Classes/Http/RequestHandler.php +++ b/typo3/sysext/backend/Classes/Http/RequestHandler.php @@ -16,6 +16,7 @@ namespace TYPO3\CMS\Backend\Http; use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Core\RequestHandlerInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; /** * General RequestHandler for the TYPO3 Backend. This is used for all Backend requests except for CLI @@ -33,7 +34,7 @@ class RequestHandler implements RequestHandlerInterface { protected $bootstrap; /** - * Constructor handing over the bootstrap + * Constructor handing over the bootstrap and the original request * * @param Bootstrap $bootstrap */ @@ -44,9 +45,14 @@ class RequestHandler implements RequestHandlerInterface { /** * Handles any backend request * - * @return void + * @param \Psr\Http\Message\ServerRequestInterface $request + * @return NULL|\Psr\Http\Message\ResponseInterface */ - public function handleRequest() { + public function handleRequest(\Psr\Http\Message\ServerRequestInterface $request) { + // enable dispatching via Request/Response logic only for typo3/index.php currently + $path = substr($request->getUri()->getPath(), strlen(GeneralUtility::getIndpEnv('TYPO3_SITE_PATH'))); + $routingEnabled = ($path === TYPO3_mainDir . 'index.php' || $path === TYPO3_mainDir); + // Evaluate the constant for skipping the BE user check for the bootstrap if (defined('TYPO3_PROCEED_IF_NO_USER') && TYPO3_PROCEED_IF_NO_USER) { $proceedIfNoUserIsLoggedIn = TRUE; @@ -68,14 +74,20 @@ class RequestHandler implements RequestHandlerInterface { ->endOutputBufferingAndCleanPreviousOutput() ->initializeOutputCompression() ->sendHttpHeaders(); + + if ($routingEnabled) { + return $this->dispatch($request); + } + return NULL; } /** * This request handler can handle any backend request (but not CLI). * + * @param \Psr\Http\Message\ServerRequestInterface $request * @return bool If the request is not a CLI script, TRUE otherwise FALSE */ - public function canHandleRequest() { + public function canHandleRequest(\Psr\Http\Message\ServerRequestInterface $request) { return (TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_BE && !(TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_CLI)); } @@ -88,4 +100,19 @@ class RequestHandler implements RequestHandlerInterface { public function getPriority() { return 50; } + + /** + * Dispatch the request to the appropriate controller, will go to a proper dispatcher/router class in the future + * + * @internal + * @param \Psr\Http\Message\RequestInterface $request + * @return NULL|\Psr\Http\Message\ResponseInterface + */ + protected function dispatch($request) { + $controller = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Controller\LoginController::class); + if ($controller instanceof \TYPO3\CMS\Core\Http\ControllerInterface) { + return $controller->processRequest($request); + } + return NULL; + } } diff --git a/typo3/sysext/backend/Tests/Unit/BackendModuleRequestHandlerTest.php b/typo3/sysext/backend/Tests/Unit/Http/BackendModuleRequestHandlerTest.php similarity index 74% rename from typo3/sysext/backend/Tests/Unit/BackendModuleRequestHandlerTest.php rename to typo3/sysext/backend/Tests/Unit/Http/BackendModuleRequestHandlerTest.php index 654ef29c088fa7ed197ff1a623eb836c989e0f11..9c7d054411887a1eaf1f85c55c198156f485947a 100644 --- a/typo3/sysext/backend/Tests/Unit/BackendModuleRequestHandlerTest.php +++ b/typo3/sysext/backend/Tests/Unit/Http/BackendModuleRequestHandlerTest.php @@ -1,5 +1,5 @@ <?php -namespace TYPO3\CMS\Backend\Tests\Unit; +namespace TYPO3\CMS\Backend\Tests\Unit\Http; /* * This file is part of the TYPO3 CMS project. @@ -35,9 +35,15 @@ class BackendModuleRequestHandlerTest extends UnitTestCase { */ protected $formProtectionMock; + /** + * @var \TYPO3\CMS\Core\Http\ServerRequest|PHPUnit_Framework_MockObject_MockObject + */ + protected $requestMock; + public function setUp() { + $this->requestMock = $this->getAccessibleMock(\TYPO3\CMS\Core\Http\ServerRequest::class, array(), array(), '', FALSE); $this->formProtectionMock = $this->getMockForAbstractClass(AbstractFormProtection::class, array(), '', TRUE, TRUE, TRUE, array('validateToken')); - $this->subject = $this->getAccessibleMock(BackendModuleRequestHandler::class, array('boot', 'getFormProtection'), array(), '', FALSE); + $this->subject = $this->getAccessibleMock(BackendModuleRequestHandler::class, array('boot', 'getFormProtection'), array(\TYPO3\CMS\Core\Core\Bootstrap::getInstance()), '', TRUE); } /** @@ -48,16 +54,16 @@ class BackendModuleRequestHandlerTest extends UnitTestCase { public function moduleIndexIsCalled() { $GLOBALS['TBE_MODULES'] = array( '_PATHS' => array( - 'module_fixture' => __DIR__ . '/Fixtures/ModuleFixture/' + 'module_fixture' => __DIR__ . '/../Fixtures/ModuleFixture/' ) ); - $_GET['M'] = 'module_fixture'; + $this->requestMock->expects($this->any())->method('getQueryParams')->will($this->returnValue(array('M' => 'module_fixture'))); $this->formProtectionMock->expects($this->once())->method('validateToken')->will($this->returnValue(TRUE)); $this->subject->expects($this->once())->method('boot'); $this->subject->expects($this->once())->method('getFormProtection')->will($this->returnValue($this->formProtectionMock)); - $this->subject->handleRequest(); + $this->subject->handleRequest($this->requestMock); } /** @@ -70,7 +76,7 @@ class BackendModuleRequestHandlerTest extends UnitTestCase { $this->subject->expects($this->once())->method('boot'); $this->subject->expects($this->once())->method('getFormProtection')->will($this->returnValue($this->formProtectionMock)); - $this->subject->handleRequest(); + $this->subject->handleRequest($this->requestMock); } /** @@ -82,16 +88,15 @@ class BackendModuleRequestHandlerTest extends UnitTestCase { $GLOBALS['TBE_MODULES'] = array( '_PATHS' => array( '_dispatcher' => array(), - 'module_fixture' => __DIR__ . '/Fixtures/ModuleFixture/' + 'module_fixture' => __DIR__ . '/../Fixtures/ModuleFixture/' ) ); - $_GET['M'] = 'module_fixture'; - + $this->requestMock->expects($this->any())->method('getQueryParams')->will($this->returnValue(array('M' => 'module_fixture'))); $this->formProtectionMock->expects($this->once())->method('validateToken')->will($this->returnValue(TRUE)); $this->subject->expects($this->once())->method('boot'); $this->subject->expects($this->once())->method('getFormProtection')->will($this->returnValue($this->formProtectionMock)); - $this->subject->handleRequest(); + $this->subject->handleRequest($this->requestMock); } } diff --git a/typo3/sysext/core/Classes/Core/Bootstrap.php b/typo3/sysext/core/Classes/Core/Bootstrap.php index 0e4ec6d6413ea917ef6d28ecc26098f50a04a630..43c98e482e6f95e74d74fec22eec0afbf2b60212 100644 --- a/typo3/sysext/core/Classes/Core/Bootstrap.php +++ b/typo3/sysext/core/Classes/Core/Bootstrap.php @@ -73,11 +73,18 @@ class Bootstrap { protected $activeErrorHandlerClassName; /** - * registered request handlers + * A list of all registered request handlers, see the Application class / entry points for the registration * @var RequestHandlerInterface[] */ protected $availableRequestHandlers = array(); + /** + * The Response object when using Request/Response logic + * @var \Psr\Http\Message\ResponseInterface + * @see shutdown() + */ + protected $response; + /** * @var bool */ @@ -178,19 +185,6 @@ class Bootstrap { return $this; } - /** - * Resolve the request handler that were registered based on the application - * and execute the request - * - * @return Bootstrap - * @throws \TYPO3\CMS\Core\Exception - */ - public function handleRequest() { - $requestHandler = $this->resolveRequestHandler(); - $requestHandler->handleRequest(); - return $this; - } - /** * Run the base setup that checks server environment, determines pathes, * populates base files and sets common configuration. @@ -265,15 +259,16 @@ class Bootstrap { * Be sure to always have the constants that are defined in $this->defineTypo3RequestTypes() are set, * so most RequestHandlers can check if they can handle the request. * + * @param \Psr\Http\Message\ServerRequestInterface $request * @return RequestHandlerInterface * @throws \TYPO3\CMS\Core\Exception * @internal This is not a public API method, do not use in own extensions */ - public function resolveRequestHandler() { + protected function resolveRequestHandler(\Psr\Http\Message\ServerRequestInterface $request) { $suitableRequestHandlers = array(); foreach ($this->availableRequestHandlers as $requestHandlerClassName) { $requestHandler = GeneralUtility::makeInstance($requestHandlerClassName, $this); - if ($requestHandler->canHandleRequest()) { + if ($requestHandler->canHandleRequest($request)) { $priority = $requestHandler->getPriority(); if (isset($suitableRequestHandlers[$priority])) { throw new \TYPO3\CMS\Core\Exception('More than one request handler with the same priority can handle the request, but only one handler may be active at a time!', 1176471352); @@ -288,6 +283,42 @@ class Bootstrap { return array_pop($suitableRequestHandlers); } + /** + * Builds a Request instance from the current process, and then resolves the request + * through the request handlers depending on Frontend, Backend, CLI etc. + * + * @return Bootstrap + * @throws \TYPO3\CMS\Core\Exception + */ + protected function handleRequest() { + // Build the Request object + $request = \TYPO3\CMS\Core\Http\ServerRequestFactory::fromGlobals(); + + // Resolve request handler that were registered based on the Application + $requestHandler = $this->resolveRequestHandler($request); + + // Execute the command which returns a Response object or NULL + $this->response = $requestHandler->handleRequest($request); + return $this; + } + + /** + * Outputs content if there is a proper Response object. + * + * @return Bootstrap + */ + protected function sendResponse() { + if ($this->response instanceof \Psr\Http\Message\ResponseInterface) { + if (!headers_sent()) { + foreach ($this->response->getHeaders() as $name => $values) { + header($name . ': ' . implode(', ', $values), FALSE); + } + } + echo $this->response->getBody()->__toString(); + } + return $this; + } + /** * Registers the instance of the specified object for an early boot stage. * On finalizing the Object Manager initialization, all those instances will @@ -1140,6 +1171,7 @@ class Bootstrap { * @internal This is not a public API method, do not use in own extensions */ public function shutdown() { + $this->sendResponse(); return $this; } diff --git a/typo3/sysext/core/Classes/Core/RequestHandlerInterface.php b/typo3/sysext/core/Classes/Core/RequestHandlerInterface.php index cdddae390b09af1602afd3fee9da3ef858a4904a..7af6f4258c5542a909ebd4210bf4ad2abe30cee0 100644 --- a/typo3/sysext/core/Classes/Core/RequestHandlerInterface.php +++ b/typo3/sysext/core/Classes/Core/RequestHandlerInterface.php @@ -16,7 +16,7 @@ namespace TYPO3\CMS\Core\Core; /** * The interface for a request handler - * see FrontendRequestHandler + * see RequestHandler in EXT:backend/Classes/Http/ and EXT:frontend/Classes/Http * * @api */ @@ -25,18 +25,20 @@ interface RequestHandlerInterface { /** * Handles a raw request * - * @return void + * @param \Psr\Http\Message\ServerRequestInterface $request + * @return NULL|\Psr\Http\Message\ResponseInterface * @api */ - public function handleRequest(); + public function handleRequest(\Psr\Http\Message\ServerRequestInterface $request); /** - * Checks if the request handler can handle the current request. + * Checks if the request handler can handle the given request. * + * @param \Psr\Http\Message\ServerRequestInterface $request * @return bool TRUE if it can handle the request, otherwise FALSE * @api */ - public function canHandleRequest(); + public function canHandleRequest(\Psr\Http\Message\ServerRequestInterface $request); /** * Returns the priority - how eager the handler is to actually handle the @@ -47,4 +49,5 @@ interface RequestHandlerInterface { * @api */ public function getPriority(); + } diff --git a/typo3/sysext/core/Classes/Http/ControllerInterface.php b/typo3/sysext/core/Classes/Http/ControllerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..e2d275e7c4911ea394e603549171c4e58257ba38 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/ControllerInterface.php @@ -0,0 +1,37 @@ +<?php +namespace TYPO3\CMS\Core\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\ResponseInterface; +use Psr\Http\Message\RequestInterface; + +/** + * An interface every controller should implement + * in order to deal with PSR-7 standard. + * + * @internal please note that this API will be extended until TYPO3 CMS 7 LTS and is not public yet. + */ +interface ControllerInterface { + + /** + * Processes a typical request. + * + * @param RequestInterface $request The request object + * @return ResponseInterface $response The response, created by the controller + * @api + */ + public function processRequest(RequestInterface $request); + +} diff --git a/typo3/sysext/core/Classes/Http/Message.php b/typo3/sysext/core/Classes/Http/Message.php new file mode 100644 index 0000000000000000000000000000000000000000..e5938b840f3d2eb0d85bfb72720d31979b8f5e1a --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Message.php @@ -0,0 +1,472 @@ +<?php +namespace TYPO3\CMS\Core\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\MessageInterface; +use Psr\Http\Message\StreamInterface; + +/** + * Default implementation for the MessageInterface of the PSR-7 standard + * It is the base for any request or response for PSR-7. + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class Message implements MessageInterface { + + /** + * The HTTP Protocol version, defaults to 1.1 + * @var string + */ + protected $protocolVersion = '1.1'; + + /** + * Associative array containing all headers of this Message + * This is a mixed-case list of the headers (as due to the specification) + * @var array + */ + protected $headers = array(); + + /** + * Lowercased version of all headers, in order to check if a header is set or not + * this way a lot of checks are easier to be set + * @var array + */ + protected $lowercasedHeaderNames = array(); + + /** + * The body as a Stream object + * @var StreamInterface + */ + protected $body; + + /** + * Retrieves the HTTP protocol version as a string. + * + * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0"). + * + * @return string HTTP protocol version. + */ + public function getProtocolVersion() { + return $this->protocolVersion; + } + + /** + * Return an instance with the specified HTTP protocol version. + * + * The version string MUST contain only the HTTP version number (e.g., + * "1.1", "1.0"). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new protocol version. + * + * @param string $version HTTP protocol version + * @return Message + */ + public function withProtocolVersion($version) { + $clonedObject = clone $this; + $clonedObject->protocolVersion = $version; + return $clonedObject; + } + + /** + * Retrieves all message header values. + * + * The keys represent the header name as it will be sent over the wire, and + * each value is an array of strings associated with the header. + * + * // Represent the headers as a string + * foreach ($message->getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return array Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders() { + return $this->headers; + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name) { + return isset($this->lowercasedHeaderNames[strtolower($name)]); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name) { + if (!$this->hasHeader($name)) { + return array(); + } + $header = $this->lowercasedHeaderNames[strtolower($name)]; + $headerValue = $this->headers[$header]; + if (is_array($headerValue)) { + return $headerValue; + } else { + return array($headerValue); + } + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name) { + $headerValue = $this->getHeader($name); + if (empty($headerValue)) { + return ''; + } + return implode(',', $headerValue); + } + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return Message + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader($name, $value) { + if (is_string($value)) { + $value = array($value); + } + + if (!is_array($value) || !$this->arrayContainsOnlyStrings($value)) { + throw new \InvalidArgumentException('Invalid header value for header "' . $name . '"". The value must be a string or an array of strings.', 1436717266); + } + + $this->validateHeaderName($name); + $this->validateHeaderValues($value); + $lowercasedHeaderName = strtolower($name); + + $clonedObject = clone $this; + $clonedObject->headers[$name] = $value; + $clonedObject->lowercasedHeaderNames[$lowercasedHeaderName] = $name; + return $clonedObject; + } + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return Message + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader($name, $value) { + if (is_string($value)) { + $value = array($value); + } + if (!is_array($value) || !$this->arrayContainsOnlyStrings($value)) { + throw new \InvalidArgumentException('Invalid header value for header "' . $name . '". The header value must be a string or array of strings', 1436717267); + } + $this->validateHeaderName($name); + $this->validateHeaderValues($value); + if (!$this->hasHeader($name)) { + return $this->withHeader($name, $value); + } + $name = $this->lowercasedHeaderNames[strtolower($name)]; + $clonedObject = clone $this; + $clonedObject->headers[$name] = array_merge($this->headers[$name], $value); + return $clonedObject; + } + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return Message + */ + public function withoutHeader($name) { + if (!$this->hasHeader($name)) { + return clone $this; + } + // fetch the original header from the lowercased version + $lowercasedHeader = strtolower($name); + $name = $this->lowercasedHeaderNames[$lowercasedHeader]; + $clonedObject = clone $this; + unset($clonedObject->headers[$name], $clonedObject->lowercasedHeaderNames[$lowercasedHeader]); + return $clonedObject; + } + + /** + * Gets the body of the message. + * + * @return \Psr\Http\Message\StreamInterface Returns the body as a stream. + */ + public function getBody() { + return $this->body; + } + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param \Psr\Http\Message\StreamInterface $body Body. + * @return Message + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(StreamInterface $body) { + $clonedObject = clone $this; + $clonedObject->body = $body; + return $clonedObject; + } + + /** + * Ensure header names and values are valid. + * + * @param array $headers + * @throws \InvalidArgumentException + */ + protected function assertHeaders(array $headers) { + foreach ($headers as $name => $headerValues) { + $this->validateHeaderName($name); + // check if all values are correct + array_walk($headerValues, function($value, $key, Message $messageObject) { + if (!$messageObject->isValidHeaderValue($value)) { + throw new \InvalidArgumentException('Invalid header value for header "' . $key . '"', 1436717268); + } + }, $this); + } + } + + /** + * Filter a set of headers to ensure they are in the correct internal format. + * + * Used by message constructors to allow setting all initial headers at once. + * + * @param array $originalHeaders Headers to filter. + * @return array Filtered headers and names. + */ + protected function filterHeaders(array $originalHeaders) { + $headerNames = $headers = array(); + foreach ($originalHeaders as $header => $value) { + if (!is_string($header) || (!is_array($value) && !is_string($value))) { + continue; + } + if (!is_array($value)) { + $value = array($value); + } + $headerNames[strtolower($header)] = $header; + $headers[$header] = $value; + } + return array($headerNames, $headers); + } + + /** + * Helper function to test if an array contains only strings + * + * @param array $data + * @return bool + */ + protected function arrayContainsOnlyStrings(array $data) { + return array_reduce($data, function($original, $item) { + return is_string($item) ? $original : FALSE; + }, TRUE); + } + + /** + * Assert that the provided header values are valid. + * + * @see http://tools.ietf.org/html/rfc7230#section-3.2 + * @param string[] $values + * @throws \InvalidArgumentException + */ + protected function validateHeaderValues(array $values) { + array_walk($values, function($value, $key, Message $messageObject) { + if (!$messageObject->isValidHeaderValue($value)) { + throw new \InvalidArgumentException('Invalid header value for header "' . $key . '"', 1436717269); + } + }, $this); + } + + /** + * Filter a header value + * + * Ensures CRLF header injection vectors are filtered. + * + * Per RFC 7230, only VISIBLE ASCII characters, spaces, and horizontal + * tabs are allowed in values; header continuations MUST consist of + * a single CRLF sequence followed by a space or horizontal tab. + * + * This method filters any values not allowed from the string, and is + * lossy. + * + * @see http://en.wikipedia.org/wiki/HTTP_response_splitting + * @param string $value + * @return string + */ + public function filter($value) { + $value = (string)$value; + $length = strlen($value); + $string = ''; + for ($i = 0; $i < $length; $i += 1) { + $ascii = ord($value[$i]); + + // Detect continuation sequences + if ($ascii === 13) { + $lf = ord($value[$i + 1]); + $ws = ord($value[$i + 2]); + if ($lf === 10 && in_array($ws, [9, 32], TRUE)) { + $string .= $value[$i] . $value[$i + 1]; + $i += 1; + } + continue; + } + + // Non-visible, non-whitespace characters + // 9 === horizontal tab + // 32-126, 128-254 === visible + // 127 === DEL + // 255 === null byte + if (($ascii < 32 && $ascii !== 9) || $ascii === 127 || $ascii > 254) { + continue; + } + + $string .= $value[$i]; + } + + return $string; + } + + /** + * Check whether or not a header name is valid and throw an exception. + * + * @see http://tools.ietf.org/html/rfc7230#section-3.2 + * @param string $name + * @throws \InvalidArgumentException + */ + public function validateHeaderName($name) { + if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/', $name)) { + throw new \InvalidArgumentException('Invalid header name, given "' . $name . '"', 1436717270); + } + } + + /** + * Checks if a a HTTP header value is valid. + * + * Per RFC 7230, only VISIBLE ASCII characters, spaces, and horizontal + * tabs are allowed in values; header continuations MUST consist of + * a single CRLF sequence followed by a space or horizontal tab. + * + * @see http://en.wikipedia.org/wiki/HTTP_response_splitting + * @param string $value + * @return bool + */ + public function isValidHeaderValue($value) { + $value = (string)$value; + + // Look for: + // \n not preceded by \r, OR + // \r not followed by \n, OR + // \r\n not followed by space or horizontal tab; these are all CRLF attacks + if (preg_match("#(?:(?:(?<!\r)\n)|(?:\r(?!\n))|(?:\r\n(?![ \t])))#", $value)) { + return FALSE; + } + + $length = strlen($value); + for ($i = 0; $i < $length; $i += 1) { + $ascii = ord($value[$i]); + + // Non-visible, non-whitespace characters + // 9 === horizontal tab + // 10 === line feed + // 13 === carriage return + // 32-126, 128-254 === visible + // 127 === DEL + // 255 === null byte + if (($ascii < 32 && ! in_array($ascii, [9, 10, 13], TRUE)) || $ascii === 127 || $ascii > 254) { + return FALSE; + } + } + + return TRUE; + } + +} diff --git a/typo3/sysext/core/Classes/Http/Request.php b/typo3/sysext/core/Classes/Http/Request.php new file mode 100644 index 0000000000000000000000000000000000000000..9d7d05dc2c64334b56d983589f4bf81dde621406 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Request.php @@ -0,0 +1,349 @@ +<?php +namespace TYPO3\CMS\Core\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\RequestInterface; +use Psr\Http\Message\UriInterface; +use Psr\Http\Message\StreamInterface; + +/** + * Default implementation for the RequestInterface of the PSR-7 standard + * It is the base for any request sent BY PHP. + * + * Please see ServerRequest for the typical use cases in the framework. + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class Request extends Message implements RequestInterface { + + /** + * The request-target, if it has been provided or calculated. + * @var NULL|string + */ + protected $requestTarget; + + /** + * The HTTP method, defaults to GET + * + * @var string + */ + protected $method; + + /** + * Supported HTTP methods + * + * @var array + */ + protected $supportedMethods = array( + 'CONNECT', + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', + 'TRACE' + ); + + /** + * An instance of the Uri object + * @var UriInterface + */ + protected $uri; + + /** + * Constructor, the only place to set all parameters of this Request + * + * @param NULL|string $uri URI for the request, if any. + * @param NULL|string $method HTTP method for the request, if any. + * @param string|resource|StreamInterface $body Message body, if any. + * @param array $headers Headers for the message, if any. + * @throws \InvalidArgumentException for any invalid value. + */ + public function __construct($uri = NULL, $method = NULL, $body = 'php://input', array $headers = array()) { + + // Build a streamable object for the body + if (!is_string($body) && !is_resource($body) && !$body instanceof StreamInterface) { + throw new \InvalidArgumentException('Body must be a string stream resource identifier, a stream resource, or a StreamInterface instance', 1436717271); + } + + if (!$body instanceof StreamInterface) { + $body = new Stream($body); + } + + if (is_string($uri)) { + $uri = new Uri($uri); + } + + if (!$uri instanceof UriInterface && $uri !== NULL) { + throw new \InvalidArgumentException('Invalid URI provided; must be null, a string, or a UriInterface instance', 1436717272); + } + + $this->validateMethod($method); + + $this->method = $method; + $this->uri = $uri; + $this->body = $body; + list($this->headerNames, $headers) = $this->filterHeaders($headers); + $this->assertHeaders($headers); + $this->headers = $headers; + } + + /** + * Retrieves all message header values. + * + * The keys represent the header name as it will be sent over the wire, and + * each value is an array of strings associated with the header. + * + * // Represent the headers as a string + * foreach ($message->getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return array Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders() { + $headers = parent::getHeaders(); + if (!$this->hasHeader('host') && ($this->uri && $this->uri->getHost())) { + $headers['host'] = [$this->getHostFromUri()]; + } + return $headers; + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($header) { + if (!$this->hasHeader($header) && strtolower($header) === 'host' && ($this->uri && $this->uri->getHost())) { + return array($this->getHostFromUri()); + } + return parent::getHeader($header); + } + + /** + * Retrieve the host from the URI instance + * + * @return string + */ + protected function getHostFromUri() { + $host = $this->uri->getHost(); + $host .= $this->uri->getPort() ? ':' . $this->uri->getPort() : ''; + return $host; + } + + /** + * Retrieves the message's request target. + * + * Retrieves the message's request-target either as it will appear (for + * clients), as it appeared at request (for servers), or as it was + * specified for the instance (see withRequestTarget()). + * + * In most cases, this will be the origin-form of the composed URI, + * unless a value was provided to the concrete implementation (see + * withRequestTarget() below). + * + * If no URI is available, and no request-target has been specifically + * provided, this method MUST return the string "/". + * + * @return string + */ + public function getRequestTarget() { + if ($this->requestTarget !== NULL) { + return $this->requestTarget; + } + if (!$this->uri) { + return '/'; + } + $target = $this->uri->getPath(); + + if ($this->uri->getQuery()) { + $target .= '?' . $this->uri->getQuery(); + } + + if (empty($target)) { + $target = '/'; + } + return $target; + } + + /** + * Return an instance with the specific request-target. + * + * If the request needs a non-origin-form request-target — e.g., for + * specifying an absolute-form, authority-form, or asterisk-form — + * this method may be used to create an instance with the specified + * request-target, verbatim. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request target. + * + * @link http://tools.ietf.org/html/rfc7230#section-2.7 (for the various + * request-target forms allowed in request messages) + * + * @param mixed $requestTarget + * @return Request + */ + public function withRequestTarget($requestTarget) { + if (preg_match('#\s#', $requestTarget)) { + throw new \InvalidArgumentException('Invalid request target provided which contains whitespaces.', 1436717273); + } + $clonedObject = clone $this; + $clonedObject->requestTarget = $requestTarget; + return $clonedObject; + } + + /** + * Retrieves the HTTP method of the request, defaults to GET + * + * @return string Returns the request method. + */ + public function getMethod() { + return !empty($this->method) ? $this->method : 'GET'; + } + + /** + * Return an instance with the provided HTTP method. + * + * While HTTP method names are typically all uppercase characters, HTTP + * method names are case-sensitive and thus implementations SHOULD NOT + * modify the given string. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request method. + * + * @param string $method Case-sensitive method. + * @return Request + * @throws \InvalidArgumentException for invalid HTTP methods. + */ + public function withMethod($method) { + $clonedObject = clone $this; + $clonedObject->method = $method; + return $clonedObject; + } + + /** + * Retrieves the URI instance. + * + * This method MUST return a UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @return \Psr\Http\Message\UriInterface Returns a UriInterface instance + * representing the URI of the request. + */ + public function getUri() { + return $this->uri; + } + + /** + * Returns an instance with the provided URI. + * + * This method MUST update the Host header of the returned request by + * default if the URI contains a host component. If the URI does not + * contain a host component, any pre-existing Host header MUST be carried + * over to the returned request. + * + * You can opt-in to preserving the original state of the Host header by + * setting `$preserveHost` to `true`. When `$preserveHost` is set to + * `true`, this method interacts with the Host header in the following ways: + * + * - If the the Host header is missing or empty, and the new URI contains + * a host component, this method MUST update the Host header in the returned + * request. + * - If the Host header is missing or empty, and the new URI does not contain a + * host component, this method MUST NOT update the Host header in the returned + * request. + * - If a Host header is present and non-empty, this method MUST NOT update + * the Host header in the returned request. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * + * @param \Psr\Http\Message\UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return Request + */ + public function withUri(UriInterface $uri, $preserveHost = FALSE) { + $clonedObject = clone $this; + $clonedObject->uri = $uri; + + if ($preserveHost) { + return $clonedObject; + } + + if (!$uri->getHost()) { + return $clonedObject; + } + + $host = $uri->getHost(); + + if ($uri->getPort()) { + $host .= ':' . $uri->getPort(); + } + + $clonedObject->headerNames['host'] = 'Host'; + $clonedObject->headers['Host'] = array($host); + return $clonedObject; + } + + /** + * Validate the HTTP method, helper function. + * + * @param NULL|string $method + * @throws \InvalidArgumentException on invalid HTTP method. + */ + protected function validateMethod($method) { + if ($method !== NULL) { + if (!is_string($method)) { + $methodAsString = is_object($method) ? get_class($method) : gettype($method); + throw new \InvalidArgumentException('Unsupported HTTP method "' . $methodAsString . '".', 1436717274); + } + $method = strtoupper($method); + if (!in_array($method, $this->supportedMethods, TRUE)) { + throw new \InvalidArgumentException('Unsupported HTTP method "' . $method. '".', 1436717275); + } + } + } +} diff --git a/typo3/sysext/core/Classes/Http/Response.php b/typo3/sysext/core/Classes/Http/Response.php new file mode 100644 index 0000000000000000000000000000000000000000..14b4fb5e3d31e37bff65dbe2470601cc474149af --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Response.php @@ -0,0 +1,201 @@ +<?php +namespace TYPO3\CMS\Core\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\ResponseInterface; +use Psr\Http\Message\StreamInterface; + +/** + * Default implementation for the ResponseInterface of the PSR-7 standard. + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class Response extends Message implements ResponseInterface { + + /** + * The HTTP status code of the response + * @var int $statusCode + */ + protected $statusCode; + + /** + * The reason phrase of the response + * @var string $reasonPhrase + */ + protected $reasonPhrase = ''; + + /** + * The standardized and other important HTTP Status Codes + * @var array + */ + protected $availableStatusCodes = array( + // INFORMATIONAL CODES + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + // SUCCESS CODES + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-status', + 208 => 'Already Reported', + // REDIRECTION CODES + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => 'Switch Proxy', // Deprecated + 307 => 'Temporary Redirect', + // CLIENT ERROR + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + // SERVER ERROR + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 509 => 'Bandwidth Limit Exceeded', + 511 => 'Network Authentication Required' + ); + + /** + * Constructor for generating new responses + * + * @param StreamInterface|string $body + * @param int $statusCode + * @param array $headers + * @throws \InvalidArgumentException if any of the given arguments are given + */ + public function __construct($body = 'php://temp', $statusCode = 200, $headers = array()) { + // Build a streamable object for the body + if (!is_string($body) && !is_resource($body) && !$body instanceof StreamInterface) { + throw new \InvalidArgumentException('Body must be a string stream resource identifier, a stream resource, or a StreamInterface instance', 1436717277); + } + + if (!$body instanceof StreamInterface) { + $body = new Stream($body, 'rw'); + } + $this->body = $body; + + if (\TYPO3\CMS\Core\Utility\MathUtility::canBeInterpretedAsInteger($statusCode) === FALSE || !array_key_exists((int)$statusCode, $this->availableStatusCodes)) { + throw new \InvalidArgumentException('The given status code is not a valid HTTP status code.', 1436717278); + } + $this->statusCode = (int)$statusCode; + + $this->reasonPhrase = $this->availableStatusCodes[$this->statusCode]; + list($this->headerNames, $headers) = $this->filterHeaders($headers); + $this->assertHeaders($headers); + $this->headers = $headers; + } + + /** + * Gets the response status code. + * + * The status code is a 3-digit integer result code of the server's attempt + * to understand and satisfy the request. + * + * @return int Status code. + */ + public function getStatusCode() { + return $this->statusCode; + } + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return Response + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function withStatus($code, $reasonPhrase = '') { + if (\TYPO3\CMS\Core\Utility\MathUtility::canBeInterpretedAsInteger($code) === FALSE || !array_key_exists((int)$code, $this->availableStatusCodes)) { + throw new \InvalidArgumentException('The given status code is not a valid HTTP status code', 1436717279); + } + $clonedObject = clone $this; + $clonedObject->statusCode = $code; + $clonedObject->reasonPhrase = $reasonPhrase !== '' ? $reasonPhrase : $this->availableStatusCodes[$code]; + return $clonedObject; + } + + /** + * Gets the response reason phrase associated with the status code. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @return string Reason phrase; must return an empty string if none present. + */ + public function getReasonPhrase() { + return $this->reasonPhrase; + } + +} diff --git a/typo3/sysext/core/Classes/Http/ServerRequest.php b/typo3/sysext/core/Classes/Http/ServerRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..019eebfccb1956e2c0f2da76a3b9cd196c9f631d --- /dev/null +++ b/typo3/sysext/core/Classes/Http/ServerRequest.php @@ -0,0 +1,369 @@ +<?php +namespace TYPO3\CMS\Core\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 Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UploadedFileInterface; + +/** + * Represents a typical request incoming from the server to be processed + * by the TYPO3 Core. The original request is built from the ServerRequestFactory + * inside TYPO3's Bootstrap. + * + * Note that the PSR-7 standard works with immutable value objects, meaning that + * any modification to a Request object using the "with" methods will result + * in a new Request object. + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class ServerRequest extends Request implements ServerRequestInterface { + + /** + * @var array + */ + protected $attributes; + + /** + * @var array + */ + protected $cookieParams; + + /** + * @var array + */ + protected $parsedBody; + + /** + * @var array + */ + protected $queryParams; + + /** + * @var array + */ + protected $serverParams; + + /** + * @var array + */ + protected $uploadedFiles; + + /** + * Constructor, the only place to set all parameters of this Message/Request + * + * @param NULL|string $uri URI for the request, if any. + * @param NULL|string $method HTTP method for the request, if any. + * @param string|resource|StreamInterface $body Message body, if any. + * @param array $headers Headers for the message, if any. + * @param array $serverParams Server parameters, typically from $_SERVER + * @param array $uploadedFiles Upload file information, a tree of UploadedFiles + * @throws \InvalidArgumentException for any invalid value. + */ + public function __construct($uri = NULL, $method = NULL, $body = 'php://input', array $headers = array(), array $serverParams = array(), array $uploadedFiles = NULL) { + if ($uploadedFiles !== NULL) { + $this->validateUploadedFiles($uploadedFiles); + } + + parent::__construct($uri, $method, $body, $headers); + + $this->serverParams = $serverParams; + $this->uploadedFiles = $uploadedFiles; + } + + /** + * Retrieve server parameters. + * + * Retrieves data related to the incoming request environment, + * typically derived from PHP's $_SERVER superglobal. The data IS NOT + * REQUIRED to originate from $_SERVER. + * + * @return array + */ + public function getServerParams() { + return $this->serverParams; + } + + /** + * Retrieve cookies. + * + * Retrieves cookies sent by the client to the server. + * + * The data MUST be compatible with the structure of the $_COOKIE + * superglobal. + * + * @return array + */ + public function getCookieParams() { + return $this->cookieParams; + } + + /** + * Return an instance with the specified cookies. + * + * The data IS NOT REQUIRED to come from the $_COOKIE superglobal, but MUST + * be compatible with the structure of $_COOKIE. Typically, this data will + * be injected at instantiation. + * + * This method MUST NOT update the related Cookie header of the request + * instance, nor related values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated cookie values. + * + * @param array $cookies Array of key/value pairs representing cookies. + * @return ServerRequest + */ + public function withCookieParams(array $cookies) { + $clonedObject = clone $this; + $clonedObject->cookieParams = $cookies; + return $clonedObject; + } + + /** + * Retrieve query string arguments. + * + * Retrieves the deserialized query string arguments, if any. + * + * Note: the query params might not be in sync with the URI or server + * params. If you need to ensure you are only getting the original + * values, you may need to parse the query string from `getUri()->getQuery()` + * or from the `QUERY_STRING` server param. + * + * @return array + */ + public function getQueryParams() { + return $this->queryParams; + } + + /** + * Return an instance with the specified query string arguments. + * + * These values SHOULD remain immutable over the course of the incoming + * request. They MAY be injected during instantiation, such as from PHP's + * $_GET superglobal, or MAY be derived from some other value such as the + * URI. In cases where the arguments are parsed from the URI, the data + * MUST be compatible with what PHP's parse_str() would return for + * purposes of how duplicate query parameters are handled, and how nested + * sets are handled. + * + * Setting query string arguments MUST NOT change the URI stored by the + * request, nor the values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated query string arguments. + * + * @param array $query Array of query string arguments, typically from + * $_GET. + * @return ServerRequest + */ + public function withQueryParams(array $query) { + $clonedObject = clone $this; + $clonedObject->queryParams = $query; + return $clonedObject; + } + + /** + * Retrieve normalized file upload data. + * + * This method returns upload metadata in a normalized tree, with each leaf + * an instance of Psr\Http\Message\UploadedFileInterface. + * + * These values MAY be prepared from $_FILES or the message body during + * instantiation, or MAY be injected via withUploadedFiles(). + * + * @return array An array tree of UploadedFileInterface instances; an empty + * array MUST be returned if no data is present. + */ + public function getUploadedFiles() { + return $this->uploadedFiles; + } + + /** + * Create a new instance with the specified uploaded files. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param array $uploadedFiles An array tree of UploadedFileInterface instances. + * @return ServerRequest + * @throws \InvalidArgumentException if an invalid structure is provided. + */ + public function withUploadedFiles(array $uploadedFiles) { + $this->validateUploadedFiles($uploadedFiles); + $clonedObject = clone $this; + $clonedObject->uploadedFiles = $uploadedFiles; + return $clonedObject; + } + + /** + * Retrieve any parameters provided in the request body. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, this method MUST + * return the contents of $_POST. + * + * Otherwise, this method may return any results of deserializing + * the request body content; as parsing returns structured content, the + * potential types MUST be arrays or objects only. A null value indicates + * the absence of body content. + * + * @return null|array|object The deserialized body parameters, if any. + * These will typically be an array or object. + */ + public function getParsedBody() { + return $this->parsedBody; + } + + /** + * Return an instance with the specified body parameters. + * + * These MAY be injected during instantiation. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, use this method + * ONLY to inject the contents of $_POST. + * + * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of + * deserializing the request body content. Deserialization/parsing returns + * structured data, and, as such, this method ONLY accepts arrays or objects, + * or a null value if nothing was available to parse. + * + * As an example, if content negotiation determines that the request data + * is a JSON payload, this method could be used to create a request + * instance with the deserialized parameters. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param null|array|object $data The deserialized body data. This will + * typically be in an array or object. + * @return ServerRequest + * @throws \InvalidArgumentException if an unsupported argument type is + * provided. + */ + public function withParsedBody($data) { + $clonedObject = clone $this; + $clonedObject->parsedBody = $data; + return $clonedObject; + } + + /** + * Retrieve attributes derived from the request. + * + * The request "attributes" may be used to allow injection of any + * parameters derived from the request: e.g., the results of path + * match operations; the results of decrypting cookies; the results of + * deserializing non-form-encoded message bodies; etc. Attributes + * will be application and request specific, and CAN be mutable. + * + * @return array Attributes derived from the request. + */ + public function getAttributes() { + return $this->attributes; + } + + /** + * Retrieve a single derived request attribute. + * + * Retrieves a single derived request attribute as described in + * getAttributes(). If the attribute has not been previously set, returns + * the default value as provided. + * + * This method obviates the need for a hasAttribute() method, as it allows + * specifying a default value to return if the attribute is not found. + * + * @see getAttributes() + * + * @param string $name The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * @return mixed + */ + public function getAttribute($name, $default = NULL) { + return isset($this->attributes[$name]) ? $this->attributes[$name] : $default; + } + + /** + * Return an instance with the specified derived request attribute. + * + * This method allows setting a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated attribute. + * + * @see getAttributes() + * + * @param string $name The attribute name. + * @param mixed $value The value of the attribute. + * @return ServerRequest + */ + public function withAttribute($name, $value) { + $clonedObject = clone $this; + $clonedObject->attributes[$name] = $value; + return $clonedObject; + } + + /** + * Return an instance that removes the specified derived request attribute. + * + * This method allows removing a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the attribute. + * + * @see getAttributes() + * + * @param string $name The attribute name. + * @return ServerRequest + */ + public function withoutAttribute($name) { + $clonedObject = clone $this; + if (!isset($clonedObject->attributes[$name])) { + return $clonedObject; + } else { + unset($clonedObject->attributes[$name]); + return $clonedObject; + } + } + + /** + * Recursively validate the structure in an uploaded files array. + * + * @param array $uploadedFiles + * @throws \InvalidArgumentException if any leaf is not an UploadedFileInterface instance. + */ + protected function validateUploadedFiles(array $uploadedFiles) { + foreach ($uploadedFiles as $file) { + if (is_array($file)) { + $this->validateUploadedFiles($file); + continue; + } + if (!$file instanceof UploadedFileInterface) { + throw new \InvalidArgumentException('Invalid file in uploaded files structure.', 1436717281); + } + } + } + +} diff --git a/typo3/sysext/core/Classes/Http/ServerRequestFactory.php b/typo3/sysext/core/Classes/Http/ServerRequestFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..73014f111d4161e915b9e76bda56462627fbbf42 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/ServerRequestFactory.php @@ -0,0 +1,152 @@ +<?php +namespace TYPO3\CMS\Core\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\UploadedFileInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Class ServerRequestFactory to create ServerRequest objects + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class ServerRequestFactory { + + /** + * Create a request from the original superglobal variables. + * + * @return ServerRequest + * @throws \InvalidArgumentException when invalid file values given + * @internal Note that this is not public API yet. + */ + static public function fromGlobals() { + $serverParameters = $_SERVER; + $headers = static::prepareHeaders($serverParameters); + + $method = isset($serverParameters['REQUEST_METHOD']) ? $serverParameters['REQUEST_METHOD'] : 'GET'; + $uri = new Uri(GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL')); + + $request = new ServerRequest( + $uri, + $method, + 'php://input', + $headers, + $serverParameters, + static::normalizeUploadedFiles($_FILES) + ); + + if (!empty($_COOKIE)) { + $request = $request->withCookieParams($_COOKIE); + } + $queryParameters = GeneralUtility::_GET(); + if (!empty($queryParameters)) { + $request = $request->withQueryParams($queryParameters); + } + $parsedBody = GeneralUtility::_POST(); + if (!empty($parsedBody)) { + $request = $request->withParsedBody($parsedBody); + } + return $request; + } + + /** + * Fetch headers from $_SERVER variables + * which are only the ones starting with HTTP_* and CONTENT_* + * + * @param array $server + * @return array + */ + protected static function prepareHeaders(array $server) { + $headers = array(); + foreach ($server as $key => $value) { + if (strpos($key, 'HTTP_COOKIE') === 0) { + // Cookies are handled using the $_COOKIE superglobal + continue; + } + if (!empty($value)) { + if (strpos($key, 'HTTP_') === 0) { + $name = strtr(substr($key, 5), '_', ' '); + $name = strtr(ucwords(strtolower($name)), ' ', '-'); + $name = strtolower($name); + $headers[$name] = $value; + } elseif (strpos($key, 'CONTENT_') === 0) { + $name = substr($key, 8); // Content- + $name = 'Content-' . (($name == 'MD5') ? $name : ucfirst(strtolower($name))); + $name = strtolower($name); + $headers[$name] = $value; + } + } + } + return $headers; + } + + /** + * Normalize uploaded files + * + * Transforms each value into an UploadedFileInterface instance, and ensures that nested arrays are normalized. + * + * @param array $files + * @return array + * @throws \InvalidArgumentException for unrecognized values + */ + protected static function normalizeUploadedFiles(array $files) { + $normalizedFileUploads = array(); + foreach ($files as $key => $value) { + if ($value instanceof UploadedFileInterface) { + $normalizedFileUploads[$key] = $value; + } elseif (is_array($value)) { + if (isset($value['tmp_name'])) { + $normalizedFileUploads[$key] = self::createUploadedFile($value); + } else { + $normalizedFileUploads[$key] = self::normalizeUploadedFiles($value); + } + } else { + throw new \InvalidArgumentException('Invalid value in files specification.', 1436717282); + } + } + return $normalizedFileUploads; + } + + /** + * Create and return an UploadedFile instance from a $_FILES specification. + * + * If the specification represents an array of values, this method will + * delegate to normalizeNestedFileSpec() and return that return value. + * + * @param array $value $_FILES structure + * @return UploadedFileInterface[]|UploadedFileInterface + */ + protected static function createUploadedFile(array $value) { + if (is_array($value['tmp_name'])) { + $files = array(); + foreach (array_keys($value['tmp_name']) as $key) { + $data = array( + 'tmp_name' => $value['tmp_name'][$key], + 'size' => $value['size'][$key], + 'error' => $value['error'][$key], + 'name' => $value['name'][$key], + 'type' => $value['type'][$key] + ); + $files[$key] = self::createUploadedFile($data); + } + return $files; + } else { + return new UploadedFile($value['tmp_name'], $value['size'], $value['error'], $value['name'], $value['type']); + } + } + +} diff --git a/typo3/sysext/core/Classes/Http/Stream.php b/typo3/sysext/core/Classes/Http/Stream.php new file mode 100644 index 0000000000000000000000000000000000000000..10df091ea7d2d5cb5a9e7e9704809ff62649f79b --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Stream.php @@ -0,0 +1,343 @@ +<?php +namespace TYPO3\CMS\Core\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\StreamInterface; + +/** + * Default implementation for the StreamInterface of the PSR-7 standard + * Acts mainly as a decorator class for streams/resources. + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class Stream implements StreamInterface { + + /** + * The actual PHP resource + * @var resource + */ + protected $resource; + + /** + * @var string|resource + */ + protected $stream; + + /** + * Constructor setting up the PHP resource + * + * @param string|resource $stream + * @param string $mode Mode with which to open stream + * @throws \InvalidArgumentException + */ + public function __construct($stream, $mode = 'r') { + $this->stream = $stream; + if (is_resource($stream)) { + $this->resource = $stream; + } elseif (is_string($stream)) { + $this->resource = fopen($stream, $mode); + } else { + throw new \InvalidArgumentException('Invalid stream provided; must be a string stream identifier or resource', 1436717284); + } + } + + /** + * Reads all data from the stream into a string, from the beginning to end. + * + * This method MUST attempt to seek to the beginning of the stream before + * reading data and read the stream until the end is reached. + * + * Warning: This could attempt to load a large amount of data into memory. + * + * This method MUST NOT raise an exception in order to conform with PHP's + * string casting operations. + * + * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring + * @return string + */ + public function __toString() { + if (!$this->isReadable()) { + return ''; + } + try { + $this->rewind(); + return $this->getContents(); + } catch (\RuntimeException $e) { + return ''; + } + } + + /** + * Closes the stream and any underlying resources. + * + * @return void + */ + public function close() { + if (!$this->resource) { + return; + } + $resource = $this->detach(); + fclose($resource); + } + + /** + * Separates any underlying resources from the stream. + * + * After the stream has been detached, the stream is in an unusable state. + * + * @return resource|null Underlying PHP stream, if any + */ + public function detach() { + $resource = $this->resource; + $this->resource = NULL; + return $resource; + } + + /** + * Get the size of the stream if known. + * + * @return int|null Returns the size in bytes if known, or null if unknown. + */ + public function getSize() { + if ($this->resource === NULL) { + return NULL; + } + $stats = fstat($this->resource); + return $stats['size']; + } + + /** + * Returns the current position of the file read/write pointer + * + * @return int Position of the file pointer + * @throws \RuntimeException on error. + */ + public function tell() { + if (!$this->resource) { + throw new \RuntimeException('No resource available; cannot tell position', 1436717285); + } + $result = ftell($this->resource); + if (!is_int($result)) { + throw new \RuntimeException('Error occurred during tell operation', 1436717286); + } + return $result; + } + + /** + * Returns true if the stream is at the end of the stream. + * + * @return bool + */ + public function eof() { + if (!$this->resource) { + return TRUE; + } + return feof($this->resource); + } + + /** + * Returns whether or not the stream is seekable. + * + * @return bool + */ + public function isSeekable() { + if (!$this->resource) { + return FALSE; + } + return (bool)$this->getMetadata('seekable'); + } + + /** + * Seek to a position in the stream. + * + * @link http://www.php.net/manual/en/function.fseek.php + * + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. + * + * @throws \RuntimeException on failure. + */ + public function seek($offset, $whence = SEEK_SET) { + if (!$this->resource) { + throw new \RuntimeException('No resource available; cannot seek position', 1436717287); + } + + if (!$this->isSeekable()) { + throw new \RuntimeException('Stream is not seekable', 1436717288); + } + $result = fseek($this->resource, $offset, $whence); + if ($result !== 0) { + throw new \RuntimeException('Error seeking within stream', 1436717289); + } + } + + /** + * Seek to the beginning of the stream. + * + * If the stream is not seekable, this method will raise an exception; + * otherwise, it will perform a seek(0). + * + * @see seek() + * @link http://www.php.net/manual/en/function.fseek.php + * @throws \RuntimeException on failure. + */ + public function rewind() { + $this->seek(0); + } + + /** + * Returns whether or not the stream is writable. + * + * @return bool + */ + public function isWritable() { + if (!$this->resource) { + return FALSE; + } + $uri = $this->getMetadata('uri'); + return is_writable($uri); + } + + /** + * Write data to the stream. + * + * @param string $string The string that is to be written. + * @return int Returns the number of bytes written to the stream. + * @throws \RuntimeException on failure. + */ + public function write($string) { + if (!$this->resource) { + throw new \RuntimeException('No resource available; cannot write', 1436717290); + } + $result = fwrite($this->resource, $string); + if ($result === FALSE) { + throw new \RuntimeException('Error writing to stream', 1436717291); + } + return $result; + } + + /** + * Returns whether or not the stream is readable. + * + * @return bool + */ + public function isReadable() { + if (!$this->resource) { + return FALSE; + } + $mode = $this->getMetadata('mode'); + return (strpos($mode, 'r') !== FALSE || strpos($mode, '+') !== FALSE); + } + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return + * them. Fewer than $length bytes may be returned if underlying stream + * call returns fewer bytes. + * @return string Returns the data read from the stream, or an empty string + * if no bytes are available. + * @throws \RuntimeException if an error occurs. + */ + public function read($length) { + if (!$this->resource) { + throw new \RuntimeException('No resource available; cannot read', 1436717292); + } + if (!$this->isReadable()) { + throw new \RuntimeException('Stream is not readable', 1436717293); + } + $result = fread($this->resource, $length); + if ($result === FALSE) { + throw new \RuntimeException('Error reading stream', 1436717294); + } + return $result; + } + + /** + * Returns the remaining contents in a string + * + * @return string + * @throws \RuntimeException if unable to read or an error occurs while + * reading. + */ + public function getContents() { + if (!$this->isReadable()) { + return ''; + } + $result = stream_get_contents($this->resource); + if ($result === FALSE) { + throw new \RuntimeException('Error reading from stream', 1436717295); + } + return $result; + } + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * + * The keys returned are identical to the keys returned from PHP's + * stream_get_meta_data() function. + * + * @link http://php.net/manual/en/function.stream-get-meta-data.php + * + * @param string $key Specific metadata to retrieve. + * + * @return array|mixed|null Returns an associative array if no key is + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. + */ + public function getMetadata($key = NULL) { + $metadata = stream_get_meta_data($this->resource); + if ($key === NULL) { + return $metadata; + } + if (!isset($metadata[$key])) { + return NULL; + } + return $metadata[$key]; + } + + /** + * Attach a new stream/resource to the instance. + * + * @param string|resource $resource + * @param string $mode + * @throws \InvalidArgumentException for stream identifier that cannot be cast to a resource + * @throws \InvalidArgumentException for non-resource stream + */ + public function attach($resource, $mode = 'r') { + $error = NULL; + if (!is_resource($resource) && is_string($resource)) { + set_error_handler(function ($e) use (&$error) { + $error = $e; + }, E_WARNING); + $resource = fopen($resource, $mode); + restore_error_handler(); + } + if ($error) { + throw new \InvalidArgumentException('Invalid stream reference provided', 1436717296); + } + if (!is_resource($resource)) { + throw new \InvalidArgumentException('Invalid stream provided; must be a string stream identifier or resource', 1436717297); + } + $this->resource = $resource; + } + +} diff --git a/typo3/sysext/core/Classes/Http/UploadedFile.php b/typo3/sysext/core/Classes/Http/UploadedFile.php new file mode 100644 index 0000000000000000000000000000000000000000..efeaf4e19d6e7f08d13232a2b0d985db0ea6356a --- /dev/null +++ b/typo3/sysext/core/Classes/Http/UploadedFile.php @@ -0,0 +1,265 @@ +<?php +namespace TYPO3\CMS\Core\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\StreamInterface; +use Psr\Http\Message\UploadedFileInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Class UploadedFile which represents one uploaded file, usually coming + * from $_FILES, according to PSR-7 standard. + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class UploadedFile implements UploadedFileInterface { + + /** + * @var NULL|string + */ + protected $file; + + /** + * @var NULL|StreamInterface + */ + protected $stream; + + /** + * @var string + */ + protected $clientFilename; + + /** + * @var string + */ + protected $clientMediaType; + + /** + * @var int + */ + protected $error; + + /** + * @var bool + */ + protected $moved = false; + + /** + * @var int + */ + protected $size; + + /** + * Constructor method + * + * @param string|resource $input is either a stream or a filename + * @param int $size see $_FILES['size'] from PHP + * @param int $errorStatus see $_FILES['error'] + * @param string $clientFilename the original filename handed over from the client + * @param string $clientMediaType the media type (optional) + * + * @throws \InvalidArgumentException + */ + public function __construct($input, $size, $errorStatus, $clientFilename = NULL, $clientMediaType = NULL) { + + if (is_string($input)) { + $this->file = $input; + } + + if (is_resource($input)) { + $this->stream = new Stream($input); + } elseif ($input instanceof StreamInterface) { + $this->stream = $input; + } + + if (!$this->file && !$this->stream) { + throw new \InvalidArgumentException('The input given was not a valid stream or file.', 1436717301); + } + + if (!is_int($size)) { + throw new \InvalidArgumentException('The size provided for an uploaded file must be an integer.', 1436717302); + } + $this->size = $size; + + if (!is_int($errorStatus) || 0 > $errorStatus || 8 < $errorStatus) { + throw new \InvalidArgumentException('Invalid error status for an uploaded file. See UPLOAD_ERR_* constant in PHP.', 1436717303); + } + $this->error = $errorStatus; + + if ($clientFilename !== NULL && !is_string($clientFilename)) { + throw new \InvalidArgumentException('Invalid client filename provided for an uploaded file.', 1436717304); + } + $this->clientFilename = $clientFilename; + + if ($clientMediaType !== NULL && !is_string($clientMediaType)) { + throw new \InvalidArgumentException('Invalid client media type provided for an uploaded file.', 1436717305); + } + $this->clientMediaType = $clientMediaType; + } + + /** + * Retrieve a stream representing the uploaded file. + * Returns a StreamInterface instance, representing the uploaded file. The purpose of this method + * is to allow utilizing native PHP stream functionality to manipulate the file upload, such as + * stream_copy_to_stream() (though the result will need to be decorated in a native PHP stream wrapper + * to work with such functions). + * + * If the moveTo() method has been called previously, this method raises an exception. + * + * @return StreamInterface Stream representation of the uploaded file. + * @throws \RuntimeException in cases when no stream is available or can be created. + */ + public function getStream() { + if ($this->moved) { + throw new \RuntimeException('Cannot retrieve stream as it was moved.', 1436717306); + } + + if ($this->stream instanceof StreamInterface) { + return $this->stream; + } + + $this->stream = new Stream($this->file); + return $this->stream; + } + + /** + * Move the uploaded file to a new location. + * + * Use this method as an alternative to move_uploaded_file(). This method is + * guaranteed to work in both SAPI and non-SAPI environments. + * Implementations must determine which environment they are in, and use the + * appropriate method (move_uploaded_file(), rename(), or a stream + * operation) to perform the operation. + * + * $targetPath may be an absolute path, or a relative path. If it is a + * relative path, resolution should be the same as used by PHP's rename() + * function. + * + * The original file or stream MUST be removed on completion. + * + * If this method is called more than once, any subsequent calls MUST raise + * an exception. + * + * When used in an SAPI environment where $_FILES is populated, when writing + * files via moveTo(), is_uploaded_file() and move_uploaded_file() SHOULD be + * used to ensure permissions and upload status are verified correctly. + * + * If you wish to move to a stream, use getStream(), as SAPI operations + * cannot guarantee writing to stream destinations. + * + * @see http://php.net/is_uploaded_file + * @see http://php.net/move_uploaded_file + * @param string $targetPath Path to which to move the uploaded file. + * @throws \InvalidArgumentException if the $path specified is invalid. + * @throws \RuntimeException on any error during the move operation, or on the second or subsequent call to the method. + */ + public function moveTo($targetPath) { + if (!is_string($targetPath) || empty($targetPath)) { + throw new \InvalidArgumentException('Invalid path while moving an uploaded file.', 1436717307); + } + + if ($this->moved) { + throw new \RuntimeException('Cannot move uploaded file, as it was already moved.', 1436717308); + } + + // Check if the target path is inside the allowed paths of TYPO3, and make it absolute. + $targetPath = GeneralUtility::getFileAbsFileName($targetPath); + if (empty($targetPath)) { + throw new \RuntimeException('Cannot move uploaded file, as it was already moved.', 1436717309); + } + + if (!empty($this->file) && is_uploaded_file($this->file)) { + if (GeneralUtility::upload_copy_move($this->file, $targetPath . basename($this->file)) === FALSE) { + throw new \RuntimeException('An error occurred while moving uploaded file', 1436717310); + } + } elseif ($this->stream) { + $handle = fopen($targetPath, 'wb+'); + if ($handle === FALSE) { + throw new \RuntimeException('Unable to write to target path.', 1436717311); + } + + $this->stream->rewind(); + while (!$this->stream->eof()) { + fwrite($handle, $this->stream->read(4096)); + } + + fclose($handle); + } + + $this->moved = TRUE; + } + + /** + * Retrieve the file size. + * Usually returns the value stored in the "size" key of + * the file in the $_FILES array if available, as PHP calculates this based + * on the actual size transmitted. + * + * @return int|NULL The file size in bytes or null if unknown. + */ + public function getSize() { + return $this->size; + } + + /** + * Retrieve the error associated with the uploaded file. + * Usually returns the value stored in the "error" key of + * the file in the $_FILES array. + * + * The return value MUST be one of PHP's UPLOAD_ERR_XXX constants. + * + * If the file was uploaded successfully, this method MUST return + * UPLOAD_ERR_OK. + * + * @see http://php.net/manual/en/features.file-upload.errors.php + * @return int One of PHP's UPLOAD_ERR_XXX constants. + */ + public function getError() { + return $this->error; + } + + /** + * Retrieve the filename sent by the client. + * Usually returns the value stored in the "name" key of + * the file in the $_FILES array. + * + * Do not trust the value returned by this method. A client could send + * a malicious filename with the intention to corrupt or hack your + * application. + * + * @return string|NULL The filename sent by the client or null if none was provided. + */ + public function getClientFilename() { + return $this->clientFilename; + } + + /** + * Retrieve the media type sent by the client. + * Usually returns the value stored in the "type" key of + * the file in the $_FILES array. + * + * Do not trust the value returned by this method. A client could send + * a malicious media type with the intention to corrupt or hack your + * application. + * + * @return string|NULL The media type sent by the client or null if none was provided. + */ + public function getClientMediaType() { + return $this->clientMediaType; + } + +} diff --git a/typo3/sysext/core/Classes/Http/Uri.php b/typo3/sysext/core/Classes/Http/Uri.php new file mode 100644 index 0000000000000000000000000000000000000000..98cd63dd13e3aebc5ab2712554ed5667bfc63493 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Uri.php @@ -0,0 +1,716 @@ +<?php +namespace TYPO3\CMS\Core\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\UriInterface; + +/** + * Represents a URI based on the PSR-7 Standard. + * + * Highly inspired by https://github.com/phly/http/ + * + * @internal Note that this is not public API yet. + */ +class Uri implements UriInterface { + + /** + * Sub-delimiters used in query strings and fragments. + * + * @const string + */ + const SUBDELIMITER_CHARLIST = '!\$&\'\(\)\*\+,;='; + + /** + * Unreserved characters used in paths, query strings, and fragments. + * + * @const string + */ + const UNRESERVED_CHARLIST = 'a-zA-Z0-9_\-\.~'; + + /** + * The default scheme for the URI + * @var string + */ + protected $scheme; + + /** + * @var int[] Associative array containing schemes and their default ports. + */ + protected $supportedSchemes = array( + 'http' => 80, + 'https' => 443 + ); + + /** + * The authority part of the URI + * @var string + */ + protected $authority = ''; + + /** + * The userInfo part of the URI + * @var string + */ + protected $userInfo = ''; + + /** + * The host part of the URI + * @var string + */ + protected $host = ''; + + /** + * The port of the URI (empty if it is the standard port for the scheme) + * @var int|NULL + */ + protected $port = NULL; + + /** + * The path part of the URI (can be empty or /) + * @var string + */ + protected $path = ''; + + /** + * The query part of the URI without the ? + * @var string + */ + protected $query; + + /** + * The fragment part of the URI without the # before + * @var string + */ + protected $fragment; + + /** + * @param string|null $uri The full URI including query string and fragment + * @throws \InvalidArgumentException when the URI is not a string + */ + public function __construct($uri = '') { + if (!is_string($uri)) { + $argumentType = is_object($uri) ? get_class($uri) : gettype($uri); + throw new \InvalidArgumentException('URI passed must be a string, but is of type "' . $argumentType . '"', 1436717320); + } + if (!empty($uri)) { + $this->parseUri($uri); + } + } + + /** + * helper function for parsing the full URI string + * @param string $uri + * @throws \InvalidArgumentException if the URI is malformed. + */ + protected function parseUri($uri) { + $uriParts = parse_url($uri); + + if ($uriParts === FALSE) { + throw new \InvalidArgumentException('The parsedUri string appears to be malformed', 1436717322); + } + + if (isset($uriParts['scheme'])) { + $this->scheme = $this->sanitizeScheme($uriParts['scheme']); + } + + if (isset($uriParts['user'])) { + $this->userInfo = $uriParts['user']; + if (isset($uriParts['pass'])) { + $this->userInfo .= ':' . $uriParts['pass']; + } + } + + if (isset($uriParts['host'])) { + $this->host = $uriParts['host']; + } + + if (isset($uriParts['port'])) { + $this->port = (int)$uriParts['port']; + } + + if (isset($uriParts['path'])) { + $this->path = $this->sanitizePath($uriParts['path']); + } + + if (isset($uriParts['query'])) { + $this->query = $this->sanitizeQuery($uriParts['query']); + } + + if (isset($uriParts['fragment'])) { + $this->fragment = $this->sanitizeFragment($uriParts['fragment']); + } + } + + /** + * Retrieve the scheme component of the URI. + * + * If no scheme is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.1. + * + * The trailing ":" character is not part of the scheme and MUST NOT be + * added. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.1 + * @return string The URI scheme. + */ + public function getScheme() { + return $this->scheme; + } + + /** + * Retrieve the authority component of the URI. + * + * If no authority information is present, this method MUST return an empty + * string. + * + * The authority syntax of the URI is: + * + * <pre> + * [user-info@]host[:port] + * </pre> + * + * If the port component is not set or is the standard port for the current + * scheme, it SHOULD NOT be included. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.2 + * @return string The URI authority, in "[user-info@]host[:port]" format. + */ + public function getAuthority() { + if (empty($this->host)) { + return ''; + } + + $authority = $this->host; + if (!empty($this->userInfo)) { + $authority = $this->userInfo . '@' . $authority; + } + + if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + /** + * Retrieve the user information component of the URI. + * + * If no user information is present, this method MUST return an empty + * string. + * + * If a user is present in the URI, this will return that value; + * additionally, if the password is also present, it will be appended to the + * user value, with a colon (":") separating the values. + * + * The trailing "@" character is not part of the user information and MUST + * NOT be added. + * + * @return string The URI user information, in "username[:password]" format. + */ + public function getUserInfo() { + return $this->userInfo; + } + + /** + * Retrieve the host component of the URI. + * + * If no host is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.2.2. + * + * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 + * @return string The URI host. + */ + public function getHost() { + return $this->host; + } + + /** + * Retrieve the port component of the URI. + * + * If a port is present, and it is non-standard for the current scheme, + * this method MUST return it as an integer. If the port is the standard port + * used with the current scheme, this method SHOULD return null. + * + * If no port is present, and no scheme is present, this method MUST return + * a null value. + * + * If no port is present, but a scheme is present, this method MAY return + * the standard port for that scheme, but SHOULD return null. + * + * @return null|int The URI port. + */ + public function getPort() { + return $this->isNonStandardPort($this->scheme, $this->host, $this->port) ? $this->port : NULL; + } + + /** + * Retrieve the path component of the URI. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * Normally, the empty path "" and absolute path "/" are considered equal as + * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically + * do this normalization because in contexts with a trimmed base path, e.g. + * the front controller, this difference becomes significant. It's the task + * of the user to handle both "" and "/". + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.3. + * + * As an example, if the value should include a slash ("/") not intended as + * delimiter between path segments, that value MUST be passed in encoded + * form (e.g., "%2F") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.3 + * @return string The URI path. + */ + public function getPath() { + return $this->path; + } + + /** + * Retrieve the query string of the URI. + * + * If no query string is present, this method MUST return an empty string. + * + * The leading "?" character is not part of the query and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.4. + * + * As an example, if a value in a key/value pair of the query string should + * include an ampersand ("&") not intended as a delimiter between values, + * that value MUST be passed in encoded form (e.g., "%26") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.4 + * @return string The URI query string. + */ + public function getQuery() { + return $this->query; + } + + /** + * Retrieve the fragment component of the URI. + * + * If no fragment is present, this method MUST return an empty string. + * + * The leading "#" character is not part of the fragment and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.5. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.5 + * @return string The URI fragment. + */ + public function getFragment() { + return $this->fragment; + } + + /** + * Return an instance with the specified scheme. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified scheme. + * + * Implementations MUST support the schemes "http" and "https" case + * insensitively, and MAY accommodate other schemes if required. + * + * An empty scheme is equivalent to removing the scheme. + * + * @param string $scheme The scheme to use with the new instance. + * + * @return self A new instance with the specified scheme. + * @throws \InvalidArgumentException for invalid or unsupported schemes. + */ + public function withScheme($scheme) { + $scheme = $this->sanitizeScheme($scheme); + + $clonedObject = clone $this; + $clonedObject->scheme = $scheme; + return $clonedObject; + } + + /** + * Return an instance with the specified user information. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified user information. + * + * Password is optional, but the user information MUST include the + * user; an empty string for the user is equivalent to removing user + * information. + * + * @param string $user The user name to use for authority. + * @param null|string $password The password associated with $user. + * + * @return self A new instance with the specified user information. + */ + public function withUserInfo($user, $password = NULL) { + + $userInfo = $user; + if (!empty($password)) { + $userInfo .= ':' . $password; + } + + $clonedObject = clone $this; + $clonedObject->userInfo = $userInfo; + return $clonedObject; + } + + /** + * Return an instance with the specified host. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified host. + * + * An empty host value is equivalent to removing the host. + * + * @param string $host The hostname to use with the new instance. + * + * @return self A new instance with the specified host. + * @throws \InvalidArgumentException for invalid hostnames. + */ + public function withHost($host) { + $clonedObject = clone $this; + $clonedObject->host = $host; + return $clonedObject; + } + + /** + * Return an instance with the specified port. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified port. + * + * Implementations MUST raise an exception for ports outside the + * established TCP and UDP port ranges. + * + * A null value provided for the port is equivalent to removing the port + * information. + * + * @param null|int $port The port to use with the new instance; a null value + * removes the port information. + * + * @return self A new instance with the specified port. + * @throws \InvalidArgumentException for invalid ports. + */ + public function withPort($port) { + if (\TYPO3\CMS\Core\Utility\MathUtility::canBeInterpretedAsInteger($port) === FALSE) { + $argumentType = is_object($port) ? get_class($port) : gettype($port); + throw new \InvalidArgumentException('Invalid port "' . $argumentType . '" specified, must be an integer.', 1436717324); + } + + $port = (int)$port; + if ($port < 1 || $port > 65535) { + throw new \InvalidArgumentException('Invalid port "' . $port . '" specified, must be a valid TCP/UDP port.', 1436717326); + } + + $clonedObject = clone $this; + $clonedObject->port = $port; + return $clonedObject; + } + + /** + * Return an instance with the specified path. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified path. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * If the path is intended to be domain-relative rather than path relative then + * it must begin with a slash ("/"). Paths not starting with a slash ("/") + * are assumed to be relative to some base path known to the application or + * consumer. + * + * Users can provide both encoded and decoded path characters. + * Implementations ensure the correct encoding as outlined in getPath(). + * + * @param string $path The path to use with the new instance. + * + * @return self A new instance with the specified path. + * @throws \InvalidArgumentException for invalid paths. + */ + public function withPath($path) { + if (!is_string($path)) { + throw new \InvalidArgumentException('Invalid path provided. Must be of type string.', 1436717328); + } + + if (strpos($path, '?') !== FALSE) { + throw new \InvalidArgumentException('Invalid path provided. Must not contain a query string.', 1436717330); + } + + if (strpos($path, '#') !== FALSE) { + throw new \InvalidArgumentException('Invalid path provided; must not contain a URI fragment', 1436717332); + } + + $path = $this->sanitizePath($path); + $clonedObject = clone $this; + $clonedObject->path = $path; + return $clonedObject; + } + + /** + * Return an instance with the specified query string. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified query string. + * + * Users can provide both encoded and decoded query characters. + * Implementations ensure the correct encoding as outlined in getQuery(). + * + * An empty query string value is equivalent to removing the query string. + * + * @param string $query The query string to use with the new instance. + * + * @return self A new instance with the specified query string. + * @throws \InvalidArgumentException for invalid query strings. + */ + public function withQuery($query) { + if (!is_string($query)) { + throw new \InvalidArgumentException('Query string must be a string.', 1436717334); + } + + if (strpos($query, '#') !== FALSE) { + throw new \InvalidArgumentException('Query string must not include a URI fragment.', 1436717336); + } + + $query = $this->sanitizeQuery($query); + $clonedObject = clone $this; + $clonedObject->query = $query; + return $clonedObject; + } + + /** + * Return an instance with the specified URI fragment. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified URI fragment. + * + * Users can provide both encoded and decoded fragment characters. + * Implementations ensure the correct encoding as outlined in getFragment(). + * + * An empty fragment value is equivalent to removing the fragment. + * + * @param string $fragment The fragment to use with the new instance. + * + * @return self A new instance with the specified fragment. + */ + public function withFragment($fragment) { + $fragment = $this->sanitizeFragment($fragment); + $clonedObject = clone $this; + $clonedObject->fragment = $fragment; + return $clonedObject; + } + + /** + * Return the string representation as a URI reference. + * + * Depending on which components of the URI are present, the resulting + * string is either a full URI or relative reference according to RFC 3986, + * Section 4.1. The method concatenates the various components of the URI, + * using the appropriate delimiters: + * + * - If a scheme is present, it MUST be suffixed by ":". + * - If an authority is present, it MUST be prefixed by "//". + * - The path can be concatenated without delimiters. But there are two + * cases where the path has to be adjusted to make the URI reference + * valid as PHP does not allow to throw an exception in __toString(): + * - If the path is rootless and an authority is present, the path MUST + * be prefixed by "/". + * - If the path is starting with more than one "/" and no authority is + * present, the starting slashes MUST be reduced to one. + * - If a query is present, it MUST be prefixed by "?". + * - If a fragment is present, it MUST be prefixed by "#". + * + * @see http://tools.ietf.org/html/rfc3986#section-4.1 + * @return string + */ + public function __toString() { + $uri = ''; + + if (!empty($this->scheme)) { + $uri .= $this->scheme . '://'; + } + + $authority = $this->getAuthority(); + if (!empty($authority)) { + $uri .= $authority; + } + + $path = $this->getPath(); + if (!empty($path)) { + $uri .= '/' . ltrim($path, '/'); + } + + if ($this->query) { + $uri .= '?' . $this->query; + } + if ($this->fragment) { + $uri .= '#' . $this->fragment; + } + return $uri; + } + + /** + * Is a given port non-standard for the current scheme? + * + * @param string $scheme + * @param string $host + * @param int $port + * @return bool + */ + protected function isNonStandardPort($scheme, $host, $port) { + if (empty($scheme)) { + return TRUE; + } + + if (empty($host) || empty($port)) { + return FALSE; + } + + return !isset($this->supportedSchemes[$scheme]) || $port !== $this->supportedSchemes[$scheme]; + } + + /** + * Filters the scheme to ensure it is a valid scheme. + * + * @param string $scheme Scheme name. + * + * @return string Filtered scheme. + * @throws \InvalidArgumentException when a scheme is given which is not supported + */ + protected function sanitizeScheme($scheme) { + $scheme = strtolower($scheme); + $scheme = preg_replace('#:(//)?$#', '', $scheme); + + if (empty($scheme)) { + return ''; + } + + if (!array_key_exists($scheme, $this->supportedSchemes)) { + throw new \InvalidArgumentException('Unsupported scheme "' . $scheme . '"; must be any empty string or in the set (' . implode(', ', array_keys($this->supportedSchemes)) . ')', 1436717338); + } + + return $scheme; + } + + /** + * Filters the path of a URI to ensure it is properly encoded. + * + * @param string $path + * @return string + */ + protected function sanitizePath($path) { + return preg_replace_callback( + '/(?:[^' . self::UNRESERVED_CHARLIST . ':@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/', + function($matches) { + return rawurlencode($matches[0]); + }, + $path + ); + } + + /** + * Filter a query string to ensure it is propertly encoded. + * + * Ensures that the values in the query string are properly urlencoded. + * + * @param string $query + * @return string + */ + protected function sanitizeQuery($query) { + if (!empty($query) && strpos($query, '?') === 0) { + $query = substr($query, 1); + } + + $parts = explode('&', $query); + foreach ($parts as $index => $part) { + list($key, $value) = $this->splitQueryValue($part); + if ($value === NULL) { + $parts[$index] = $this->sanitizeQueryOrFragment($key); + continue; + } + $parts[$index] = $this->sanitizeQueryOrFragment($key) . '=' . $this->sanitizeQueryOrFragment($value); + } + + return implode('&', $parts); + } + + /** + * Split a query value into a key/value tuple. + * + * @param string $value + * @return array A value with exactly two elements, key and value + */ + protected function splitQueryValue($value) { + $data = explode('=', $value, 2); + if (count($data) === 1) { + $data[] = NULL; + } + return $data; + } + + /** + * Filter a fragment value to ensure it is properly encoded. + * + * @param null|string $fragment + * @return string + */ + protected function sanitizeFragment($fragment) { + if ($fragment === NULL) { + $fragment = ''; + } + + if (!empty($fragment) && strpos($fragment, '#') === 0) { + $fragment = substr($fragment, 1); + } + + return $this->sanitizeQueryOrFragment($fragment); + } + + /** + * Filter a query string key or value, or a fragment. + * + * @param string $value + * @return string + */ + protected function sanitizeQueryOrFragment($value) { + return preg_replace_callback( + '/(?:[^' . self::UNRESERVED_CHARLIST . self::SUBDELIMITER_CHARLIST . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/', + function($matches) { + return rawurlencode($matches[0]); + }, + $value + ); + } + +} diff --git a/typo3/sysext/core/Tests/Unit/Http/MessageTest.php b/typo3/sysext/core/Tests/Unit/Http/MessageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..75938b0cc63a252253d32f9bb8925947ce1ce927 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/MessageTest.php @@ -0,0 +1,302 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\Stream; +use TYPO3\CMS\Core\Http\Message; + +/** + * Testcase for \TYPO3\CMS\Core\Http\Message + * + * Adapted from https://github.com/phly/http/ + */ +class MessageTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @var Stream + */ + protected $stream; + + /** + * @var Message + */ + protected $message; + + public function setUp() { + $this->stream = new Stream('php://memory', 'wb+'); + $this->message = (new Message())->withBody($this->stream); + } + + /** + * @test + */ + public function protocolHasAcceptableDefault() { + $this->assertEquals('1.1', $this->message->getProtocolVersion()); + } + + /** + * @test + */ + public function protocolMutatorReturnsCloneWithChanges() { + $message = $this->message->withProtocolVersion('1.0'); + $this->assertNotSame($this->message, $message); + $this->assertEquals('1.0', $message->getProtocolVersion()); + } + + /** + * @test + */ + public function usesStreamProvidedInConstructorAsBody() { + $this->assertSame($this->stream, $this->message->getBody()); + } + + /** + * @test + */ + public function bodyMutatorReturnsCloneWithChanges() { + $stream = new Stream('php://memory', 'wb+'); + $message = $this->message->withBody($stream); + $this->assertNotSame($this->message, $message); + $this->assertSame($stream, $message->getBody()); + } + + /** + * @test + */ + public function getHeaderReturnsHeaderValueAsArray() { + $message = $this->message->withHeader('X-Foo', ['Foo', 'Bar']); + $this->assertNotSame($this->message, $message); + $this->assertEquals(['Foo', 'Bar'], $message->getHeader('X-Foo')); + } + + /** + * @test + */ + public function getHeaderLineReturnsHeaderValueAsCommaConcatenatedString() { + $message = $this->message->withHeader('X-Foo', ['Foo', 'Bar']); + $this->assertNotSame($this->message, $message); + $this->assertEquals('Foo,Bar', $message->getHeaderLine('X-Foo')); + } + + /** + * @test + */ + public function getHeadersKeepsHeaderCaseSensitivity() { + $message = $this->message->withHeader('X-Foo', ['Foo', 'Bar']); + $this->assertNotSame($this->message, $message); + $this->assertEquals(['X-Foo' => ['Foo', 'Bar']], $message->getHeaders()); + } + + /** + * @test + */ + public function getHeadersReturnsCaseWithWhichHeaderFirstRegistered() { + $message = $this->message + ->withHeader('X-Foo', 'Foo') + ->withAddedHeader('x-foo', 'Bar'); + $this->assertNotSame($this->message, $message); + $this->assertEquals(['X-Foo' => ['Foo', 'Bar']], $message->getHeaders()); + } + + /** + * @test + */ + public function hasHeaderReturnsFalseIfHeaderIsNotPresent() { + $this->assertFalse($this->message->hasHeader('X-Foo')); + } + + /** + * @test + */ + public function hasHeaderReturnsTrueIfHeaderIsPresent() { + $message = $this->message->withHeader('X-Foo', 'Foo'); + $this->assertNotSame($this->message, $message); + $this->assertTrue($message->hasHeader('X-Foo')); + } + + /** + * @test + */ + public function addHeaderAppendsToExistingHeader() { + $message = $this->message->withHeader('X-Foo', 'Foo'); + $this->assertNotSame($this->message, $message); + $message2 = $message->withAddedHeader('X-Foo', 'Bar'); + $this->assertNotSame($message, $message2); + $this->assertEquals('Foo,Bar', $message2->getHeaderLine('X-Foo')); + } + + + /** + * @test + */ + public function canRemoveHeaders() { + $message = $this->message->withHeader('X-Foo', 'Foo'); + $this->assertNotSame($this->message, $message); + $this->assertTrue($message->hasHeader('x-foo')); + $message2 = $message->withoutHeader('x-foo'); + $this->assertNotSame($this->message, $message2); + $this->assertNotSame($message, $message2); + $this->assertFalse($message2->hasHeader('X-Foo')); + } + + /** + * @test + */ + public function headerRemovalIsCaseInsensitive() { + $message = $this->message + ->withHeader('X-Foo', 'Foo') + ->withAddedHeader('x-foo', 'Bar') + ->withAddedHeader('X-FOO', 'Baz'); + $this->assertNotSame($this->message, $message); + $this->assertTrue($message->hasHeader('x-foo')); + $message2 = $message->withoutHeader('x-foo'); + $this->assertNotSame($this->message, $message2); + $this->assertNotSame($message, $message2); + $this->assertFalse($message2->hasHeader('X-Foo')); + $headers = $message2->getHeaders(); + $this->assertEquals(0, count($headers)); + } + + /** + * @return array + */ + public function invalidGeneralHeaderValuesDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'array' => [['foo' => ['bar']]], + 'object' => [(object) ['foo' => 'bar']], + ]; + } + + /** + * @dataProvider invalidGeneralHeaderValuesDataProvider + */ + public function testWithHeaderRaisesExceptionForInvalidNestedHeaderValue($value) { + $this->setExpectedException('InvalidArgumentException', 'Invalid header value'); + $message = $this->message->withHeader('X-Foo', [$value]); + } + + /** + * @return array + */ + public function invalidHeaderValuesDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'object' => [(object) ['foo' => 'bar']], + ]; + } + + /** + * @dataProvider invalidHeaderValuesDataProvider + */ + public function withHeaderRaisesExceptionForInvalidValueType($value) { + $this->setExpectedException('InvalidArgumentException', 'Invalid header value'); + $message = $this->message->withHeader('X-Foo', $value); + } + + /** + * @dataProvider invalidHeaderValuesDataProvider + */ + public function withAddedHeaderRaisesExceptionForNonStringNonArrayValue($value) { + $this->setExpectedException('InvalidArgumentException', 'must be a string'); + $message = $this->message->withAddedHeader('X-Foo', $value); + } + + /** + * @test + */ + public function withoutHeaderDoesNothingIfHeaderDoesNotExist() { + $this->assertFalse($this->message->hasHeader('X-Foo')); + $message = $this->message->withoutHeader('X-Foo'); + $this->assertNotSame($this->message, $message); + $this->assertFalse($message->hasHeader('X-Foo')); + } + + /** + * @test + */ + public function getHeaderReturnsAnEmptyArrayWhenHeaderDoesNotExist() { + $this->assertSame([], $this->message->getHeader('X-Foo-Bar')); + } + + /** + * @test + */ + public function getHeaderLineReturnsEmptyStringWhenHeaderDoesNotExist() { + $this->assertSame('', $this->message->getHeaderLine('X-Foo-Bar')); + } + + /** + * @return array + */ + public function headersWithInjectionVectorsDataProvider() { + return [ + 'name-with-cr' => ["X-Foo\r-Bar", 'value'], + 'name-with-lf' => ["X-Foo\n-Bar", 'value'], + 'name-with-crlf' => ["X-Foo\r\n-Bar", 'value'], + 'name-with-2crlf' => ["X-Foo\r\n\r\n-Bar", 'value'], + 'value-with-cr' => ['X-Foo-Bar', "value\rinjection"], + 'value-with-lf' => ['X-Foo-Bar', "value\ninjection"], + 'value-with-crlf' => ['X-Foo-Bar', "value\r\ninjection"], + 'value-with-2crlf' => ['X-Foo-Bar', "value\r\n\r\ninjection"], + 'array-value-with-cr' => ['X-Foo-Bar', ["value\rinjection"]], + 'array-value-with-lf' => ['X-Foo-Bar', ["value\ninjection"]], + 'array-value-with-crlf' => ['X-Foo-Bar', ["value\r\ninjection"]], + 'array-value-with-2crlf' => ['X-Foo-Bar', ["value\r\n\r\ninjection"]], + ]; + } + + /** + * @dataProvider headersWithInjectionVectorsDataProvider + * @test + */ + public function doesNotAllowCRLFInjectionWhenCallingWithHeader($name, $value) { + $this->setExpectedException('InvalidArgumentException'); + $this->message->withHeader($name, $value); + } + + /** + * @dataProvider headersWithInjectionVectorsDataProvider + * @test + */ + public function doesNotAllowCRLFInjectionWhenCallingWithAddedHeader($name, $value) { + $this->setExpectedException('InvalidArgumentException'); + $this->message->withAddedHeader($name, $value); + } + + /** + * @test + */ + public function testWithHeaderAllowsHeaderContinuations() { + $message = $this->message->withHeader('X-Foo-Bar', "value,\r\n second value"); + $this->assertEquals("value,\r\n second value", $message->getHeaderLine('X-Foo-Bar')); + } + + /** + * @test + */ + public function testWithAddedHeaderAllowsHeaderContinuations() { + $message = $this->message->withAddedHeader('X-Foo-Bar', "value,\r\n second value"); + $this->assertEquals("value,\r\n second value", $message->getHeaderLine('X-Foo-Bar')); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Http/RequestTest.php b/typo3/sysext/core/Tests/Unit/Http/RequestTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7d9fd08311fd722bf5c0e37ee2dca3687b671a77 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/RequestTest.php @@ -0,0 +1,453 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Http\Request; +use TYPO3\CMS\Core\Http\Stream; + +/** + * Testcase for \TYPO3\CMS\Core\Http\Request + * + * Adapted from https://github.com/phly/http/ + */ +class RequestTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @var Request + */ + protected $request; + + public function setUp() { + $this->request = new Request(); + } + + /** + * @test + */ + public function getMethodIsGetByDefault() { + $this->assertEquals('GET', $this->request->getMethod()); + } + + /** + * @test + */ + public function getMethodMutatorReturnsCloneWithChangedMethod() { + $request = $this->request->withMethod('GET'); + $this->assertNotSame($this->request, $request); + $this->assertEquals('GET', $request->getMethod()); + } + + /** + * @test + */ + public function getUriIsNullByDefault() { + $this->assertNull($this->request->getUri()); + } + + /** + * @test + */ + public function constructorRaisesExceptionForInvalidStream() { + $this->setExpectedException('InvalidArgumentException'); + new Request(['TOTALLY INVALID']); + } + + /** + * @test + */ + public function withUriReturnsNewInstanceWithNewUri() { + $request = $this->request->withUri(new Uri('https://example.com:10082/foo/bar?baz=bat')); + $this->assertNotSame($this->request, $request); + $request2 = $request->withUri(new Uri('/baz/bat?foo=bar')); + $this->assertNotSame($this->request, $request2); + $this->assertNotSame($request, $request2); + $this->assertEquals('/baz/bat?foo=bar', (string) $request2->getUri()); + } + + /** + * @test + */ + public function constructorCanAcceptAllMessageParts() { + $uri = new Uri('http://example.com/'); + $body = new Stream('php://memory'); + $headers = [ + 'x-foo' => ['bar'], + ]; + $request = new Request( + $uri, + 'POST', + $body, + $headers + ); + + $this->assertSame($uri, $request->getUri()); + $this->assertEquals('POST', $request->getMethod()); + $this->assertSame($body, $request->getBody()); + $testHeaders = $request->getHeaders(); + foreach ($headers as $key => $value) { + $this->assertArrayHasKey($key, $testHeaders); + $this->assertEquals($value, $testHeaders[$key]); + } + } + + /** + * @return array + */ + public function invalidRequestUriDataProvider() { + return [ + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'array' => [['http://example.com']], + 'stdClass' => [(object) ['href' => 'http://example.com']], + ]; + } + + /** + * @dataProvider invalidRequestUriDataProvider + * @test + */ + public function constructorRaisesExceptionForInvalidUri($uri) { + $this->setExpectedException('InvalidArgumentException', 'Invalid URI'); + new Request($uri); + } + + /** + * @return array + */ + public function invalidRequestMethodDataProvider() { + return [ + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'bad-string' => ['BOGUS-METHOD'], + 'array' => [['POST']], + 'stdClass' => [(object) ['method' => 'POST']], + ]; + } + + /** + * @dataProvider invalidRequestMethodDataProvider + * @test + */ + public function constructorRaisesExceptionForInvalidMethod($method) { + $this->setExpectedException('InvalidArgumentException', 'Unsupported HTTP method'); + new Request(NULL, $method); + } + + /** + * @return array + */ + public function invalidRequestBodyDataProvider() { + return [ + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'array' => [['BODY']], + 'stdClass' => [(object) ['body' => 'BODY']], + ]; + } + + /** + * @dataProvider invalidRequestBodyDataProvider + * @test + */ + public function constructorRaisesExceptionForInvalidBody($body) { + $this->setExpectedException('InvalidArgumentException', 'stream'); + new Request(NULL, NULL, $body); + } + + /** + * @test + */ + public function constructorIgnoresInvalidHeaders() { + $headers = [ + ['INVALID'], + 'x-invalid-null' => NULL, + 'x-invalid-true' => TRUE, + 'x-invalid-false' => FALSE, + 'x-invalid-int' => 1, + 'x-invalid-object' => (object) ['INVALID'], + 'x-valid-string' => 'VALID', + 'x-valid-array' => ['VALID'], + ]; + $expected = [ + 'x-valid-string' => ['VALID'], + 'x-valid-array' => ['VALID'], + ]; + $request = new Request(NULL, NULL, 'php://memory', $headers); + $this->assertEquals($expected, $request->getHeaders()); + } + + /** + * @test + */ + public function getRequestTargetIsSlashWhenNoUriPresent() { + $request = new Request(); + $this->assertEquals('/', $request->getRequestTarget()); + } + + /** + * @test + */ + public function getRequestTargetIsSlashWhenUriHasNoPathOrQuery() { + $request = (new Request()) + ->withUri(new Uri('http://example.com')); + $this->assertEquals('/', $request->getRequestTarget()); + } + + /** + * @return array + */ + public function requestsWithUriDataProvider() { + return [ + 'absolute-uri' => [ + (new Request()) + ->withUri(new Uri('https://api.example.com/user')) + ->withMethod('POST'), + '/user' + ], + 'absolute-uri-with-query' => [ + (new Request()) + ->withUri(new Uri('https://api.example.com/user?foo=bar')) + ->withMethod('POST'), + '/user?foo=bar' + ], + 'relative-uri' => [ + (new Request()) + ->withUri(new Uri('/user')) + ->withMethod('GET'), + '/user' + ], + 'relative-uri-with-query' => [ + (new Request()) + ->withUri(new Uri('/user?foo=bar')) + ->withMethod('GET'), + '/user?foo=bar' + ], + ]; + } + + /** + * @dataProvider requestsWithUriDataProvider + * @test + */ + public function getRequestTargetWhenUriIsPresent($request, $expected) { + $this->assertEquals($expected, $request->getRequestTarget()); + } + + /** + * @return array + */ + public function validRequestTargetsDataProvider() { + return [ + 'asterisk-form' => ['*'], + 'authority-form' => ['api.example.com'], + 'absolute-form' => ['https://api.example.com/users'], + 'absolute-form-query' => ['https://api.example.com/users?foo=bar'], + 'origin-form-path-only' => ['/users'], + 'origin-form' => ['/users?id=foo'], + ]; + } + + /** + * @dataProvider validRequestTargetsDataProvider + * @test + */ + public function getRequestTargetCanProvideARequestTarget($requestTarget) { + $request = (new Request())->withRequestTarget($requestTarget); + $this->assertEquals($requestTarget, $request->getRequestTarget()); + } + + /** + * @test + */ + public function withRequestTargetCannotContainWhitespace() { + $request = new Request(); + $this->setExpectedException('InvalidArgumentException', 'Invalid request target'); + $request->withRequestTarget('foo bar baz'); + } + + /** + * @test + */ + public function getRequestTargetDoesNotCacheBetweenInstances() { + $request = (new Request())->withUri(new Uri('https://example.com/foo/bar')); + $original = $request->getRequestTarget(); + $newRequest = $request->withUri(new Uri('http://mwop.net/bar/baz')); + $this->assertNotEquals($original, $newRequest->getRequestTarget()); + } + + /** + * @test + */ + public function getRequestTargetIsResetWithNewUri() { + $request = (new Request())->withUri(new Uri('https://example.com/foo/bar')); + $original = $request->getRequestTarget(); + $newRequest = $request->withUri(new Uri('http://mwop.net/bar/baz')); + } + + /** + * @test + */ + public function getHeadersContainsHostHeaderIfUriWithHostIsPresent() { + $request = new Request('http://example.com'); + $headers = $request->getHeaders(); + $this->assertArrayHasKey('host', $headers); + $this->assertContains('example.com', $headers['host']); + } + + /** + * @test + */ + public function getHeadersContainsNoHostHeaderIfNoUriPresent() { + $request = new Request(); + $headers = $request->getHeaders(); + $this->assertArrayNotHasKey('host', $headers); + } + + /** + * @test + */ + public function getHeadersContainsNoHostHeaderIfUriDoesNotContainHost() { + $request = new Request(new Uri()); + $headers = $request->getHeaders(); + $this->assertArrayNotHasKey('host', $headers); + } + + /** + * @test + */ + public function getHeaderWithHostReturnsUriHostWhenPresent() { + $request = new Request('http://example.com'); + $header = $request->getHeader('host'); + $this->assertEquals(array('example.com'), $header); + } + + /** + * @test + */ + public function getHeaderWithHostReturnsEmptyArrayIfNoUriPresent() { + $request = new Request(); + $this->assertSame([], $request->getHeader('host')); + } + + /** + * @test + */ + public function getHeaderWithHostReturnsEmptyArrayIfUriDoesNotContainHost() { + $request = new Request(new Uri()); + $this->assertSame([], $request->getHeader('host')); + } + + /** + * @test + */ + public function getHeaderLineWithHostReturnsUriHostWhenPresent() { + $request = new Request('http://example.com'); + $header = $request->getHeaderLine('host'); + $this->assertContains('example.com', $header); + } + + /** + * @test + */ + public function getHeaderLineWithHostReturnsEmptyStringIfNoUriPresent() { + $request = new Request(); + $this->assertSame('', $request->getHeaderLine('host')); + } + + /** + * @test + */ + public function getHeaderLineWithHostReturnsEmptyStringIfUriDoesNotContainHost() { + $request = new Request(new Uri()); + $this->assertSame('', $request->getHeaderLine('host')); + } + + /** + * @test + */ + public function getHeaderLineWithHostTakesPrecedenceOverModifiedUri() { + $request = (new Request()) + ->withAddedHeader('Host', 'example.com'); + + $uri = (new Uri())->withHost('www.example.com'); + $new = $request->withUri($uri, TRUE); + + $this->assertEquals('example.com', $new->getHeaderLine('Host')); + } + + /** + * @test + */ + public function getHeaderLineWithHostTakesPrecedenceOverEmptyUri() { + $request = (new Request()) + ->withAddedHeader('Host', 'example.com'); + + $uri = new Uri(); + $new = $request->withUri($uri); + + $this->assertEquals('example.com', $new->getHeaderLine('Host')); + } + + /** + * @test + */ + public function getHeaderLineWithHostDoesNotTakePrecedenceOverHostWithPortFromUri() { + $request = (new Request()) + ->withAddedHeader('Host', 'example.com'); + + $uri = (new Uri()) + ->withHost('www.example.com') + ->withPort(10081); + $new = $request->withUri($uri); + + $this->assertEquals('www.example.com:10081', $new->getHeaderLine('Host')); + } + + /** + * @return array + */ + public function headersWithInjectionVectorsDataProvider() { + return [ + 'name-with-cr' => ["X-Foo\r-Bar", 'value'], + 'name-with-lf' => ["X-Foo\n-Bar", 'value'], + 'name-with-crlf' => ["X-Foo\r\n-Bar", 'value'], + 'name-with-2crlf' => ["X-Foo\r\n\r\n-Bar", 'value'], + 'value-with-cr' => ['X-Foo-Bar', "value\rinjection"], + 'value-with-lf' => ['X-Foo-Bar', "value\ninjection"], + 'value-with-crlf' => ['X-Foo-Bar', "value\r\ninjection"], + 'value-with-2crlf' => ['X-Foo-Bar', "value\r\n\r\ninjection"], + 'array-value-with-cr' => ['X-Foo-Bar', ["value\rinjection"]], + 'array-value-with-lf' => ['X-Foo-Bar', ["value\ninjection"]], + 'array-value-with-crlf' => ['X-Foo-Bar', ["value\r\ninjection"]], + 'array-value-with-2crlf' => ['X-Foo-Bar', ["value\r\n\r\ninjection"]], + ]; + } + + /** + * @test + * @dataProvider headersWithInjectionVectorsDataProvider + */ + public function constructorRaisesExceptionForHeadersWithCRLFVectors($name, $value) { + $this->setExpectedException('InvalidArgumentException'); + $request = new Request(NULL, NULL, 'php://memory', [$name => $value]); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Http/ResponseTest.php b/typo3/sysext/core/Tests/Unit/Http/ResponseTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4e4d153afedb3cbfbad553abdfa1642d54538641 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/ResponseTest.php @@ -0,0 +1,214 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\Response; +use TYPO3\CMS\Core\Http\Stream; + +/** + * Testcase for \TYPO3\CMS\Core\Http\Response + * + * Adapted from https://github.com/phly/http/ + */ +class ResponseTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @var Response + */ + protected $response; + + public function setUp() { + $this->response = new Response(); + } + + /** + * @test + */ + public function testStatusCodeIs200ByDefault() { + $this->assertEquals(200, $this->response->getStatusCode()); + } + + /** + * @test + */ + public function testStatusCodeMutatorReturnsCloneWithChanges() { + $response = $this->response->withStatus(400); + $this->assertNotSame($this->response, $response); + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * @return array + */ + public function invalidStatusCodesDataProvider() { + return [ + 'too-low' => [99], + 'too-high' => [600], + 'null' => [NULL], + 'bool' => [TRUE], + 'string' => ['foo'], + 'array' => [[200]], + 'object' => [(object) [200]], + ]; + } + + /** + * @dataProvider invalidStatusCodesDataProvider + * @test + */ + public function testCannotSetInvalidStatusCode($code) { + $this->setExpectedException('InvalidArgumentException'); + $response = $this->response->withStatus($code); + } + + /** + * @test + */ + public function testReasonPhraseDefaultsToStandards() { + $response = $this->response->withStatus(422); + $this->assertEquals('Unprocessable Entity', $response->getReasonPhrase()); + } + + /** + * @test + */ + public function testCanSetCustomReasonPhrase() { + $response = $this->response->withStatus(422, 'Foo Bar!'); + $this->assertEquals('Foo Bar!', $response->getReasonPhrase()); + } + + /** + * @test + */ + public function testConstructorRaisesExceptionForInvalidStream() { + $this->setExpectedException('InvalidArgumentException'); + new Response(['TOTALLY INVALID']); + } + + /** + * @test + */ + public function testConstructorCanAcceptAllMessageParts() { + $body = new Stream('php://memory'); + $status = 302; + $headers = [ + 'location' => ['http://example.com/'], + ]; + + $response = new Response($body, $status, $headers); + $this->assertSame($body, $response->getBody()); + $this->assertEquals(302, $response->getStatusCode()); + $this->assertEquals($headers, $response->getHeaders()); + } + + /** + * @return array + */ + public function invalidStatusDataProvider() { + return [ + 'true' => [TRUE], + 'false' => [FALSE], + 'float' => [100.1], + 'bad-string' => ['Two hundred'], + 'array' => [[200]], + 'object' => [(object) ['statusCode' => 200]], + 'too-small' => [1], + 'too-big' => [600], + ]; + } + + /** + * @dataProvider invalidStatusDataProvider + * @test + */ + public function testConstructorRaisesExceptionForInvalidStatus($code) { + $this->setExpectedException('InvalidArgumentException', 'The given status code is not a valid HTTP status code.'); + new Response('php://memory', $code); + } + + /** + * @return array + */ + public function invalidResponseBodyDataProvider() { + return [ + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'array' => [['BODY']], + 'stdClass' => [(object) ['body' => 'BODY']], + ]; + } + + /** + * @dataProvider invalidResponseBodyDataProvider + * @test + */ + public function testConstructorRaisesExceptionForInvalidBody($body) { + $this->setExpectedException('InvalidArgumentException', 'stream'); + new Response($body); + } + + /** + * @test + */ + public function constructorIgonoresInvalidHeaders() { + $headers = [ + ['INVALID'], + 'x-invalid-null' => NULL, + 'x-invalid-true' => TRUE, + 'x-invalid-false' => FALSE, + 'x-invalid-int' => 1, + 'x-invalid-object' => (object) ['INVALID'], + 'x-valid-string' => 'VALID', + 'x-valid-array' => ['VALID'], + ]; + $expected = [ + 'x-valid-string' => ['VALID'], + 'x-valid-array' => ['VALID'], + ]; + $response = new Response('php://memory', 200, $headers); + $this->assertEquals($expected, $response->getHeaders()); + } + + /** + * @return array + */ + public function headersWithInjectionVectorsDataProvider() { + return [ + 'name-with-cr' => ["X-Foo\r-Bar", 'value'], + 'name-with-lf' => ["X-Foo\n-Bar", 'value'], + 'name-with-crlf' => ["X-Foo\r\n-Bar", 'value'], + 'name-with-2crlf' => ["X-Foo\r\n\r\n-Bar", 'value'], + 'value-with-cr' => ['X-Foo-Bar', "value\rinjection"], + 'value-with-lf' => ['X-Foo-Bar', "value\ninjection"], + 'value-with-crlf' => ['X-Foo-Bar', "value\r\ninjection"], + 'value-with-2crlf' => ['X-Foo-Bar', "value\r\n\r\ninjection"], + 'array-value-with-cr' => ['X-Foo-Bar', ["value\rinjection"]], + 'array-value-with-lf' => ['X-Foo-Bar', ["value\ninjection"]], + 'array-value-with-crlf' => ['X-Foo-Bar', ["value\r\ninjection"]], + 'array-value-with-2crlf' => ['X-Foo-Bar', ["value\r\n\r\ninjection"]], + ]; + } + + /** + * @test + * @dataProvider headersWithInjectionVectorsDataProvider + */ + public function cnstructorRaisesExceptionForHeadersWithCRLFVectors($name, $value) { + $this->setExpectedException('InvalidArgumentException'); + $request = new Response('php://memory', 200, [$name => $value]); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Http/ServerRequestFactoryTest.php b/typo3/sysext/core/Tests/Unit/Http/ServerRequestFactoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..88a5213d29baf771cbfcff19d4a853a77d9580dc --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/ServerRequestFactoryTest.php @@ -0,0 +1,84 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\ServerRequestFactory; +use TYPO3\CMS\Core\Http\UploadedFile; + +/** + * Testcase for \TYPO3\CMS\Core\Http\ServerRequestFactory + */ +class ServerRequestFactoryTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @test + */ + public function uploadedFilesAreNormalizedFromFilesSuperGlobal() { + $_SERVER['HTTP_HOST'] = 'localhost'; + $_SERVER['REQUEST_URI'] = '/index.php'; + $_FILES = array( + 'tx_uploadexample_piexample' => array( + 'name' => array( + 'newExample' => array( + 'image' => 'o51pb.jpg', + 'imageCollection' => array( + 0 => 'composer.json', + ), + ), + ), + 'type' => array( + 'newExample' => array( + 'image' => 'image/jpeg', + 'imageCollection' => array( + 0 => 'application/json' + ) + ) + ), + 'tmp_name' => array( + 'newExample' => array( + 'image' => '/Applications/MAMP/tmp/php/phphXdbcd', + 'imageCollection' => array( + 0 => '/Applications/MAMP/tmp/php/phpgrZ4bb' + ) + ) + ), + 'error' => array( + 'newExample' => array( + 'image' => 0, + 'imageCollection' => array( + 0 => 0 + ) + ) + ), + 'size' => array( + 'newExample' => array( + 'image' => 59065, + 'imageCollection' => array( + 0 => 683 + ) + ) + ) + ) + ); + + $uploadedFiles = ServerRequestFactory::fromGlobals()->getUploadedFiles(); + + $this->assertNotEmpty($uploadedFiles['tx_uploadexample_piexample']['newExample']['image']); + $this->assertTrue($uploadedFiles['tx_uploadexample_piexample']['newExample']['image'] instanceof UploadedFile); + $this->assertNotEmpty($uploadedFiles['tx_uploadexample_piexample']['newExample']['imageCollection'][0]); + $this->assertTrue($uploadedFiles['tx_uploadexample_piexample']['newExample']['imageCollection'][0] instanceof UploadedFile); + } + +} diff --git a/typo3/sysext/core/Tests/Unit/Http/ServerRequestTest.php b/typo3/sysext/core/Tests/Unit/Http/ServerRequestTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2d729819e236d3ef39332b88b51abd0cc3b33673 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/ServerRequestTest.php @@ -0,0 +1,175 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Http\UploadedFile; + +/** + * Testcase for \TYPO3\CMS\Core\Http\ServerRequest + * + * Adapted from https://github.com/phly/http/ + */ +class ServerRequestTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @var ServerRequest + */ + protected $request; + + public function setUp() { + $this->request = new ServerRequest(); + } + + /** + * @test + */ + public function getServerParamsAreEmptyByDefault() { + $this->assertEmpty($this->request->getServerParams()); + } + + /** + * @test + */ + public function getQueryParamsAreEmptyByDefault() { + $this->assertEmpty($this->request->getQueryParams()); + } + + /** + * @test + */ + public function withQueryParamsMutatorReturnsCloneWithChanges() { + $value = ['foo' => 'bar']; + $request = $this->request->withQueryParams($value); + $this->assertNotSame($this->request, $request); + $this->assertEquals($value, $request->getQueryParams()); + } + + /** + * @test + */ + public function getCookieParamsAreEmptyByDefault() { + $this->assertEmpty($this->request->getCookieParams()); + } + + /** + * @test + */ + public function withCookieParamsMutatorReturnsCloneWithChanges() { + $value = ['foo' => 'bar']; + $request = $this->request->withCookieParams($value); + $this->assertNotSame($this->request, $request); + $this->assertEquals($value, $request->getCookieParams()); + } + + /** + * @test + */ + public function getUploadedFilesAreEmptyByDefault() { + $this->assertEmpty($this->request->getUploadedFiles()); + } + + /** + * @test + */ + public function getParsedBodyIsEmptyByDefault() { + $this->assertEmpty($this->request->getParsedBody()); + } + + /** + * @test + */ + public function withParsedBodyMutatorReturnsCloneWithChanges() { + $value = ['foo' => 'bar']; + $request = $this->request->withParsedBody($value); + $this->assertNotSame($this->request, $request); + $this->assertEquals($value, $request->getParsedBody()); + } + + /** + * @test + */ + public function getAttributesAreEmptyByDefault() { + $this->assertEmpty($this->request->getAttributes()); + } + + /** + * @depends testAttributesAreEmptyByDefault + * @test + */ + public function withAttributeMutatorReturnsCloneWithChanges() { + $request = $this->request->withAttribute('foo', 'bar'); + $this->assertNotSame($this->request, $request); + $this->assertEquals('bar', $request->getAttribute('foo')); + + return $request; + } + + /** + * @depends testAttributeMutatorReturnsCloneWithChanges + * @test + */ + public function withoutAttributeReturnsCloneWithoutAttribute($request) { + $new = $request->withoutAttribute('foo'); + $this->assertNotSame($request, $new); + $this->assertNull($new->getAttribute('foo', NULL)); + } + + /** + * @test + */ + public function constructorUsesProvidedArguments() { + $server = [ + 'foo' => 'bar', + 'baz' => 'bat', + ]; + + $server['server'] = TRUE; + + $files = [ + 'files' => new UploadedFile('php://temp', 0, 0), + ]; + + $uri = new Uri('http://example.com'); + $method = 'POST'; + $headers = [ + 'host' => ['example.com'], + ]; + + $request = new ServerRequest( + $uri, + $method, + 'php://memory', + $headers, + $server, + $files + ); + + $this->assertEquals($server, $request->getServerParams()); + $this->assertEquals($files, $request->getUploadedFiles()); + + $this->assertSame($uri, $request->getUri()); + $this->assertEquals($method, $request->getMethod()); + $this->assertEquals($headers, $request->getHeaders()); + + $body = $request->getBody(); + $r = new \ReflectionProperty($body, 'stream'); + $r->setAccessible(TRUE); + $stream = $r->getValue($body); + $this->assertEquals('php://memory', $stream); + } + +} diff --git a/typo3/sysext/core/Tests/Unit/Http/StreamTest.php b/typo3/sysext/core/Tests/Unit/Http/StreamTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2ffd0a7fd8e08fb4b6ea2cc1d1fc7b7ca9de9091 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/StreamTest.php @@ -0,0 +1,536 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\Stream; + +/** + * Testcase for \TYPO3\CMS\Core\Http\StreamTest + * + * Adapted from https://github.com/phly/http/ + */ +class StreamTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @var Stream + */ + protected $stream; + + public function setUp() { + $this->stream = new Stream('php://memory', 'wb+'); + } + + /** + * @test + */ + public function canInstantiateWithStreamIdentifier() { + $this->assertInstanceOf(Stream::class, $this->stream); + } + + /** + * @test + */ + public function canInstantiteWithStreamResource() { + $resource = fopen('php://memory', 'wb+'); + $stream = new Stream($resource); + $this->assertInstanceOf(Stream::class, $stream); + } + + /** + * @test + */ + public function isReadableReturnsFalseIfStreamIsNotReadable() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $stream = new Stream($fileName, 'w'); + $this->assertFalse($stream->isReadable()); + } + + /** + * @test + */ + public function isWritableReturnsFalseIfStreamIsNotWritable() { + $stream = new Stream('php://memory', 'r'); + $this->assertFalse($stream->isWritable()); + } + + /** + * @test + */ + public function toStringRetrievesFullContentsOfStream() { + $message = 'foo bar'; + $this->stream->write($message); + $this->assertEquals($message, (string) $this->stream); + } + + /** + * @test + */ + public function detachReturnsResource() { + $resource = fopen('php://memory', 'wb+'); + $stream = new Stream($resource); + $this->assertSame($resource, $stream->detach()); + } + + /** + * @test + */ + public function constructorRaisesExceptionWhenPassingInvalidStreamResource() { + $this->setExpectedException('InvalidArgumentException'); + $stream = new Stream([' THIS WILL NOT WORK ']); + } + + /** + * @test + */ + public function toStringSerializationReturnsEmptyStringWhenStreamIsNotReadable() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $stream = new Stream($fileName, 'w'); + + $this->assertEquals('', $stream->__toString()); + } + + /** + * @test + */ + public function closeClosesResource() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->close(); + $this->assertFalse(is_resource($resource)); + } + + /** + * @test + */ + public function closeUnsetsResource() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->close(); + + $this->assertNull($stream->detach()); + } + + /** + * @test + */ + public function closeDoesNothingAfterDetach() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $detached = $stream->detach(); + + $stream->close(); + $this->assertTrue(is_resource($detached)); + $this->assertSame($resource, $detached); + } + + /** + * @test + */ + public function getSizeReportsNullWhenNoResourcePresent() { + $this->stream->detach(); + $this->assertNull($this->stream->getSize()); + } + + /** + * @test + */ + public function tellReportsCurrentPositionInResource() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + + fseek($resource, 2); + + $this->assertEquals(2, $stream->tell()); + } + + /** + * @test + */ + public function tellRaisesExceptionIfResourceIsDetached() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + + fseek($resource, 2); + $stream->detach(); + $this->setExpectedException('RuntimeException', 'No resource'); + $stream->tell(); + } + + /** + * @test + */ + public function eofReportsFalseWhenNotAtEndOfStream() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + + fseek($resource, 2); + $this->assertFalse($stream->eof()); + } + + /** + * @test + */ + public function eofReportsTrueWhenAtEndOfStream() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + + while (!feof($resource)) { + fread($resource, 4096); + } + $this->assertTrue($stream->eof()); + } + + /** + * @test + */ + public function eofReportsTrueWhenStreamIsDetached() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + + fseek($resource, 2); + $stream->detach(); + $this->assertTrue($stream->eof()); + } + + /** + * @test + */ + public function isSeekableReturnsTrueForReadableStreams() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $this->assertTrue($stream->isSeekable()); + } + + /** + * @test + */ + public function isSeekableReturnsFalseForDetachedStreams() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->detach(); + $this->assertFalse($stream->isSeekable()); + } + + /** + * @test + */ + public function seekAdvancesToGivenOffsetOfStream() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->seek(2); + $this->assertEquals(2, $stream->tell()); + } + + /** + * @test + */ + public function rewindResetsToStartOfStream() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->seek(2); + $stream->rewind(); + $this->assertEquals(0, $stream->tell()); + } + + /** + * @test + */ + public function seekRaisesExceptionWhenStreamIsDetached() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->detach(); + $this->setExpectedException('RuntimeException', 'No resource'); + $stream->seek(2); + } + + /** + * @test + */ + public function isWritableReturnsFalseWhenStreamIsDetached() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->detach(); + $this->assertFalse($stream->isWritable()); + } + + /** + * @test + */ + public function writeRaisesExceptionWhenStreamIsDetached() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->detach(); + $this->setExpectedException('RuntimeException', 'No resource'); + $stream->write('bar'); + } + + /** + * @test + */ + public function isReadableReturnsFalseWhenStreamIsDetached() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'wb+'); + $stream = new Stream($resource); + $stream->detach(); + $this->assertFalse($stream->isReadable()); + } + + /** + * @test + */ + public function readRaisesExceptionWhenStreamIsDetached() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'r'); + $stream = new Stream($resource); + $stream->detach(); + $this->setExpectedException('RuntimeException', 'No resource'); + $stream->read(4096); + } + + /** + * @test + */ + public function readReturnsEmptyStringWhenAtEndOfFile() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'r'); + $stream = new Stream($resource); + while (!feof($resource)) { + fread($resource, 4096); + } + $this->assertEquals('', $stream->read(4096)); + } + + /** + * @test + */ + public function getContentsReturnsEmptyStringIfStreamIsNotReadable() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + $this->testFilesToDelete[] = $fileName; + file_put_contents($fileName, 'FOO BAR'); + $resource = fopen($fileName, 'w'); + $stream = new Stream($resource); + $this->assertEquals('', $stream->getContents()); + } + + /** + * @return array + */ + public function invalidResourcesDataProvider() { + $fileName = tempnam(sys_get_temp_dir(), 'PHLY'); + $this->testFilesToDelete[] = $fileName; + + return [ + 'null' => [NULL], + 'false' => [FALSE], + 'true' => [TRUE], + 'int' => [1], + 'float' => [1.1], + 'string-non-resource' => ['foo-bar-baz'], + 'array' => [[fopen($fileName, 'r+')]], + 'object' => [(object) ['resource' => fopen($fileName, 'r+')]], + ]; + } + + /** + * @dataProvider invalidResourcesDataProvider + * @test + */ + public function attachWithNonStringNonResourceRaisesException($resource) { + $this->setExpectedException('InvalidArgumentException', 'Invalid stream'); + $this->stream->attach($resource); + } + + /** + * @test + */ + public function attachWithResourceAttachesResource() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'r+'); + $this->stream->attach($resource); + + $r = new \ReflectionProperty($this->stream, 'resource'); + $r->setAccessible(TRUE); + $test = $r->getValue($this->stream); + $this->assertSame($resource, $test); + } + + /** + * @test + */ + public function attachWithStringRepresentingResourceCreatesAndAttachesResource() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $this->stream->attach($fileName); + + $resource = fopen($fileName, 'r+'); + fwrite($resource, 'FooBar'); + + $this->stream->rewind(); + $test = (string) $this->stream; + $this->assertEquals('FooBar', $test); + } + + /** + * @test + */ + public function getContentsShouldGetFullStreamContents() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'r+'); + $this->stream->attach($resource); + + fwrite($resource, 'FooBar'); + + // rewind, because current pointer is at end of stream! + $this->stream->rewind(); + $test = $this->stream->getContents(); + $this->assertEquals('FooBar', $test); + } + + /** + * @test + */ + public function getContentsShouldReturnStreamContentsFromCurrentPointer() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'r+'); + $this->stream->attach($resource); + + fwrite($resource, 'FooBar'); + + // seek to position 3 + $this->stream->seek(3); + $test = $this->stream->getContents(); + $this->assertEquals('Bar', $test); + } + + /** + * @test + */ + public function getMetadataReturnsAllMetadataWhenNoKeyPresent() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'r+'); + $this->stream->attach($resource); + + $expected = stream_get_meta_data($resource); + $test = $this->stream->getMetadata(); + + $this->assertEquals($expected, $test); + } + + /** + * @test + */ + public function getMetadataReturnsDataForSpecifiedKey() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'r+'); + $this->stream->attach($resource); + + $metadata = stream_get_meta_data($resource); + $expected = $metadata['uri']; + + $test = $this->stream->getMetadata('uri'); + + $this->assertEquals($expected, $test); + } + + /** + * @test + */ + public function getMetadataReturnsNullIfNoDataExistsForKey() { + $fileName = PATH_site . 'typo3temp/' . $this->getUniqueId('test_'); + touch($fileName); + $this->testFilesToDelete[] = $fileName; + $resource = fopen($fileName, 'r+'); + $this->stream->attach($resource); + + $this->assertNull($this->stream->getMetadata('TOTALLY_MADE_UP')); + } + + /** + * @test + */ + public function getSizeReturnsStreamSize() { + $resource = fopen(__FILE__, 'r'); + $expected = fstat($resource); + $stream = new Stream($resource); + $this->assertEquals($expected['size'], $stream->getSize()); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Http/UploadedFileTest.php b/typo3/sysext/core/Tests/Unit/Http/UploadedFileTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a7106e01f57f33de545d63e32c4dbb88175c3d21 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/UploadedFileTest.php @@ -0,0 +1,258 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\Stream; +use TYPO3\CMS\Core\Http\UploadedFile; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Testcase for \TYPO3\CMS\Core\Http\UploadedFile + * + * Adapted from https://github.com/phly/http/ + */ +class UploadedFileTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + protected $tmpFile; + + public function setUp() { + $this->tmpfile = NULL; + } + + public function tearDown() { + if (is_scalar($this->tmpFile) && file_exists($this->tmpFile)) { + unlink($this->tmpFile); + } + } + + /** + * @return array + */ + public function invalidStreamsDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + /* Have not figured out a valid way to test an invalid path yet; null byte injection + * appears to get caught by fopen() + 'invalid-path' => [ ('WIN' === strtoupper(substr(PHP_OS, 0, 3))) ? '[:]' : 'foo' . chr(0) ], + */ + 'array' => [['filename']], + 'object' => [(object) ['filename']], + ]; + } + + /** + * @dataProvider invalidStreamsDataProvider + * @test + */ + public function constructorRaisesExceptionOnInvalidStreamOrFile($streamOrFile) { + $this->setExpectedException('InvalidArgumentException'); + new UploadedFile($streamOrFile, 0, UPLOAD_ERR_OK); + } + + /** + * @return array + */ + public function invalidSizesDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'float' => [1.1], + 'string' => ['1'], + 'array' => [[1]], + 'object' => [(object) [1]], + ]; + } + + /** + * @dataProvider invalidSizesDataProvider + * @test + */ + public function constructorRaisesExceptionOnInvalidSize($size) { + $this->setExpectedException('InvalidArgumentException', 'size'); + new UploadedFile(fopen('php://temp', 'wb+'), $size, UPLOAD_ERR_OK); + } + + /** + * @return array + */ + public function invalidErrorStatusesDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'float' => [1.1], + 'string' => ['1'], + 'array' => [[1]], + 'object' => [(object) [1]], + 'negative' => [-1], + 'too-big' => [9], + ]; + } + + /** + * @dataProvider invalidErrorStatusesDataProvider + * @test + */ + public function constructorRaisesExceptionOnInvalidErrorStatus($status) { + $this->setExpectedException('InvalidArgumentException', 'status'); + new UploadedFile(fopen('php://temp', 'wb+'), 0, $status); + } + + /** + * @return array + */ + public function invalidFilenamesAndMediaTypesDataProvider() { + return [ + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'array' => [['string']], + 'object' => [(object) ['string']], + ]; + } + + /** + * @dataProvider invalidFilenamesAndMediaTypesDataProvider + * @test + */ + public function constructorRaisesExceptionOnInvalidClientFilename($filename) { + $this->setExpectedException('InvalidArgumentException', 'filename'); + new UploadedFile(fopen('php://temp', 'wb+'), 0, UPLOAD_ERR_OK, $filename); + } + + /** + * @dataProvider invalidFilenamesAndMediaTypesDataProvider + * @test + */ + public function constructorRaisesExceptionOnInvalidClientMediaType($mediaType) { + $this->setExpectedException('InvalidArgumentException', 'media type'); + new UploadedFile(fopen('php://temp', 'wb+'), 0, UPLOAD_ERR_OK, 'foobar.baz', $mediaType); + } + + /** + * @test + */ + public function getStreamReturnsOriginalStreamObject() { + $stream = new Stream('php://temp'); + $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $this->assertSame($stream, $upload->getStream()); + } + + /** + * @test + */ + public function getStreamReturnsWrappedPhpStream() { + $stream = fopen('php://temp', 'wb+'); + $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $uploadStream = $upload->getStream()->detach(); + $this->assertSame($stream, $uploadStream); + } + + /** + * @test + */ + public function getStreamReturnsStreamForFile() { + $this->tmpFile = $stream = tempnam(sys_get_temp_dir(), 'phly'); + $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $uploadStream = $upload->getStream(); + $r = new \ReflectionProperty($uploadStream, 'stream'); + $r->setAccessible(TRUE); + $this->assertSame($stream, $r->getValue($uploadStream)); + } + + /** + * @test + */ + public function moveToMovesFileToDesignatedPath() { + $stream = new Stream('php://temp', 'wb+'); + $stream->write('Foo bar!'); + $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + + $this->tmpFile = $to = GeneralUtility::tempnam('psr7'); + $upload->moveTo($to); + $this->assertTrue(file_exists($to)); + $contents = file_get_contents($to); + $this->assertEquals($stream->__toString(), $contents); + } + + /** + * @return array + */ + public function invalidMovePathsDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'empty' => [''], + 'array' => [['filename']], + 'object' => [(object) ['filename']], + ]; + } + + /** + * @dataProvider invalidMovePathsDataProvider + * @test + */ + public function moveToRaisesExceptionForInvalidPath($path) { + $stream = new Stream('php://temp', 'wb+'); + $stream->write('Foo bar!'); + $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + + $this->tmpFile = $path; + $this->setExpectedException('InvalidArgumentException', 'path'); + $upload->moveTo($path); + } + + /** + * @test + */ + public function moveToCannotBeCalledMoreThanOnce() { + $stream = new Stream('php://temp', 'wb+'); + $stream->write('Foo bar!'); + $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + + $this->tmpFile = $to = GeneralUtility::tempnam('psr7'); + $upload->moveTo($to); + $this->assertTrue(file_exists($to)); + + $this->setExpectedException('RuntimeException', 'moved'); + $upload->moveTo($to); + } + + /** + * @test + */ + public function getGetStreamRaisesExceptionAfterMove() { + $stream = new Stream('php://temp', 'wb+'); + $stream->write('Foo bar!'); + $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + + $this->tmpFile = $to = GeneralUtility::tempnam('psr7'); + $upload->moveTo($to); + $this->assertTrue(file_exists($to)); + + $this->setExpectedException('RuntimeException', 'moved'); + $upload->getStream(); + } + +} diff --git a/typo3/sysext/core/Tests/Unit/Http/UriTest.php b/typo3/sysext/core/Tests/Unit/Http/UriTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5024f4c0dbe1b33eb7210fe8f0e9d04a0a44c450 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/UriTest.php @@ -0,0 +1,486 @@ +<?php +namespace TYPO3\CMS\Core\Tests\Unit\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 TYPO3\CMS\Core\Http\Uri; + +/** + * Testcase for \TYPO3\CMS\Core\Http\Uri + * + * Adapted from https://github.com/phly/http/ + */ +class UriTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @test + */ + public function constructorSetsAllProperties() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $this->assertEquals('https', $uri->getScheme()); + $this->assertEquals('user:pass', $uri->getUserInfo()); + $this->assertEquals('local.example.com', $uri->getHost()); + $this->assertEquals(3001, $uri->getPort()); + $this->assertEquals('user:pass@local.example.com:3001', $uri->getAuthority()); + $this->assertEquals('/foo', $uri->getPath()); + $this->assertEquals('bar=baz', $uri->getQuery()); + $this->assertEquals('quz', $uri->getFragment()); + } + + /** + * @test + */ + public function canSerializeToString() { + $url = 'https://user:pass@local.example.com:3001/foo?bar=baz#quz'; + $uri = new Uri($url); + $this->assertEquals($url, (string) $uri); + } + + /** + * @test + */ + public function withSchemeReturnsNewInstanceWithNewScheme() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withScheme('http'); + $this->assertNotSame($uri, $new); + $this->assertEquals('http', $new->getScheme()); + $this->assertEquals('http://user:pass@local.example.com:3001/foo?bar=baz#quz', (string) $new); + } + + /** + * @test + */ + public function withUserInfoReturnsNewInstanceWithProvidedUser() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withUserInfo('matthew'); + $this->assertNotSame($uri, $new); + $this->assertEquals('matthew', $new->getUserInfo()); + $this->assertEquals('https://matthew@local.example.com:3001/foo?bar=baz#quz', (string) $new); + } + + /** + * @test + */ + public function withUserInfoReturnsNewInstanceWithProvidedUserAndPassword() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withUserInfo('matthew', 'zf2'); + $this->assertNotSame($uri, $new); + $this->assertEquals('matthew:zf2', $new->getUserInfo()); + $this->assertEquals('https://matthew:zf2@local.example.com:3001/foo?bar=baz#quz', (string) $new); + } + + /** + * @test + */ + public function withHostReturnsNewInstanceWithProvidedHost() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withHost('framework.zend.com'); + $this->assertNotSame($uri, $new); + $this->assertEquals('framework.zend.com', $new->getHost()); + $this->assertEquals('https://user:pass@framework.zend.com:3001/foo?bar=baz#quz', (string) $new); + } + + /** + * @return array + */ + public function validPortsDataProvider() { + return [ + 'int' => [3000], + 'string' => ["3000"] + ]; + } + + /** + * @dataProvider validPortsDataProvider + * @test + */ + public function withPortReturnsNewInstanceWithProvidedPort($port) { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withPort($port); + $this->assertNotSame($uri, $new); + $this->assertEquals($port, $new->getPort()); + $this->assertEquals( + sprintf('https://user:pass@local.example.com:%d/foo?bar=baz#quz', $port), + (string) $new + ); + } + + /** + * @return array + */ + public function invalidPortsDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'string' => ['string'], + 'array' => [[3000]], + 'object' => [(object) [3000]], + 'zero' => [0], + 'too-small' => [-1], + 'too-big' => [65536], + ]; + } + + /** + * @dataProvider invalidPortsDataProvider + */ + public function withPortRaisesExceptionForInvalidPorts($port) { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $this->setExpectedException('InvalidArgumentException', 'Invalid port'); + $new = $uri->withPort($port); + } + + /** + * @test + */ + public function withPathReturnsNewInstanceWithProvidedPath() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withPath('/bar/baz'); + $this->assertNotSame($uri, $new); + $this->assertEquals('/bar/baz', $new->getPath()); + $this->assertEquals('https://user:pass@local.example.com:3001/bar/baz?bar=baz#quz', (string) $new); + } + + /** + * @return array + */ + public function invalidPathsDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'array' => [['/bar/baz']], + 'object' => [(object) ['/bar/baz']], + 'query' => ['/bar/baz?bat=quz'], + 'fragment' => ['/bar/baz#bat'], + ]; + } + + /** + * @dataProvider invalidPathsDataProvider + * @test + */ + public function withPathRaisesExceptionForInvalidPaths($path) { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $this->setExpectedException('InvalidArgumentException', 'Invalid path'); + $new = $uri->withPath($path); + } + + /** + * @test + */ + public function withQueryReturnsNewInstanceWithProvidedQuery() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withQuery('baz=bat'); + $this->assertNotSame($uri, $new); + $this->assertEquals('baz=bat', $new->getQuery()); + $this->assertEquals('https://user:pass@local.example.com:3001/foo?baz=bat#quz', (string) $new); + } + + /** + * @return array + */ + public function invalidQueryStringsDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'array' => [['baz=bat']], + 'object' => [(object) ['baz=bat']], + 'fragment' => ['baz=bat#quz'], + ]; + } + + /** + * @dataProvider invalidQueryStringsDataProvider + * @test + */ + public function withQueryRaisesExceptionForInvalidQueryStrings($query) { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $this->setExpectedException('InvalidArgumentException', 'Query string'); + $new = $uri->withQuery($query); + } + + /** + * @test + */ + public function withFragmentReturnsNewInstanceWithProvidedFragment() { + $uri = new Uri('https://user:pass@local.example.com:3001/foo?bar=baz#quz'); + $new = $uri->withFragment('qat'); + $this->assertNotSame($uri, $new); + $this->assertEquals('qat', $new->getFragment()); + $this->assertEquals('https://user:pass@local.example.com:3001/foo?bar=baz#qat', (string) $new); + } + + /** + * @return array + */ + public function authorityInfoDataProvider() { + return [ + 'host-only' => ['http://foo.com/bar', 'foo.com'], + 'host-port' => ['http://foo.com:3000/bar', 'foo.com:3000'], + 'user-host' => ['http://me@foo.com/bar', 'me@foo.com'], + 'user-host-port' => ['http://me@foo.com:3000/bar', 'me@foo.com:3000'], + ]; + } + + /** + * @dataProvider authorityInfoDataProvider + * @test + */ + public function getAuthorityReturnsExpectedValues($url, $expected) { + $uri = new Uri($url); + $this->assertEquals($expected, $uri->getAuthority()); + } + + /** + * @test + */ + public function canEmitOriginFormUrl() { + $url = '/foo/bar?baz=bat'; + $uri = new Uri($url); + $this->assertEquals($url, (string) $uri); + } + + /** + * @test + */ + public function settingEmptyPathOnAbsoluteUriReturnsAnEmptyPath() { + $uri = new Uri('http://example.com/foo'); + $new = $uri->withPath(''); + $this->assertEquals('', $new->getPath()); + } + + /** + * @test + */ + public function stringRepresentationOfAbsoluteUriWithNoPathSetsAnEmptyPath() { + $uri = new Uri('http://example.com'); + $this->assertEquals('http://example.com', (string) $uri); + } + + /** + * @test + */ + public function getPathOnOriginFormRemainsAnEmptyPath() { + $uri = new Uri('?foo=bar'); + $this->assertEquals('', $uri->getPath()); + } + + /** + * @test + */ + public function stringRepresentationOfOriginFormWithNoPathRetainsEmptyPath() { + $uri = new Uri('?foo=bar'); + $this->assertEquals('?foo=bar', (string) $uri); + } + + /** + * @return array + */ + public function invalidConstructorUrisDataProvider() { + return [ + 'null' => [NULL], + 'true' => [TRUE], + 'false' => [FALSE], + 'int' => [1], + 'float' => [1.1], + 'array' => [['http://example.com/']], + 'object' => [(object) ['uri' => 'http://example.com/']], + ]; + } + + /** + * @dataProvider invalidConstructorUrisDataProvider + */ + public function constructorRaisesExceptionForNonStringURI($uri) { + $this->setExpectedException('InvalidArgumentException'); + new Uri($uri); + } + + /** + * @test + */ + public function constructorRaisesExceptionForSeriouslyMalformedURI() { + $this->setExpectedException('InvalidArgumentException'); + new Uri('http:///www.php-fig.org/'); + } + + /** + * @test + */ + public function withSchemeStripsOffDelimiter() { + $uri = new Uri('http://example.com'); + $new = $uri->withScheme('https://'); + $this->assertEquals('https', $new->getScheme()); + } + + /** + * @return array + */ + public function invalidSchemesDataProvider() { + return [ + 'mailto' => ['mailto'], + 'ftp' => ['ftp'], + 'telnet' => ['telnet'], + 'ssh' => ['ssh'], + 'git' => ['git'], + ]; + } + + /** + * @dataProvider invalidSchemesDataProvider + * @test + */ + public function constructWithUnsupportedSchemeRaisesAnException($scheme) { + $this->setExpectedException('InvalidArgumentException', 'Unsupported scheme'); + $uri = new Uri($scheme . '://example.com'); + } + + /** + * @dataProvider invalidSchemesDataProvider + * @test + */ + public function withSchemeUsingUnsupportedSchemeRaisesAnException($scheme) { + $uri = new Uri('http://example.com'); + $this->setExpectedException('InvalidArgumentException', 'Unsupported scheme'); + $uri->withScheme($scheme); + } + + /** + * @test + */ + public function withPathIsNotPrefixedWithSlashIfSetWithoutOne() { + $uri = new Uri('http://example.com'); + $new = $uri->withPath('foo/bar'); + $this->assertEquals('foo/bar', $new->getPath()); + } + + /** + * @test + */ + public function withPathNotSlashPrefixedIsEmittedWithSlashDelimiterWhenUriIsCastToString() { + $uri = new Uri('http://example.com'); + $new = $uri->withPath('foo/bar'); + $this->assertEquals('http://example.com/foo/bar', $new->__toString()); + } + + /** + * @test + */ + public function withQueryStripsQueryPrefixIfPresent() { + $uri = new Uri('http://example.com'); + $new = $uri->withQuery('?foo=bar'); + $this->assertEquals('foo=bar', $new->getQuery()); + } + + /** + * @test + */ + public function withFragmentStripsFragmentPrefixIfPresent() { + $uri = new Uri('http://example.com'); + $new = $uri->withFragment('#/foo/bar'); + $this->assertEquals('/foo/bar', $new->getFragment()); + } + + /** + * @return array + */ + public function standardSchemePortCombinationsDataProvider() { + return [ + 'http' => ['http', 80], + 'https' => ['https', 443], + ]; + } + + /** + * @dataProvider standardSchemePortCombinationsDataProvider + * @test + */ + public function getAuthorityOmitsPortForStandardSchemePortCombinations($scheme, $port) { + $uri = (new Uri()) + ->withHost('example.com') + ->withScheme($scheme) + ->withPort($port); + $this->assertEquals('example.com', $uri->getAuthority()); + } + + /** + * @test + */ + public function getPathIsProperlyEncoded() { + $uri = (new Uri())->withPath('/foo^bar'); + $expected = '/foo%5Ebar'; + $this->assertEquals($expected, $uri->getPath()); + } + + /** + * @test + */ + public function getPathDoesNotBecomeDoubleEncoded() { + $uri = (new Uri())->withPath('/foo%5Ebar'); + $expected = '/foo%5Ebar'; + $this->assertEquals($expected, $uri->getPath()); + } + + /** + * @return array + */ + public function queryStringsForEncodingDataProvider() { + return [ + 'key-only' => ['k^ey', 'k%5Eey'], + 'key-value' => ['k^ey=valu`', 'k%5Eey=valu%60'], + 'array-key-only' => ['key[]', 'key%5B%5D'], + 'array-key-value' => ['key[]=valu`', 'key%5B%5D=valu%60'], + 'complex' => ['k^ey&key[]=valu`&f<>=`bar', 'k%5Eey&key%5B%5D=valu%60&f%3C%3E=%60bar'], + ]; + } + + /** + * @dataProvider queryStringsForEncodingDataProvider + * @test + */ + public function getQueryIsProperlyEncoded($query, $expected) { + $uri = (new Uri())->withQuery($query); + $this->assertEquals($expected, $uri->getQuery()); + } + + /** + * @dataProvider queryStringsForEncodingDataProvider + * @test + */ + public function getQueryIsNotDoubleEncoded($query, $expected) { + $uri = (new Uri())->withQuery($expected); + $this->assertEquals($expected, $uri->getQuery()); + } + + /** + * @test + */ + public function getFragmentIsProperlyEncoded() { + $uri = (new Uri())->withFragment('/p^th?key^=`bar#b@z'); + $expected = '/p%5Eth?key%5E=%60bar%23b@z'; + $this->assertEquals($expected, $uri->getFragment()); + } + + /** + * @test + */ + public function getFragmentIsNotDoubleEncoded() { + $expected = '/p%5Eth?key%5E=%60bar%23b@z'; + $uri = (new Uri())->withFragment($expected); + $this->assertEquals($expected, $uri->getFragment()); + } +} diff --git a/typo3/sysext/frontend/Classes/Http/EidRequestHandler.php b/typo3/sysext/frontend/Classes/Http/EidRequestHandler.php index a2fbef86377e3f243031565f245d3e000e6e5d76..2fe2551f2a615f3d4facbca1a0110519dbf3b08b 100644 --- a/typo3/sysext/frontend/Classes/Http/EidRequestHandler.php +++ b/typo3/sysext/frontend/Classes/Http/EidRequestHandler.php @@ -20,6 +20,7 @@ use TYPO3\CMS\Core\TimeTracker\TimeTracker; use TYPO3\CMS\Frontend\Utility\EidUtility; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Core\RequestHandlerInterface; +use Psr\Http\Message\ServerRequestInterface; /** * Lightweight alternative to the regular RequestHandler used when $_GET[eID] is set. @@ -34,7 +35,7 @@ class EidRequestHandler implements RequestHandlerInterface { protected $bootstrap; /** - * Constructor handing over the bootstrap + * Constructor handing over the bootstrap and the original request * * @param Bootstrap $bootstrap */ @@ -45,15 +46,17 @@ class EidRequestHandler implements RequestHandlerInterface { /** * Handles a frontend request based on the _GP "eID" variable. * - * @return void + * @param ServerRequestInterface $request + * @return NULL|\Psr\Http\Message\ResponseInterface */ - public function handleRequest() { + public function handleRequest(ServerRequestInterface $request) { + $response = NULL; // Timetracking started $configuredCookieName = trim($GLOBALS['TYPO3_CONF_VARS']['BE']['cookieName']); if (empty($configuredCookieName)) { $configuredCookieName = 'be_typo_user'; } - if ($_COOKIE[$configuredCookieName]) { + if ($request->getCookieParams()[$configuredCookieName]) { $GLOBALS['TT'] = new TimeTracker(); } else { $GLOBALS['TT'] = new NullTimeTracker(); @@ -74,17 +77,18 @@ class EidRequestHandler implements RequestHandlerInterface { // Remove any output produced until now $this->bootstrap->endOutputBufferingAndCleanPreviousOutput(); require EidUtility::getEidScriptPath(); - $this->bootstrap->shutdown(); - exit; + + return $response; } /** * This request handler can handle any frontend request. * + * @param ServerRequestInterface $request The request to process * @return bool If the request is not an eID request, TRUE otherwise FALSE */ - public function canHandleRequest() { - return GeneralUtility::_GP('eID') ? TRUE : FALSE; + public function canHandleRequest(ServerRequestInterface $request) { + return $request->getQueryParams()['eID'] || $request->getParsedBody()['eID'] ? TRUE : FALSE; } /** @@ -96,4 +100,5 @@ class EidRequestHandler implements RequestHandlerInterface { public function getPriority() { return 80; } + } diff --git a/typo3/sysext/frontend/Classes/Http/RequestHandler.php b/typo3/sysext/frontend/Classes/Http/RequestHandler.php index d11a1d6c5eecb40eee53b0f8e807687209b151ed..b223440ee214e0930e4a9825d70410b934a4fd7b 100644 --- a/typo3/sysext/frontend/Classes/Http/RequestHandler.php +++ b/typo3/sysext/frontend/Classes/Http/RequestHandler.php @@ -58,7 +58,13 @@ class RequestHandler implements RequestHandlerInterface { protected $controller; /** - * Constructor handing over the bootstrap + * The request handed over + * @var \Psr\Http\Message\ServerRequestInterface + */ + protected $request; + + /** + * Constructor handing over the bootstrap and the original request * * @param Bootstrap $bootstrap */ @@ -69,9 +75,12 @@ class RequestHandler implements RequestHandlerInterface { /** * Handles a frontend request * - * @return void + * @param \Psr\Http\Message\ServerRequestInterface $request + * @return NULL|\Psr\Http\Message\ResponseInterface */ - public function handleRequest() { + public function handleRequest(\Psr\Http\Message\ServerRequestInterface $request) { + $response = NULL; + $this->request = $request; $this->initializeTimeTracker(); // Hook to preprocess the current request: @@ -266,7 +275,9 @@ class RequestHandler implements RequestHandlerInterface { } if ($sendTSFEContent) { - echo $this->controller->content; + /** @var \TYPO3\CMS\Core\Http\Response $response */ + $response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\Response::class); + $response->getBody()->write($this->controller->content); } // Debugging Output if (isset($GLOBALS['error']) && is_object($GLOBALS['error']) && @is_callable(array($GLOBALS['error'], 'debugOutput'))) { @@ -275,15 +286,17 @@ class RequestHandler implements RequestHandlerInterface { if (TYPO3_DLOG) { GeneralUtility::devLog('END of FRONTEND session', 'cms', 0, array('_FLUSH' => TRUE)); } + return $response; } /** * This request handler can handle any frontend request. * + * @param \Psr\Http\Message\ServerRequestInterface $request * @return bool If the request is not an eID request, TRUE otherwise FALSE */ - public function canHandleRequest() { - return GeneralUtility::_GP('eID') ? FALSE : TRUE; + public function canHandleRequest(\Psr\Http\Message\ServerRequestInterface $request) { + return $request->getQueryParams()['eID'] || $request->getParsedBody()['eID'] ? FALSE : TRUE; } /** @@ -319,7 +332,7 @@ class RequestHandler implements RequestHandlerInterface { if (empty($configuredCookieName)) { $configuredCookieName = 'be_typo_user'; } - if ($_COOKIE[$configuredCookieName]) { + if ($this->request->getCookieParams()[$configuredCookieName]) { $this->timeTracker = new TimeTracker(); } else { $this->timeTracker = new NullTimeTracker(); @@ -357,4 +370,5 @@ class RequestHandler implements RequestHandlerInterface { // This is a dirty workaround and bypasses the protected access modifier of the controller member. $GLOBALS['TSFE'] = &$this->controller; } + }