TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Core\ApplicationInterface;
/**
* @internal
*/
abstract class AbstractApplication implements ApplicationInterface, RequestHandlerInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
protected ?RequestHandlerInterface $requestHandler;
/**
* Outputs content
*/
protected function sendResponse(ResponseInterface $response): void
{
if ($response instanceof NullResponse) {
return;
}
// @todo This requires some merge strategy or header callback handling
if (!headers_sent()) {
// If the response code was not changed by legacy code (still is 200)
// then allow the PSR-7 response object to explicitly set it.
// Otherwise let legacy code take precedence.
// This code path can be deprecated once we expose the response object to third party code
if (http_response_code() === 200) {
header('HTTP/' . $response->getProtocolVersion() . ' ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
foreach ($response->getHeaders() as $name => $values) {
// Allow replacement for first occurrence of header but afterward do not replace
// to allow multiple headers to be sent, e.g. for Set-Cookie headers.
$replace = true;
foreach ($values as $value) {
header($name . ': ' . $value, $replace);
$replace = false;
}
}
}
$body = $response->getBody();
if ($body instanceof SelfEmittableStreamInterface) {
while (ob_get_level()) {
ob_end_clean();
}
// Optimization for streams that use php functions like readfile() as fastpath for serving files.
$body->emit();
} else {
echo $body->__toString();
}
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
try {
$response = $this->requestHandler->handle($request);
} catch (ImmediateResponseException $exception) {
$response = $exception->getResponse();
}
return $response;
}
/**
* Set up the application and shut it down afterwards
*/
final public function run()
{
try {
$request = ServerRequestFactory::fromGlobals();
} catch (\InvalidArgumentException $e) {
$this->logger?->debug('Rejected invalid request: {message}', [
'message' => $e->getMessage(),
'exception' => $e,
]);
$this->sendResponse(new Response(null, 400));
return;
}
$response = $this->handle($request);
$this->sendResponse($response);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\Error\MethodNotAllowedException;
trait AllowedMethodsTrait
{
/**
* Assert if request method matches allowed HTTP methods and throw exception on mismatch.
*
* @param non-empty-string ...$allowedHttpMethods
* @throws MethodNotAllowedException
*/
protected function assertAllowedHttpMethod(ServerRequestInterface $request, string ...$allowedHttpMethods): void
{
if (array_filter($allowedHttpMethods) === []) {
throw new \LogicException(
'Allowed HTTP methods cannot be empty.',
1732188461,
);
}
if (!in_array($request->getMethod(), $allowedHttpMethods, true)) {
throw new MethodNotAllowedException($allowedHttpMethods, 1732193708);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireInline;
use TYPO3\CMS\Core\Configuration\ConfigurationManager;
/**
* Entry point for TYPO3
*/
class Application extends AbstractApplication
{
public function __construct(
#[AutowireInline(
class: MiddlewareDispatcher::class,
arguments: [
'$kernel' => '@' . RequestHandler::class,
'$middlewares' => '@core.middlewares',
],
)]
RequestHandlerInterface $requestHandler,
protected readonly ConfigurationManager $configurationManager,
) {
$this->requestHandler = $requestHandler;
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
/**
* Helper class to answer "Is this a frontend or backend request?".
*
* It requires a PSR-7 ServerRequestInterface request object. Typical usage:
*
* ```
* ApplicationType::fromRequest($request)->isFrontend()
* ```
*
* Note the final request object is given to your controller by the frontend and
* backend RequestHandler's. This request should be used in code calling this class.
* However, various library parts of the TYPO3 core do not receive the request
* object directly, so extensions may not receive it either. To work around
* this technical debt for now, the RequestHandler's set the final request object
* as $GLOBALS['TYPO3_REQUEST'], which can be used in those cases to feed this class.
* Also note that CLI calls often do NOT create a request object, depending on their task.
*
* Classes that may be called from CLI without request object thus use this helper like:
*
* ```
* // Do something special if this is a frontend request.
* if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface
* && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend()
* ) {
* ```
*
* Important: $GLOBALS['TYPO3_REQUEST'] is NOT available before the RequestHandler has been
* called. This especially means the question "Is this a frontend or backend request?"
* can NOT be answered in the TYPO3 bootstrap related extension files ext_localconf.php
* and Configuration/TCA/* files.
*/
enum ApplicationType: string
{
// SystemEnvironmentBuilder::REQUESTTYPE_BE
case BACKEND = 'backend';
// SystemEnvironmentBuilder::REQUESTTYPE_FE
case FRONTEND = 'frontend';
// SystemEnvironmentBuilder::REQUESTTYPE_INSTALL
case INSTALL = 'install';
/**
* Create an ApplicationType object from a given PSR-7 request.
*
* @throws \RuntimeException
*/
public static function fromRequest(ServerRequestInterface $request): self
{
$type = $request->getAttribute('applicationType');
if (!is_int($type)) {
// Request object has no valid type. Type is set by the Frontend / Backend / Install
// application. If it's missing, we've not been called behind a legit TYPO3 application object.
// This is bogus, we throw a generic RuntimeException that should not be caught.
throw new \RuntimeException('No valid attribute "applicationType" found in request object.', 1606222812);
}
if (($type & SystemEnvironmentBuilder::REQUESTTYPE_FE) === SystemEnvironmentBuilder::REQUESTTYPE_FE) {
return self::FRONTEND;
}
if (($type & SystemEnvironmentBuilder::REQUESTTYPE_BE) === SystemEnvironmentBuilder::REQUESTTYPE_BE) {
return self::BACKEND;
}
if (($type & SystemEnvironmentBuilder::REQUESTTYPE_INSTALL) === SystemEnvironmentBuilder::REQUESTTYPE_INSTALL) {
return self::INSTALL;
}
throw new \LogicException('Could not resolve application type to either frontend or backend', 1678875015);
}
public function abbreviate(): ?string
{
return self::abbreviations()[$this] ?? null;
}
public function isFrontend(): bool
{
return $this === self::FRONTEND;
}
public function isBackend(): bool
{
return $this === self::BACKEND;
}
private static function abbreviations(): \WeakMap
{
$map = new \WeakMap();
$map[self::FRONTEND] = 'FE';
$map[self::BACKEND] = 'BE';
return $map;
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http\Client;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\RejectedPromise;
use Psr\Http\Message\RequestInterface;
/**
* Guzzle client middleware for filtering allowed request targets (SSRF prevention)
*
* @internal
*/
final readonly class AllowedHostsMiddleware
{
public function __construct(
private string $context,
private array $allowedHosts,
) {}
/**
* @param callable(RequestInterface, array): PromiseInterface $nextHandler
* @return callable(RequestInterface $request, array $options): PromiseInterface
*/
public function __invoke(callable $nextHandler): callable
{
return fn(RequestInterface $request, array $options): PromiseInterface
=> $this->matches($request->getUri()->getHost())
? $nextHandler($request, $options)
: new RejectedPromise(sprintf(
'Requested host \'%s\' is not allowed in $GLOBALS[\'TYPO3_CONF_VARS\'][\'HTTP\'][\'allowed_hosts\'][\'%s\']',
$request->getUri()->getHost(),
$this->context
));
}
private function matches(string $host): bool
{
foreach ($this->allowedHosts as $allowedHost) {
// Match wildcards
if (str_contains($allowedHost, '*')) {
$expr = implode(
'.+',
array_map(
static fn(string $part): string => preg_quote($part, '/'),
explode('*', $allowedHost)
)
);
if (preg_match('/^' . $expr . '$/', $host)) {
return true;
}
} elseif ($allowedHost === $host) {
// Exact matches
return true;
}
}
return false;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http\Client;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\HandlerStack;
/**
* @internal
*/
readonly class GuzzleClientFactory
{
/**
* Creates the client to do requests
*/
public function getClient(?string $context = null): ClientInterface
{
$httpOptions = $GLOBALS['TYPO3_CONF_VARS']['HTTP'];
$httpOptions['verify'] = filter_var($httpOptions['verify'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $httpOptions['verify'];
// HEADS UP:
// Passing a guzzle handler stack instead of an array of middlewares has never been documented,
// but was theoretically possible since the introduction of handler middlewares.
// This is not considered API (we can not control the ordering of the AllowedHosts Middleware)
// but is preserved for maximum compatibility for now. It may vanish in a major release without notice.
$stack = ($httpOptions['handler'] ?? null) instanceof HandlerStack ? $httpOptions['handler'] : HandlerStack::create();
if ($context !== null) {
$allowedHosts = $httpOptions['allowed_hosts'][$context] ?? null;
if (is_array($allowedHosts)) {
$stack->push(
new AllowedHostsMiddleware($context, array_filter($allowedHosts, is_string(...))),
'typo3_allowed_hosts'
);
}
}
unset($httpOptions['allowed_hosts']);
if (is_array($httpOptions['handler'] ?? null)) {
foreach ($httpOptions['handler'] as $name => $handler) {
$stack->push($handler, (string)$name);
}
}
$httpOptions['handler'] = $stack;
return new Client($httpOptions);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Symfony\Component\HttpFoundation\Cookie;
trait CookieHeaderTrait
{
/**
* @return Cookie::SAMESITE_*
*/
private function sanitizeSameSiteCookieValue(string $cookieSameSite): string
{
if (!in_array($cookieSameSite, [Cookie::SAMESITE_STRICT, Cookie::SAMESITE_LAX, Cookie::SAMESITE_NONE], true)) {
$cookieSameSite = Cookie::SAMESITE_STRICT;
}
return $cookieSameSite;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
final readonly class CookieScope
{
public function __construct(
public string $domain,
public bool $hostOnly,
public string $path,
) {}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
trait CookieScopeTrait
{
/**
* Returns the domain and path to be used for setting cookies.
* The information is taken from the value in $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] if set,
* otherwise the normalized request params are used.
*/
private function getCookieScope(NormalizedParams $normalizedParams): CookieScope
{
$cookieDomain = $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] ?? '';
// If a specific cookie domain is defined for a given application type, use that domain
if (!empty($GLOBALS['TYPO3_CONF_VARS'][$this->loginType]['cookieDomain'])) {
$cookieDomain = $GLOBALS['TYPO3_CONF_VARS'][$this->loginType]['cookieDomain'];
}
if (!$cookieDomain) {
return new CookieScope(
domain: $normalizedParams->getRequestHostOnly(),
hostOnly: true,
// If no cookie domain is set, use the base path
path: $normalizedParams->getSitePath(),
);
}
if ($cookieDomain[0] === '/') {
$match = [];
$matchCount = @preg_match($cookieDomain, $normalizedParams->getRequestHostOnly(), $match);
if ($matchCount === false) {
$this->logger->critical(
'The regular expression for the cookie domain ({domain}) contains errors. The session is not shared across sub-domains.',
['domain' => $cookieDomain]
);
}
if ($matchCount === false || $matchCount === 0) {
return new CookieScope(
domain: $normalizedParams->getRequestHostOnly(),
hostOnly: true,
// If no cookie domain could be matched, use the base path
path: $normalizedParams->getSitePath(),
);
}
$cookieDomain = $match[0];
}
return new CookieScope(
// Normalize cookie domain by removing leading and trailing dots,
// see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3
// > Note that a leading %x2E ("."), if present, is ignored even though that character is not permitted,
// > but a trailing %x2E ("."), if present, will cause the user agent to ignore the attribute.
domain: trim($cookieDomain, '.'),
hostOnly: false,
path: '/',
);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Dispatcher which resolves a target, which was given to the request to call a controller and method (but also a callable)
* where the request contains a "target" as attribute.
*
* Used in eID Frontend Requests, see EidHandler
*/
class Dispatcher implements DispatcherInterface
{
/**
* @var ContainerInterface
*/
protected $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
/**
* Main method that fetches the target from the request and calls the target directly
*
* @param ServerRequestInterface $request the current server request
* @return ResponseInterface the filled response by the callable/controller/action
* @throws \InvalidArgumentException if the defined target is invalid
*/
public function dispatch(ServerRequestInterface $request): ResponseInterface
{
$targetIdentifier = $request->getAttribute('target');
$target = $this->getCallableFromTarget($targetIdentifier);
$arguments = [$request];
return $target(...$arguments);
}
/**
* Creates a callable out of the given parameter, which can be a string, a callable / closure or an array
* which can be invoked as a function.
*
* @param array|string|callable $target the target which is being resolved.
* @return callable
* @throws \InvalidArgumentException
*/
protected function getCallableFromTarget($target)
{
if (is_array($target)) {
return $target;
}
if ($target instanceof \Closure) {
return $target;
}
// Only a class name is given
if (is_string($target) && !str_contains($target, ':')) {
$targetObject = $this->container->has($target) ? $this->container->get($target) : GeneralUtility::makeInstance($target);
if (!method_exists($targetObject, '__invoke')) {
throw new \InvalidArgumentException('Object "' . $target . '" doesn\'t implement an __invoke() method and cannot be used as target.', 1442431631);
}
return $targetObject;
}
// Check if the target is a concatenated string of "className::actionMethod"
if (is_string($target) && str_contains($target, '::')) {
[$className, $methodName] = explode('::', $target, 2);
$targetObject = $this->container->has($className) ? $this->container->get($className) : GeneralUtility::makeInstance($className);
return [$targetObject, $methodName];
}
// Closures needs to be checked at last as a string with object::method is recognized as callable
if (is_callable($target)) {
return $target;
}
throw new \InvalidArgumentException('Invalid target for "' . $target . '", as it is not callable.', 1425381442);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* An interface for dispatcher that delegate requests to a certain callable, typically a
* controller / action combination. Usually called from the RequestHandler.
*
* @internal This low level interface is used core internally only
*/
interface DispatcherInterface
{
/**
* Main method to dispatch a request and its response to a callable object
*/
public function dispatch(ServerRequestInterface $request): ResponseInterface;
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http\Error;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\Http\HtmlResponse;
/**
* @internal
*/
final class MethodNotAllowedException extends Exception
{
public readonly array $allowedMethods;
/**
* @param non-empty-list<non-empty-string> $allowedMethods
*/
public function __construct(array $allowedMethods, int $code = 0, ?\Throwable $previous = null)
{
$this->allowedMethods = array_map('strtoupper', $allowedMethods);
parent::__construct(
sprintf(
'HTTP method is not allowed! Allowed method(s): %s',
implode(', ', $this->allowedMethods),
),
$code,
$previous,
);
}
public function createResponse(): ResponseInterface
{
return new HtmlResponse($this->message, 405, ['Allow' => implode(', ', $this->allowedMethods)]);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
use TYPO3\CMS\Core\Resource\Driver\DriverInterface;
/**
* A lazy stream, that wraps the FAL dumpFileContents() method to send file contents
* using emit(), as defined in SelfEmittableStreamInterface.
* This call will fall back to the FAL getFileContents() method if the fastpath possibility
* using SelfEmittableStreamInterface is not used.
*
* @internal
*/
readonly class FalDumpFileContentsDecoratorStream implements StreamInterface, SelfEmittableStreamInterface
{
use StreamDecoratorTrait;
public function __construct(
protected string $identifier,
protected DriverInterface $driver,
protected int $size
) {}
/**
* Emit the response to stdout, as specified in SelfEmittableStreamInterface.
* Offload to the driver method dumpFileContents.
*/
public function emit()
{
$this->driver->dumpFileContents($this->identifier);
}
/**
* Creates a stream (on demand). This method is consumed by the guzzle StreamDecoratorTrait
* and is used when this stream is used without the emit() fastpath.
*/
protected function createStream(): StreamInterface
{
$stream = new Stream('php://temp', 'rw');
$stream->write($this->driver->getFileContents($this->identifier));
return $stream;
}
public function getSize(): int
{
return $this->size;
}
public function isWritable(): bool
{
return false;
}
/**
* @throws \RuntimeException
*/
public function write(string $string): int
{
throw new \RuntimeException('Cannot write to a ' . self::class, 1538331852);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
/**
* A default HTML response object
*
* Highly inspired by ZF zend-diactoros
*
* @internal Note that this is not public API yet.
*/
class HtmlResponse extends Response
{
/**
* Creates a HTML response object with a default 200 response code
*
* @param string $content HTML content written to the response
* @param int $status status code for the response; defaults to 200.
* @param array $headers Additional headers to be set.
*/
public function __construct(string $content, int $status = 200, array $headers = [])
{
$body = new Stream('php://temp', 'wb+');
$body->write($content);
$body->rewind();
parent::__construct($body, $status, $headers);
// Ensure that text/html header is set, if Content-Type was not set before
if (!$this->hasHeader('Content-Type')) {
$this->headers['Content-Type'][] = 'text/html; charset=utf-8';
$this->lowercasedHeaderNames['content-type'] = 'Content-Type';
}
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ResponseInterface;
/**
* Exception that has to be handled immediately in order to stop
* current execution and provide the current response. This
* exception is used as alternative to previous die() or exit().
*
* Note this exception is only caught by Application classes, throwing
* it will bypass all outer middlewares. It should *not* be thrown by
* controllers, those should usually throw a PropagateResponseException
* instead, allowing outer middlewares to further process the response.
*
* @internal
*/
class ImmediateResponseException extends \Exception
{
/**
* @var ResponseInterface
*/
private $response;
public function __construct(ResponseInterface $response, int $code = 0)
{
$this->response = $response;
$this->code = $code;
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
/**
* Exception is thrown in case an invalid request url is used in CLI context
*
* @internal
*/
class InvalidRequestUrlOnCliException extends \InvalidArgumentException {}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
/**
* Standard values for a JSON response
*
* Highly inspired by ZF zend-diactoros
*
* @internal Note that this is not public API, use PSR-17 interfaces instead
*/
class JsonResponse extends Response
{
/**
* Default flags for json_encode; value of:
*
* <code>
* JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES
* </code>
*
* @var int
*/
public const DEFAULT_JSON_FLAGS = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES;
/**
* Create a JSON response with the given data.
*
* Default JSON encoding is performed with the following options, which
* produces RFC4627-compliant JSON, capable of embedding into HTML.
*
* - JSON_HEX_TAG
* - JSON_HEX_APOS
* - JSON_HEX_AMP
* - JSON_HEX_QUOT
* - JSON_UNESCAPED_SLASHES
*
* @param array|null $data Data to convert to JSON.
* @param int $status Integer status code for the response; 200 by default.
* @param array $headers Array of headers to use at initialization.
* @param int $encodingOptions JSON encoding options to use.
*/
public function __construct(
?array $data = [],
int $status = 200,
array $headers = [],
int $encodingOptions = self::DEFAULT_JSON_FLAGS
) {
$body = new Stream('php://temp', 'wb+');
parent::__construct($body, $status, $headers);
if ($data !== null) {
$this->setPayload($data, $encodingOptions);
}
// Ensure that application/json header is set, if Content-Type was not set before
if (!$this->hasHeader('Content-Type')) {
$this->headers['Content-Type'][] = 'application/json; charset=utf-8';
$this->lowercasedHeaderNames['content-type'] = 'Content-Type';
}
}
/**
* Overrides the exiting content, takes an array as input
*/
public function setPayload(array $data = [], int $encodingOptions = self::DEFAULT_JSON_FLAGS): JsonResponse
{
$this->body->write($this->jsonEncode($data, $encodingOptions));
$this->body->rewind();
return $this;
}
/**
* Encode the provided data to JSON.
*
* @throws \InvalidArgumentException if unable to encode the $data to JSON.
*/
private function jsonEncode(array $data, int $encodingOptions): string
{
// Clear json_last_error()
json_encode(null);
$json = json_encode($data, $encodingOptions);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \InvalidArgumentException(sprintf(
'Unable to encode data to JSON in %s: %s',
__CLASS__,
json_last_error_msg()
), 1504972434);
}
return $json;
}
}
+474
View File
@@ -0,0 +1,474 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
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
*/
protected string $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)
*/
protected array $headers = [];
/**
* 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
*/
protected array $lowercasedHeaderNames = [];
/**
* The body as a Stream object
*/
protected ?StreamInterface $body = null;
/**
* 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(): string
{
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 static
*/
public function withProtocolVersion(string $version): MessageInterface
{
$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(): array
{
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(string $name): bool
{
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(string $name): array
{
if (!$this->hasHeader($name)) {
return [];
}
$header = $this->lowercasedHeaderNames[strtolower($name)];
$headerValue = $this->headers[$header];
if (is_array($headerValue)) {
return $headerValue;
}
return [$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(string $name): string
{
$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 static
* @throws \InvalidArgumentException for invalid header names or values.
*/
public function withHeader(string $name, $value): MessageInterface
{
if (is_string($value)) {
$value = [$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 static
* @throws \InvalidArgumentException for invalid header names or values.
*/
public function withAddedHeader(string $name, $value): MessageInterface
{
if (is_string($value)) {
$value = [$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 static
*/
public function withoutHeader(string $name): MessageInterface
{
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 StreamInterface Returns the body as a stream.
*/
public function getBody(): StreamInterface
{
if ($this->body === null) {
$this->body = new Stream('php://temp', 'r+');
}
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.
*
* @return static
* @throws \InvalidArgumentException When the body is not valid.
*/
public function withBody(StreamInterface $body): MessageInterface
{
$clonedObject = clone $this;
$clonedObject->body = $body;
return $clonedObject;
}
/**
* Ensure header names and values are valid.
*
* @throws \InvalidArgumentException
*/
protected function assertHeaders(array $headers): void
{
foreach ($headers as $name => $headerValues) {
$this->validateHeaderName($name);
// check if all values are correct
array_walk($headerValues, static function (string $value, string $key, Message $messageObject): void {
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): array
{
$headerNames = $headers = [];
foreach ($originalHeaders as $header => $value) {
if (!is_string($header) || (!is_array($value) && !is_scalar($value))) {
continue;
}
if (!is_array($value)) {
$value = [(string)$value];
}
$headerNames[strtolower($header)] = $header;
$headers[$header] = $value;
}
return [$headerNames, $headers];
}
/**
* Helper function to test if an array contains only strings
*/
protected function arrayContainsOnlyStrings(array $data): bool
{
return array_reduce($data, static function ($original, $item) {
return is_string($item) ? $original : false;
}, true);
}
/**
* Assert that the provided header values are valid.
*
* @see https://tools.ietf.org/html/rfc7230#section-3.2
* @param string[] $values
* @throws \InvalidArgumentException
*/
protected function validateHeaderValues(array $values): void
{
array_walk($values, static function (string $value, string $key, Message $messageObject): void {
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
* @todo: Unused? And why is this public? Maybe align with zend-diactoros again
*/
public function filter(string $value): string
{
$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 a header name is valid and throw an exception.
*
* @see https://tools.ietf.org/html/rfc7230#section-3.2
* @throws \InvalidArgumentException
* @todo: Review. Should be protected / private, maybe align with zend-diactoros again
*/
public function validateHeaderName(string $name): void
{
if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/', $name)) {
throw new \InvalidArgumentException('Invalid header name, given "' . $name . '"', 1436717270);
}
}
/**
* Checks if an 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
* @todo: Review. Should be protected / private, maybe align with zend-diactoros again
*/
public function isValidHeaderValue(string $value): bool
{
// Any occurrence of \r or \n is invalid
if (strpbrk($value, "\r\n") !== false) {
return false;
}
foreach (unpack('C*', $value) as $ascii) {
// 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) {
return false;
}
}
return true;
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* MiddlewareDispatcher
*
* This class manages and dispatches a PSR-15 middleware stack.
*
* @internal
*/
class MiddlewareDispatcher implements RequestHandlerInterface
{
/**
* Tip of the middleware call stack
*
* @var RequestHandlerInterface
*/
protected $tip;
/**
* @var ContainerInterface|null
*/
protected $container;
public function __construct(
RequestHandlerInterface $kernel,
iterable $middlewares = [],
?ContainerInterface $container = null
) {
$this->container = $container;
$this->seedMiddlewareStack($kernel);
foreach ($middlewares as $middleware) {
if (is_string($middleware)) {
$this->lazy($middleware);
} else {
$this->add($middleware);
}
}
}
/**
* Invoke the middleware stack
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->tip->handle($request);
}
/**
* Seed the middleware stack with the inner request handler
*/
protected function seedMiddlewareStack(RequestHandlerInterface $kernel)
{
$this->tip = $kernel;
}
/**
* Add a new middleware to the stack
*
* Middlewares are organized as a stack. That means middlewares
* that have been added before will be executed after the newly
* added one (last in, first out).
*/
public function add(MiddlewareInterface $middleware): void
{
$next = $this->tip;
$this->tip = new class ($middleware, $next) implements RequestHandlerInterface {
public function __construct(private readonly MiddlewareInterface $middleware, private readonly RequestHandlerInterface $next) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->middleware->process($request, $this->next);
}
};
}
/**
* Add a new middleware by class name
*
* Middlewares are organized as a stack. That means middlewares
* that have been added before will be executed after the newly
* added one (last in, first out).
*
* @param string $middleware
*/
public function lazy(string $middleware): void
{
$next = $this->tip;
$this->tip = new class ($middleware, $next, $this->container) implements RequestHandlerInterface {
public function __construct(
private readonly string $middleware,
private readonly RequestHandlerInterface $next,
private readonly ?ContainerInterface $container = null
) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
if ($this->container !== null && $this->container->has($this->middleware)) {
$middleware = $this->container->get($this->middleware);
} else {
$middleware = GeneralUtility::makeInstance($this->middleware);
}
if (!$middleware instanceof MiddlewareInterface) {
throw new \InvalidArgumentException(get_class($middleware) . ' does not implement ' . MiddlewareInterface::class, 1516821342);
}
return $middleware->process($request, $this->next);
}
};
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent;
use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend as PhpFrontendCache;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
/**
* This class resolves middleware stacks from defined configuration in all active packages.
*
* @internal
*/
class MiddlewareStackResolver
{
/**
* @var ContainerInterface
*/
protected $container;
/**
* @var DependencyOrderingService
*/
protected $dependencyOrderingService;
/**
* @var PhpFrontendCache
*/
protected $cache;
private string $baseCacheIdentifier;
public function __construct(
ContainerInterface $container,
DependencyOrderingService $dependencyOrderingService,
#[Autowire(service: 'cache.core')]
PhpFrontendCache $cache,
#[Autowire(expression: 'service("package-dependent-cache-identifier").toString()')]
string $baseCacheIdentifier
) {
$this->container = $container;
$this->dependencyOrderingService = $dependencyOrderingService;
$this->cache = $cache;
$this->baseCacheIdentifier = '_' . $baseCacheIdentifier;
}
/**
* Returns the middleware stack registered in all packages within Configuration/RequestMiddlewares.php
* which are sorted by given dependency requirements
*
* @throws \TYPO3\CMS\Core\Cache\Exception\InvalidDataException
* @throws \TYPO3\CMS\Core\Exception
*/
public function resolve(string $stackName): \ArrayObject
{
return new \ArrayObject($this->getFromCache($stackName) ?? $this->computeMiddlewareStack($stackName));
}
protected function getFromCache(string $stackName): ?array
{
$cacheIdentifier = $this->getCacheIdentifier($stackName);
if (!$this->cache->has($cacheIdentifier)) {
return null;
}
$result = $this->cache->require($cacheIdentifier);
if ($result === false) {
// Cache entry has been removed in the meantime
return null;
}
if (!is_array($result)) {
// An invalid result is to be ignored (cache will be recreated)
return null;
}
return $result;
}
protected function computeMiddlewareStack(string $stackName): array
{
$allMiddlewares = $this->loadConfiguration();
$middlewares = $this->sanitizeMiddlewares($allMiddlewares);
// Ensure that we create a cache for $stackName, even if the stack is empty
if (!isset($middlewares[$stackName])) {
$middlewares[$stackName] = [];
}
foreach ($middlewares as $stack => $middlewaresOfStack) {
$this->cache->set($this->getCacheIdentifier($stack), 'return ' . var_export($middlewaresOfStack, true) . ';');
}
return $middlewares[$stackName];
}
/**
* Lazy load configuration from the container
*/
protected function loadConfiguration(): \ArrayObject
{
return $this->container->get('middlewares');
}
/**
* Order each stack and sanitize to a plain array
*/
protected function sanitizeMiddlewares(\ArrayObject $allMiddlewares): array
{
$middlewares = [];
foreach ($allMiddlewares as $stack => $middlewaresOfStack) {
$middlewaresOfStack = $this->dependencyOrderingService->orderByDependencies($middlewaresOfStack);
$sanitizedMiddlewares = [];
foreach ($middlewaresOfStack as $name => $middleware) {
if (isset($middleware['disabled']) && $middleware['disabled'] === true) {
// Skip this middleware if disabled by configuration
continue;
}
$sanitizedMiddlewares[$name] = $middleware['target'];
}
// Order reverse, MiddlewareDispatcher executes the last middleware in the array first (last in, first out).
$middlewares[$stack] = array_reverse($sanitizedMiddlewares);
}
return $middlewares;
}
protected function getCacheIdentifier(string $stackName): string
{
return 'middlewares_' . $stackName . $this->baseCacheIdentifier;
}
#[AsEventListener('typo3-core/middlewares')]
public function warmupCaches(CacheWarmupEvent $event): void
{
if ($event->hasGroup('system')) {
$allMiddlewares = $this->loadConfiguration();
$middlewares = $this->sanitizeMiddlewares($allMiddlewares);
foreach ($middlewares as $stack => $middlewaresOfStack) {
$this->cache->set($this->getCacheIdentifier($stack), 'return ' . var_export($middlewaresOfStack, true) . ';');
}
}
}
}
+835
View File
@@ -0,0 +1,835 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This class provides normalized server parameters in HTTP request context.
* It normalizes reverse proxy scenarios and various other web server specific differences
* of the native PSR-7 request object parameters (->getServerParams() / $GLOBALS['_SERVER']).
*
* An instance of this class is available as PSR-7 ServerRequestInterface attribute:
*
* ```
* $normalizedParams = $request->getAttribute('normalizedParams')
* ```
*/
class NormalizedParams
{
/**
* Sanitized HTTP_HOST value
*
* host[:port]
*
* - www.domain.com
* - www.domain.com:443
* - 192.168.1.42:80
*/
protected string $httpHost = '';
/**
* True if request has been done via HTTPS
*/
protected bool $isHttps = false;
/**
* Sanitized HTTP_HOST with protocol
*
* scheme://host[:port]
*
* - https://www.domain.com
*/
protected string $requestHost = '';
/**
* Host / domain part of HTTP_HOST, no port, no protocol
*
* - www.domain.com
* - 192.168.1.42
*/
protected string $requestHostOnly = '';
/**
* Port of HTTP_HOST if given
*/
protected int $requestPort = 0;
/**
* Entry script path of URI, without domain and without query parameters, with leading /
*
* [path_script]
*
* - /index.php
* - /typo3/index.php
*/
protected string $scriptName = '';
/**
* REQUEST URI without domain and scheme, with trailing slash
*
* [path][?[query]]
*
* - /some/path?p1=parameter1&p2[key]=value
* - /typo3/some/path?p1=parameter1&p2[key]=value
*/
protected string $requestUri = '';
/**
* REQUEST URI with scheme, host, port, path and query
*
* scheme://host[:[port]][path][?[query]]
*
* - http://www.domain.com/some/path?p1=parameter1&p2[key]=value
* - http://www.domain.com/typo3/some/path?id=42
*/
protected string $requestUrl = '';
/**
* REQUEST URI with scheme, host, port and path, but *without* query part
*
* scheme://host[:[port]][path_script]
*
* - http://www.domain.com/index.php
* - http://www.domain.com/typo3/index.php
*/
protected string $requestScript = '';
/**
* Full Uri with path, but without script name and query parts
*
* scheme://host[:[port]][path_dir]
*
* - http://www.domain.com/
* - http://www.domain.com/typo3/
*/
protected string $requestDir = '';
/**
* True if request via a reverse proxy is detected
*/
protected bool $isBehindReverseProxy = false;
/**
* IPv4 or IPv6 address of remote client with resolved proxy setup
*/
protected string $remoteAddress = '';
/**
* Absolute server path to entry script on server filesystem
*
* - /var/www/index.php
* - /var/www/typo3/index.php
*/
protected string $scriptFilename = '';
/**
* Absolute server path to web document root without trailing slash
*
* - /var/www
*/
protected string $documentRoot = '';
/**
* Website frontend URL.
* Note this is note "safe" if called from Backend since sys_domain and
* other factors are not taken into account.
*
* scheme://host[:[port]]/[path_dir]
*
* - https://www.domain.com/
* - https://www.domain.com/some/sub/dir/
*/
protected string $siteUrl = '';
/**
* Path part to frontend, no domain, no protocol
*
* - /
* - /some/sub/dir/
*/
protected string $sitePath = '';
/**
* Path to script, without sub path if TYPO3 is running in sub directory, without trailing slash
*
* - index.php?id=42
* - /some/path?id=42
* - typo3/some/path?id=411
*/
protected string $siteScript = '';
/**
* Entry script path of URI, without domain and without query parameters, with leading /
* This is often not set at all.
* Will be deprecated later, use $scriptName instead as more reliable solution.
*
* [path_script]
*/
protected string $pathInfo = '';
/**
* HTTP_REFERER
* Will be deprecated later, use $request->getServerParams()['HTTP_REFERER'] instead
*
* scheme://host[:[port]][path]
*
* - https://www.domain.com/typo3/module/web/layout?id=42
*/
protected string $httpReferer = '';
/**
* HTTP_USER_AGENT
* Will be deprecated later, use $request->getServerParams()['HTTP_USER_AGENT'] instead
*
* - Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36
*/
protected string $httpUserAgent = '';
/**
* HTTP_ACCEPT_ENCODING
* Will be deprecated later, use $request->getServerParams()['HTTP_ACCEPT_ENCODING'] instead
*
* - gzip, deflate
*/
protected string $httpAcceptEncoding = '';
/**
* HTTP_ACCEPT_LANGUAGE
* Will be deprecated later, use $request->getServerParams()['HTTP_ACCEPT_LANGUAGE'] instead
*
* - de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
*/
protected string $httpAcceptLanguage = '';
/**
* REMOTE_HOST Resolved host name of REMOTE_ADDR if configured in web server
* Will be deprecated later, use $request->getServerParams()['REMOTE_HOST'] instead
*
* - www.clientDomain.com
*/
protected string $remoteHost = '';
/**
* QUERY_STRING
* Will be deprecated later, use $request->getServerParams()['QUERY_STRING'] instead
*
* [query]
*
* - id=42&foo=bar
*/
protected string $queryString = '';
/**
* Constructor calculates all values by incoming variables.
*
* This object is immutable.
*
* All determine*() "detail worker methods" in this class retrieve their dependencies
* to other properties as method arguments, they are static, stateless and have no
* dependency to $this. This ensures the chain of inter-property dependencies
* is visible by only looking at the construct() method.
*
* @param array $serverParams , usually coming from $_SERVER or $request->getServerParams()
* @param array $configuration $GLOBALS['TYPO3_CONF_VARS']['SYS']
* @param string $pathThisScript Absolute server entry script path, usually found within Environment::getCurrentScript()
* @param string $pathSite Absolute server path to document root, Environment::getPublicPath()
*/
public function __construct(array $serverParams, array $configuration, string $pathThisScript, string $pathSite)
{
$isBehindReverseProxy = $this->isBehindReverseProxy = self::determineIsBehindReverseProxy(
$serverParams,
$configuration
);
$httpHost = $this->httpHost = self::determineHttpHost($serverParams, $configuration, $isBehindReverseProxy);
$isHttps = $this->isHttps = self::determineHttps($serverParams, $configuration);
$requestHost = $this->requestHost = ($isHttps ? 'https://' : 'http://') . $httpHost;
$requestHostOnly = $this->requestHostOnly = self::determineRequestHostOnly($httpHost);
$this->requestPort = self::determineRequestPort($httpHost, $requestHostOnly);
$scriptNameOnFileSystem = self::determineScriptName(
$serverParams,
$configuration,
$isHttps,
$isBehindReverseProxy
);
$scriptName = $this->scriptName = self::encodeFileSystemPathComponentForUrlPath($scriptNameOnFileSystem);
$requestUri = $this->requestUri = self::determineRequestUri(
$serverParams,
$configuration,
$isHttps,
$scriptName,
$isBehindReverseProxy
);
$requestUrl = $this->requestUrl = $requestHost . $requestUri;
$this->requestScript = $requestHost . $scriptName;
$requestDir = $this->requestDir = $requestHost . GeneralUtility::dirname($scriptName) . '/';
$this->remoteAddress = self::determineRemoteAddress($serverParams, $configuration, $isBehindReverseProxy);
$scriptFilename = $this->scriptFilename = $pathThisScript;
$this->documentRoot = self::determineDocumentRoot($scriptNameOnFileSystem, $scriptFilename);
$siteUrl = $this->siteUrl = self::determineSiteUrl($requestDir, $pathThisScript, $pathSite . '/');
$this->sitePath = self::determineSitePath($requestHost, $siteUrl);
$this->siteScript = self::determineSiteScript($requestUrl, $siteUrl);
// @deprecated Below variables can be fully deprecated as soon as core does not use them anymore
$this->pathInfo = $serverParams['PATH_INFO'] ?? '';
$this->httpReferer = $serverParams['HTTP_REFERER'] ?? '';
$this->httpUserAgent = $serverParams['HTTP_USER_AGENT'] ?? '';
$this->httpAcceptEncoding = $serverParams['HTTP_ACCEPT_ENCODING'] ?? '';
$this->httpAcceptLanguage = $serverParams['HTTP_ACCEPT_LANGUAGE'] ?? '';
$this->remoteHost = $serverParams['REMOTE_HOST'] ?? '';
$this->queryString = $serverParams['QUERY_STRING'] ?? '';
}
/**
* Factory method, to allow TYPO3 to handle configuration options directly.
*
* @param array $serverParams - could be fulfilled by $_SERVER (on web requests)
* @internal framework method. Not part of TYPO3 API.
*/
public static function createFromServerParams(array $serverParams, ?array $systemConfiguration = null): self
{
return new NormalizedParams(
$serverParams,
$systemConfiguration ?? $GLOBALS['TYPO3_CONF_VARS']['SYS'],
Environment::getCurrentScript(),
Environment::getPublicPath()
);
}
/**
* Factory method for creating normalized params from a PSR-7 server request object
*
* @internal framework method. Not part of TYPO3 API.
*/
public static function createFromRequest(ServerRequestInterface $request, ?array $systemConfiguration = null): self
{
return static::createFromServerParams(
$request->getServerParams(),
$systemConfiguration ?? $GLOBALS['TYPO3_CONF_VARS']['SYS']
);
}
private static function encodeFileSystemPathComponentForUrlPath(string $path): string
{
return implode('/', array_map(rawurlencode(...), explode('/', $path)));
}
/**
* @return string Sanitized HTTP_HOST value host[:port]
*/
public function getHttpHost(): string
{
return $this->httpHost;
}
/**
* @return bool True if client request has been done using HTTPS
*/
public function isHttps(): bool
{
return $this->isHttps;
}
/**
* @return string Sanitized HTTP_HOST with protocol scheme://host[:port], eg. https://www.domain.com/
*/
public function getRequestHost(): string
{
return $this->requestHost;
}
/**
* @return string Host / domain /IP only, eg. www.domain.com
*/
public function getRequestHostOnly(): string
{
return $this->requestHostOnly;
}
/**
* @return int Requested port if given, eg. 8080 - often not explicitly given, then 0
*/
public function getRequestPort(): int
{
return $this->requestPort;
}
/**
* @return string Script path part of URI, eg. '/typo3/index.php'
*/
public function getScriptName(): string
{
return $this->scriptName;
}
/**
* @return string Request Uri without domain and protocol, eg. /index.php?id=42
*/
public function getRequestUri(): string
{
return $this->requestUri;
}
/**
* @return string Full REQUEST_URI, eg. http://www.domain.com/typo3/foo/bar?id=42
*/
public function getRequestUrl(): string
{
return $this->requestUrl;
}
/**
* @return string REQUEST URI without query part, eg. http://www.domain.com/typo3/index.php
*/
public function getRequestScript(): string
{
return $this->requestScript;
}
/**
* @return string REQUEST URI without script file name and query parts, eg. http://www.domain.com/typo3/
*/
public function getRequestDir(): string
{
return $this->requestDir;
}
/**
* @return bool True if request comes from a configured reverse proxy
*/
public function isBehindReverseProxy(): bool
{
return $this->isBehindReverseProxy;
}
/**
* @return string Client IP
*/
public function getRemoteAddress(): string
{
return $this->remoteAddress;
}
/**
* @return string Absolute entry script path on server, eg. /var/www/typo3/index.php
*/
public function getScriptFilename(): string
{
return $this->scriptFilename;
}
/**
* @return string Absolute path to web document root, eg. /var/www/typo3
*/
public function getDocumentRoot(): string
{
return $this->documentRoot;
}
/**
* @return string Website frontend url, eg. https://www.domain.com/some/sub/dir/
*/
public function getSiteUrl(): string
{
return $this->siteUrl;
}
/**
* @return string Path part to frontend, eg. /some/sub/dir/
*/
public function getSitePath(): string
{
return $this->sitePath;
}
/**
* @return string Path part to entry script with parameters, without sub dir, eg 'typo3/index.php?id=42'
*/
public function getSiteScript(): string
{
return $this->siteScript;
}
/**
* Will be deprecated later, use getScriptName() as reliable solution instead
*
* @return string Script path part of URI, eg. 'typo3/index.php'
*/
public function getPathInfo(): string
{
return $this->pathInfo;
}
/**
* Will be deprecated later, use $request->getServerParams()['HTTP_REFERER'] instead
*
* @return string HTTP_REFERER, eg. 'https://www.domain.com/typo3/index.php?id=42'
*/
public function getHttpReferer(): string
{
return $this->httpReferer;
}
/**
* Will be deprecated later, use $request->getServerParams()['HTTP_USER_AGENT'] instead
*
* @return string HTTP_USER_AGENT identifier
*/
public function getHttpUserAgent(): string
{
return $this->httpUserAgent;
}
/**
* Will be deprecated later, use $request->getServerParams()['HTTP_ACCEPT_ENCODING'] instead
*
* @return string HTTP_ACCEPT_ENCODING, eg. 'gzip, deflate'
*/
public function getHttpAcceptEncoding(): string
{
return $this->httpAcceptEncoding;
}
/**
* Will be deprecated later, use $request->getServerParams()['HTTP_ACCEPT_LANGUAGE'] instead
*
* @return string HTTP_ACCEPT_LANGUAGE, eg. 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
*/
public function getHttpAcceptLanguage(): string
{
return $this->httpAcceptLanguage;
}
/**
* Will be deprecated later, use $request->getServerParams()['REMOTE_HOST'] instead
*
* @return string REMOTE_HOST if configured in web server, eg. 'www.clientDomain.com'
*/
public function getRemoteHost(): string
{
return $this->remoteHost;
}
/**
* Will be deprecated later, use $request->getServerParams()['QUERY_STRING'] instead
*
* @return string QUERY_STRING, eg 'id=42&foo=bar'
*/
public function getQueryString(): string
{
return $this->queryString;
}
/**
* Normalize HTTP_HOST by taking proxy configuration into account.
*
* @param array $serverParams Basically the $_SERVER, but from $request object
* @param array $configuration $TYPO3_CONF_VARS['SYS'] array
* @param bool $isBehindReverseProxy True if reverse proxy setup is detected
* @return string Normalized HTTP_HOST
*/
protected static function determineHttpHost(
array $serverParams,
array $configuration,
bool $isBehindReverseProxy
): string {
$httpHost = $serverParams['HTTP_HOST'] ?? '';
if ($isBehindReverseProxy) {
// If the request comes from a configured proxy which has set HTTP_X_FORWARDED_HOST, then
// evaluate reverseProxyHeaderMultiValue and
$xForwardedHostArray = GeneralUtility::trimExplode(',', $serverParams['HTTP_X_FORWARDED_HOST'] ?? '', true);
$xForwardedHost = '';
// Choose which host in list to use
if (!empty($xForwardedHostArray)) {
$configuredReverseProxyHeaderMultiValue = trim($configuration['reverseProxyHeaderMultiValue'] ?? '');
// Default if reverseProxyHeaderMultiValue is not set or set to 'none', instead of 'first' / 'last' is to
// ignore $serverParams['HTTP_X_FORWARDED_HOST']
// @todo: Maybe this default is stupid: Both SYS/reverseProxyIP hand SYS/reverseProxyHeaderMultiValue have to
// be configured for a working setup. It would be easier to only configure SYS/reverseProxyIP and fall
// back to "first" if SYS/reverseProxyHeaderMultiValue is not set.
if ($configuredReverseProxyHeaderMultiValue === 'last') {
$xForwardedHost = array_pop($xForwardedHostArray);
} elseif ($configuredReverseProxyHeaderMultiValue === 'first') {
$xForwardedHost = array_shift($xForwardedHostArray);
}
}
if ($xForwardedHost) {
$httpHost = $xForwardedHost;
}
}
return $httpHost;
}
/**
* Determine if the client called via HTTPS. Takes proxy ssl terminator
* configurations into account.
*
* How does TYPO3 determine if the connection was established via TLS/SSL/https?
* 1. If reverseProxySSL matches, then we now that Client -> Proxy is SSL,
* and Proxy -> App Server is non-SSL. SSL Termination happens at Proxy at ALL times.
* 2. If reverseProxyIP matches, and HTTP_X_FORWARDED_PROTO is set, it is evaluated
* 3. If no other matches, see webserverUsesHttps()
*
* Note: HTTP_X_FORWARDED_PROTO is ONLY evaluated at the point, where we know
* that the incoming REMOTE_ADDR is a trusted proxy!
*
* @param array $serverParams Basically the $_SERVER, but from $request object
* @param array $configuration $TYPO3_CONF_VARS['SYS'] array
* @return bool True if request has been done via HTTPS
*/
protected static function determineHttps(array $serverParams, array $configuration): bool
{
$configuredProxySSL = trim($configuration['reverseProxySSL'] ?? '');
$configuredProxyRegular = trim($configuration['reverseProxyIP'] ?? '');
if ($configuredProxySSL === '*') {
$configuredProxySSL = $configuredProxyRegular;
}
$httpsParam = (string)($serverParams['HTTPS'] ?? '');
if (GeneralUtility::cmpIP($serverParams['REMOTE_ADDR'] ?? '', $configuredProxySSL)) {
$isHttps = true;
} elseif (isset($serverParams['HTTP_X_FORWARDED_PROTO'])
&& GeneralUtility::cmpIP($serverParams['REMOTE_ADDR'] ?? '', $configuredProxyRegular)) {
// If the X-Forwarded-Proto header is set, and we can trust the RemoteAddr to be a proxy, check it
$isHttps = strtolower($serverParams['HTTP_X_FORWARDED_PROTO']) === 'https';
} else {
// https://secure.php.net/manual/en/reserved.variables.server.php
// "Set to a non-empty value if the script was queried through the HTTPS protocol."
$isHttps = ($serverParams['SSL_SESSION_ID'] ?? '')
|| ($httpsParam !== '' && $httpsParam !== 'off' && $httpsParam !== '0');
}
return $isHttps;
}
/**
* Determine script name and path
*
* @param array $serverParams Basically the $_SERVER, but from $request object
* @param array $configuration TYPO3_CONF_VARS['SYS'] array
* @param bool $isHttps True if used protocol is HTTPS
* @param bool $isBehindReverseProxy True if reverse proxy setup is detected
* @return string Sanitized script name
*/
protected static function determineScriptName(
array $serverParams,
array $configuration,
bool $isHttps,
bool $isBehindReverseProxy
): string {
$scriptName = $serverParams['SCRIPT_NAME'] ?? '';
if ($isBehindReverseProxy) {
// Add a prefix if TYPO3 is behind a proxy: ext-domain.com => int-server.com/prefix
if ($isHttps && !empty($configuration['reverseProxyPrefixSSL'])) {
$scriptName = $configuration['reverseProxyPrefixSSL'] . $scriptName;
} elseif (!empty($configuration['reverseProxyPrefix'])) {
$scriptName = $configuration['reverseProxyPrefix'] . $scriptName;
}
}
return $scriptName;
}
/**
* Determine REQUEST_URI, taking proxy configuration and various web server
* specifics into account.
*
* @param array $serverParams Basically the $_SERVER, but from $request object
* @param array $configuration $TYPO3_CONF_VARS['SYS'] array
* @param bool $isHttps True if used protocol is HTTPS
* @param string $scriptName Script name
* @param bool $isBehindReverseProxy True if reverse proxy setup is detected
* @return string Sanitized REQUEST_URI
*/
protected static function determineRequestUri(
array $serverParams,
array $configuration,
bool $isHttps,
string $scriptName,
bool $isBehindReverseProxy
): string {
$proxyPrefixApplied = false;
if (!empty($configuration['requestURIvar'])) {
// This is for URL rewriter that store the original URI in a server
// variable (e.g. ISAPI Rewriter for IIS: HTTP_X_REWRITE_URL), a config then looks like:
// requestURIvar = '_SERVER|HTTP_X_REWRITE_URL' which will access $GLOBALS['_SERVER']['HTTP_X_REWRITE_URL']
[$firstLevel, $secondLevel] = GeneralUtility::trimExplode('|', $configuration['requestURIvar'], true);
$requestUri = $GLOBALS[$firstLevel][$secondLevel];
} elseif (empty($serverParams['REQUEST_URI'])) {
// This is for ISS/CGI which does not have the REQUEST_URI available.
$queryString = !empty($serverParams['QUERY_STRING']) ? '?' . $serverParams['QUERY_STRING'] : '';
// script name already had the proxy prefix handling, we must not add it a second time
$proxyPrefixApplied = true;
$requestUri = '/' . ltrim($scriptName, '/') . $queryString;
} else {
$requestUri = '/' . ltrim($serverParams['REQUEST_URI'], '/');
}
if (!$proxyPrefixApplied && $isBehindReverseProxy) {
// Add a prefix if TYPO3 is behind a proxy: ext-domain.com => int-server.com/prefix
if ($isHttps && !empty($configuration['reverseProxyPrefixSSL'])) {
$requestUri = $configuration['reverseProxyPrefixSSL'] . $requestUri;
} elseif (!empty($configuration['reverseProxyPrefix'])) {
$requestUri = $configuration['reverseProxyPrefix'] . $requestUri;
}
}
return $requestUri;
}
/**
* Determine clients REMOTE_ADDR, even if there is a reverse proxy in between.
*
* @param array $serverParams Basically the $_SERVER, but from $request object
* @param array $configuration $TYPO3_CONF_VARS[SYS] array
* @param bool $isBehindReverseProxy True if reverse proxy setup is detected
* @return string Resolved REMOTE_ADDR
*/
protected static function determineRemoteAddress(
array $serverParams,
array $configuration,
bool $isBehindReverseProxy
): string {
$remoteAddress = trim($serverParams['REMOTE_ADDR'] ?? '');
if ($isBehindReverseProxy) {
$ip = GeneralUtility::trimExplode(',', $serverParams['HTTP_X_FORWARDED_FOR'] ?? '', true);
// Choose which IP in list to use
$configuredReverseProxyHeaderMultiValue = trim($configuration['reverseProxyHeaderMultiValue'] ?? '');
if (!empty($ip) && $configuredReverseProxyHeaderMultiValue === 'last') {
$ip = (string)array_pop($ip);
} elseif (!empty($ip) && $configuredReverseProxyHeaderMultiValue === 'first') {
$ip = (string)array_shift($ip);
} else {
$ip = '';
}
if (GeneralUtility::validIP($ip)) {
$remoteAddress = $ip;
}
}
return $remoteAddress;
}
/**
* Check if a configured reverse proxy setup is detected.
*
* @param array $serverParams Basically the $_SERVER, but from $request object
* @param array $configuration $TYPO3_CONF_VARS[SYS] array
* @return bool True if TYPO3 is behind a reverse proxy
*/
protected static function determineIsBehindReverseProxy($serverParams, $configuration): bool
{
return GeneralUtility::cmpIP(
trim($serverParams['REMOTE_ADDR'] ?? ''),
trim($configuration['reverseProxyIP'] ?? '')
);
}
/**
* HTTP_HOST without port
*
* @param string $httpHost host[:[port]]
* @return string Resolved host
*/
protected static function determineRequestHostOnly(string $httpHost): string
{
$httpHostBracketPosition = strpos($httpHost, ']');
$httpHostParts = explode(':', $httpHost);
return $httpHostBracketPosition !== false ? substr(
$httpHost,
0,
$httpHostBracketPosition + 1
) : array_shift($httpHostParts);
}
/**
* Requested port if given
*
* @param string $httpHost host[:[port]]
* @param string $httpHostOnly host
* @return int Resolved port if given, else 0
*/
protected static function determineRequestPort(string $httpHost, string $httpHostOnly): int
{
return strlen($httpHost) > strlen($httpHostOnly) ? (int)substr($httpHost, strlen($httpHostOnly) + 1) : 0;
}
/**
* Calculate absolute path to web document root
*
* @param string $scriptNameOnFileSystem Entry script path of URI on file system, without domain and without query parameters, with leading /
* @param string $scriptFilename Absolute path to entry script on server filesystem
* @return string Path to document root with trailing slash
*/
protected static function determineDocumentRoot(string $scriptNameOnFileSystem, string $scriptFilename): string
{
// Get the web root (it is not the root of the TYPO3 installation)
// Some CGI-versions (LA13CGI) and mod-rewrite rules on MODULE versions will deliver a 'wrong'
// DOCUMENT_ROOT (according to our description). Further various aliases/mod_rewrite rules can
// disturb this as well. Therefore the DOCUMENT_ROOT is always calculated as the SCRIPT_FILENAME
// minus the end part shared with SCRIPT_NAME.
$webDocRoot = '';
$scriptNameArray = explode('/', strrev($scriptNameOnFileSystem));
$scriptFilenameArray = explode('/', strrev($scriptFilename));
$path = [];
foreach ($scriptNameArray as $segmentNumber => $segment) {
if ($scriptFilenameArray[$segmentNumber] === (string)$segment) {
$path[] = $segment;
} else {
break;
}
}
$commonEnd = strrev(implode('/', $path));
if ($commonEnd !== '') {
$webDocRoot = substr($scriptFilename, 0, -(strlen($commonEnd) + 1));
}
return $webDocRoot;
}
/**
* Determine frontend url
*
* @param string $requestDir Full Uri with path, but without script name and query parts
* @param string $pathThisScript Absolute path to entry script on server filesystem
* @param string $pathSite Absolute server path to document root
* @return string Calculated Frontend Url
*/
protected static function determineSiteUrl(string $requestDir, string $pathThisScript, string $pathSite): string
{
$pathThisScriptDir = substr(dirname($pathThisScript), strlen($pathSite)) . '/';
$siteUrl = substr($requestDir, 0, -strlen($pathThisScriptDir));
return rtrim($siteUrl, '/') . '/';
}
/**
* Determine site path
*
* @param string $requestHost scheme://host[:port]
* @param string $siteUrl Full Frontend Url
*/
protected static function determineSitePath(string $requestHost, string $siteUrl): string
{
return substr($siteUrl, strlen($requestHost));
}
/**
* Determine site script
*/
protected static function determineSiteScript(string $requestUrl, string $siteUrl): string
{
return substr($requestUrl, strlen($siteUrl));
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
/**
* A null response object
*
* @internal Note that this is not public API yet.
*/
class NullResponse extends Response
{
public function __construct()
{
parent::__construct(null);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
/**
* Exception that has to be propagated back to the middleware stack
* in order to stop current execution and respond with the given
* response. This exception is used as alternative to previous
* die() or exit() calls.
*
* Note this exception should be used in cases where a controller wants
* to create an 'early' response. An example are failed access checks:
* A controller wants to throw an early 'denied' response without further
* local processing.
*
* When this exception is thrown by a controller, it will be caught by the
* 'very inner' ResponsePropagation middleware. The response is then returned
* to other 'outer' middlewares, allowing them to further operate on the
* response (eg. adding a content-length header) and to do other jobs
* (eg. releasing created locks).
*
* If this exception is thrown within a middleware (as opposed to be thrown
* from within controllers), it will bypass other middlewares and will be
* caught just like the parent ImmediateResponseException. This should be
* used with care, there should be little reason to do so at all.
*
* In general, from within controllers, this response exception
* should be preferred over ImmediateResponseException.
*
* @internal
*/
class PropagateResponseException extends ImmediateResponseException {}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\UriInterface;
/**
* A default redirect response object
*
* Highly inspired by ZF zend-diactoros
*
* @internal Note that this is not public API yet.
*/
class RedirectResponse extends Response
{
/**
* Creates a redirect response object with a given URI and status code.
* Also sets the "Location" response header.
*
* @param string|UriInterface $uri URI for the Location header.
* @param int $status status code for the redirect; defaults to 302.
* @param array $headers Additional headers to be set.
*/
public function __construct(string|UriInterface $uri, int $status = 302, array $headers = [])
{
$headers['location'] = [(string)$uri];
parent::__construct('php://temp', $status, $headers);
}
}
+360
View File
@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* 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.
*/
protected ?string $requestTarget = null;
/**
* The HTTP method.
*/
protected string $method = 'GET';
/**
* Supported HTTP methods
*
* @var non-empty-string[]
*/
protected array $supportedMethods = [
'CONNECT',
'DELETE',
'GET',
'HEAD',
'OPTIONS',
'PATCH',
'POST',
'PUT',
'TRACE',
// WebDAV methods
'COPY',
'LOCK',
'MKCOL',
'MOVE',
'PROPFIND',
'PROPPATCH',
'REPORT',
'UNLOCK',
// Custom methods
'PURGE',
'BAN',
];
/**
* An instance of the Uri object
*
* @todo It is a PSR-7 spec violation for this to be null. This should be corrected.
*/
protected ?UriInterface $uri;
/**
* Constructor, the only place to set all parameters of this Request
*
* @param string|UriInterface|null $uri URI for the request, if any.
* @param string $method HTTP method for the request, if any.
* @param string|resource|StreamInterface|null $body Message body, if any.
* @param array $headers Headers for the message, if any.
* @throws \InvalidArgumentException for any invalid value.
*/
public function __construct(UriInterface|string|null $uri = null, string $method = 'GET', $body = 'php://input', array $headers = [])
{
// Upcast the body to a streamable object, or error
// if it's an invalid type.
if ($body instanceof StreamInterface) {
$this->body = $body;
} else {
$this->body = match (get_debug_type($body)) {
'string', 'resource (stream)' => new Stream($body),
'null' => null,
default => throw new \InvalidArgumentException('Body must be a string stream resource identifier, a stream resource, or a StreamInterface instance', 1436717271),
};
}
if (is_string($uri)) {
$uri = new Uri($uri);
}
$this->validateMethod($method);
$this->method = $method;
$this->uri = $uri;
[$this->lowercasedHeaderNames, $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(): array
{
$headers = parent::getHeaders();
if (!$this->hasHeader('host') && ($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(string $name): array
{
if (!$this->hasHeader($name) && strtolower($name) === 'host' && ($this->uri?->getHost())) {
return [$this->getHostFromUri()];
}
return parent::getHeader($name);
}
/**
* Retrieve the host from the URI instance
*/
protected function getHostFromUri(): string
{
$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 "/".
*/
public function getRequestTarget(): string
{
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 https://tools.ietf.org/html/rfc7230#section-2.7 (for the various
* request-target forms allowed in request messages)
*/
public function withRequestTarget(mixed $requestTarget): static
{
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
*/
public function getMethod(): string
{
return $this->method;
}
/**
* 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.
* @throws \InvalidArgumentException for invalid HTTP methods.
*/
public function withMethod(string $method): static
{
$clonedObject = clone $this;
$clonedObject->method = $method;
return $clonedObject;
}
/**
* Retrieves the URI instance.
*
* This method MUST return a UriInterface instance.
*
* @link https://tools.ietf.org/html/rfc3986#section-4.3
* @return UriInterface Returns a UriInterface instance
* representing the URI of the request.
*/
public function getUri(): UriInterface
{
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 https://tools.ietf.org/html/rfc3986#section-4.3
*
* @param UriInterface $uri New request URI to use.
* @param bool $preserveHost Preserve the original state of the Host header.
*/
public function withUri(UriInterface $uri, bool $preserveHost = false): static
{
$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->lowercasedHeaderNames['host'] = 'Host';
$clonedObject->headers['Host'] = [$host];
return $clonedObject;
}
/**
* Validate the HTTP method, helper function.
*
* @throws \InvalidArgumentException on invalid HTTP method.
*/
protected function validateMethod(?string $method): void
{
if (is_null($method)) {
return;
}
$method = strtoupper($method);
if (!in_array($method, $this->supportedMethods, true)) {
throw new \InvalidArgumentException('Unsupported HTTP method "' . $method . '".', 1436717275);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory;
/**
* Front-end for sending requests, using PSR-7.
*/
#[AsAlias(RequestFactoryInterface::class, public: true)]
readonly class RequestFactory implements RequestFactoryInterface
{
public function __construct(
private GuzzleClientFactory $guzzleFactory,
) {}
/**
* Create a new request.
*
* @param string $method The HTTP method associated with the request.
* @param UriInterface|string $uri The URI associated with the request.
*/
public function createRequest(string $method, $uri): RequestInterface
{
return new Request($uri, $method, null);
}
/**
* Create a Guzzle request object with our custom implementation
*
* @param string $uri the URI to request
* @param string $method the HTTP method (defaults to GET)
* @param array $options custom options for this request
* @param ?string $context request context
*/
public function request(string $uri, string $method = 'GET', array $options = [], ?string $context = null): ResponseInterface
{
return $this->guzzleFactory->getClient($context)->request($method, $uri, $options);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Backend\Http\Application as BackendApplication;
use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
use TYPO3\CMS\Frontend\Http\Application as FrontendApplication;
final readonly class RequestHandler implements RequestHandlerInterface
{
public function __construct(
private ContainerInterface $container,
private BackendEntryPointResolver $backendEntryPointResolver,
) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
if ($this->container->has(BackendApplication::class) && $this->backendEntryPointResolver->isBackendRoute($request)) {
return $this->container->get(BackendApplication::class)->handle($request);
}
if ($this->container->has(FrontendApplication::class)) {
return $this->container->get(FrontendApplication::class)->handle($request);
}
throw new \Exception('TYPO3 is not configured. Please install typo3/cms-backend and/or typo3/cms-frontend', 1704788092);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
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, use PSR-17 interfaces instead
*/
class Response extends Message implements ResponseInterface
{
protected int $statusCode;
protected string $reasonPhrase = '';
protected array $availableStatusCodes = [
// INFORMATIONAL CODES
100 => 'Continue',
101 => 'Switching Protocols',
102 => 'Processing',
103 => 'Early Hints',
// 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',
226 => 'IM Used',
// 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',
308 => 'Permanent 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 Timeout',
409 => 'Conflict',
410 => 'Gone',
411 => 'Length Required',
412 => 'Precondition Failed',
413 => 'Payload Too Large',
414 => 'URI Too Long',
415 => 'Unsupported Media Type',
416 => 'Range Not Satisfiable',
417 => 'Expectation Failed',
418 => 'I\'m a teapot',
421 => 'Misdirected Request',
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',
451 => 'Unavailable For Legal Reasons',
// SERVER ERROR
500 => 'Internal Server Error',
501 => 'Not Implemented',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
505 => 'HTTP Version Not Supported',
506 => 'Variant Also Negotiates',
507 => 'Insufficient Storage',
508 => 'Loop Detected',
509 => 'Bandwidth Limit Exceeded',
510 => 'Not Extended',
511 => 'Network Authentication Required',
];
/**
* @throws \InvalidArgumentException if any of the given arguments are given
*/
public function __construct(
StreamInterface|string|null $body = 'php://temp',
int $statusCode = 200,
array $headers = [],
string $reasonPhrase = ''
) {
if (is_string($body)) {
$body = new Stream($body, 'rw');
}
$this->body = $body;
if (!array_key_exists($statusCode, $this->availableStatusCodes)) {
throw new \InvalidArgumentException('The given status code is not a valid HTTP status code.', 1436717278);
}
$this->statusCode = $statusCode;
$this->reasonPhrase = $reasonPhrase === '' ? $this->availableStatusCodes[$this->statusCode] : $reasonPhrase;
[$this->lowercasedHeaderNames, $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.
*/
public function getStatusCode(): int
{
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 https://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.
* @throws \InvalidArgumentException For invalid status code arguments.
*/
public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface
{
if (!array_key_exists($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 https://tools.ietf.org/html/rfc7231#section-6
* @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
*/
public function getReasonPhrase(): string
{
return $this->reasonPhrase;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
/**
* @internal Note that this is not public API, use PSR-17 interfaces instead.
*/
#[AsAlias(ResponseFactoryInterface::class, public: true)]
readonly class ResponseFactory implements ResponseFactoryInterface
{
/**
* Create a new response.
*
* @param int $code HTTP status code; defaults to 200
* @param string $reasonPhrase Reason phrase to associate with status code
*/
public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
{
return new Response(null, $code, [], $reasonPhrase);
}
}
@@ -0,0 +1,23 @@
<?php
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http\Security;
use TYPO3\CMS\Core\Exception;
/**
* Exception thrown when route requires referrer, which does not match current base URL.
*/
class InvalidReferrerException extends Exception {}
@@ -0,0 +1,23 @@
<?php
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http\Security;
use TYPO3\CMS\Core\Exception;
/**
* Exception thrown when route requires referrer, which is missing.
*/
class MissingReferrerException extends Exception {}
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http\Security;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* @internal
*/
readonly class ReferrerEnforcer
{
private const int TYPE_REFERRER_EMPTY = 1;
private const int TYPE_REFERRER_SAME_SITE = 2;
private const int TYPE_REFERRER_SAME_ORIGIN = 4;
public function handle(ServerRequestInterface $request, array $options): ?ResponseInterface
{
$requestHost = rtrim($this->resolveRequestHost($request), '/') . '/';
$requestDir = $this->resolveRequestDir($request);
$referrerType = $this->resolveReferrerType($request, $requestHost, $requestDir);
// valid referrer, no more actions required
if ($referrerType & self::TYPE_REFERRER_SAME_ORIGIN) {
return null;
}
$flags = $options['flags'] ?? [];
$expiration = $options['expiration'] ?? 5;
$nonce = $request->getAttribute('nonce');
// referrer is missing and route requested to refresh
// (created HTML refresh to enforce having referrer)
if (($request->getQueryParams()['referrer-refresh'] ?? 0) <= time()
&& (
in_array('refresh-always', $flags, true)
|| ($referrerType & self::TYPE_REFERRER_EMPTY && in_array('refresh-empty', $flags, true))
|| ($referrerType & self::TYPE_REFERRER_SAME_SITE && in_array('refresh-same-site', $flags, true))
)
) {
$refreshUri = $request->getUri();
parse_str($refreshUri->getQuery(), $queryParams);
$queryParams['referrer-refresh'] = time() + $expiration;
$refreshUri = $refreshUri->withQuery(
http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986)
);
$scriptUri = $this->resolveAbsoluteWebPath(
'EXT:core/Resources/Public/JavaScript/referrer-refresh.js',
$request
);
$attributes = ['src' => $scriptUri];
if ($nonce instanceof ConsumableNonce) {
$attributes['nonce'] = $nonce->consumeStatic(Directive::ScriptSrcElem);
}
// simulating navigate event by clicking anchor link
// since meta-refresh won't change `document.referrer` in e.g. Firefox
return new HtmlResponse(sprintf(
'<html>'
. '<head><link rel="icon" href="data:image/svg+xml,"></head>'
. '<body><a href="%s" id="referrer-refresh">&nbsp;</a>'
. '<script %s></script></body>'
. '</html>',
htmlspecialchars((string)$refreshUri),
GeneralUtility::implodeAttributes($attributes, true)
));
}
$subject = $options['subject'] ?? '';
if ($referrerType & self::TYPE_REFERRER_EMPTY) {
// still empty referrer or invalid referrer, deny route invocation
throw new MissingReferrerException(
sprintf('Missing referrer%s', $subject !== '' ? ' for ' . $subject : ''),
1588095935
);
}
// referrer is given, but does not match current base URL
throw new InvalidReferrerException(
sprintf('Invalid referrer%s', $subject !== '' ? ' for ' . $subject : ''),
1588095936
);
}
protected function resolveAbsoluteWebPath(string $target, ServerRequestInterface $request): string
{
return (string)PathUtility::getSystemResourceUri($target, $request);
}
protected function resolveReferrerType(ServerRequestInterface $request, string $requestHost, string $requestDir): int
{
$referrer = $request->getServerParams()['HTTP_REFERER'] ?? '';
if ($referrer === '') {
return self::TYPE_REFERRER_EMPTY;
}
if (str_starts_with($referrer, $requestDir)) {
// same-origin implies same-site
return self::TYPE_REFERRER_SAME_ORIGIN | self::TYPE_REFERRER_SAME_SITE;
}
if (str_starts_with($referrer, $requestHost)) {
return self::TYPE_REFERRER_SAME_SITE;
}
return 0;
}
protected function resolveRequestHost(ServerRequestInterface $request): string
{
return $request->getAttribute('normalizedParams')->getRequestHost();
}
protected function resolveRequestDir(ServerRequestInterface $request): string
{
return $request->getAttribute('normalizedParams')->getRequestDir();
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use GuzzleHttp\Psr7\LazyOpenStream;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* This class implements a stream that can be used like a usual PSR-7 stream
* but is additionally able to provide a file-serving fastpath using readfile().
* The file this stream refers to is opened on demand.
*
* @internal
*/
readonly class SelfEmittableLazyOpenStream implements SelfEmittableStreamInterface
{
use StreamDecoratorTrait;
protected LazyOpenStream $stream;
/**
* Constructor setting up the PHP resource
*/
public function __construct(protected string $filename)
{
$this->stream = new LazyOpenStream($filename, 'r');
}
/**
* Output the contents of the file to the output buffer
*/
public function emit()
{
readfile($this->filename);
}
public function isWritable(): bool
{
return false;
}
/**
* @throws \RuntimeException on failure.
*/
public function write(string $string): int
{
throw new \RuntimeException('Cannot write to a ' . self::class, 1538331833);
}
/**
* Creates the underlying stream lazily when required.
*/
protected function createStream(): StreamInterface
{
return $this->stream->stream;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\StreamInterface;
/**
* A PSR-7 stream which allows to be emitted on its own.
*
* @internal
*/
interface SelfEmittableStreamInterface extends StreamInterface
{
/**
* Output the contents of the stream to the output buffer
*/
public function emit();
}
+353
View File
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\UriInterface;
/**
* 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
{
protected array $attributes = [];
protected array $cookieParams = [];
protected array $queryParams = [];
protected array $serverParams = [];
protected array $uploadedFiles = [];
/**
* @var array|object|null
*/
protected $parsedBody;
/**
* Constructor, the only place to set all parameters of this Message/Request
*
* @param string|UriInterface|null $uri URI for the request, if any.
* @param string|null $method HTTP method for the request, if any.
* @param string|resource|StreamInterface|null $body Message body, if any.
* @param array $headers Headers for the message, if any.
* @param array $serverParams Server parameters, typically from $_SERVER
* @param array|null $uploadedFiles Upload file information, a tree of UploadedFiles
* @throws \InvalidArgumentException for any invalid value.
*/
public function __construct(string|UriInterface|null $uri = null, $method = null, $body = 'php://input', array $headers = [], array $serverParams = [], ?array $uploadedFiles = null)
{
if ($uploadedFiles !== null) {
$this->validateUploadedFiles($uploadedFiles);
}
parent::__construct($uri, $method ?? 'GET', $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.
*/
public function getServerParams(): array
{
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
* super global.
*/
public function getCookieParams(): array
{
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.
*/
public function withCookieParams(array $cookies): static
{
$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.
*/
public function getQueryParams(): array
{
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.
*/
public function withQueryParams(array $query): static
{
$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(): array
{
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.
* @throws \InvalidArgumentException if an invalid structure is provided.
*/
public function withUploadedFiles(array $uploadedFiles): static
{
$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 array|object|null 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 array|object|null $data The deserialized body data. This will
* typically be in an array or object.
* @throws \InvalidArgumentException if an unsupported argument type is
* provided.
*/
public function withParsedBody($data): static
{
$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(): array
{
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(string $name, $default = null)
{
return $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.
*/
public function withAttribute(string $name, $value): static
{
$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.
*/
public function withoutAttribute(string $name): static
{
$clonedObject = clone $this;
if (!isset($clonedObject->attributes[$name])) {
return $clonedObject;
}
unset($clonedObject->attributes[$name]);
return $clonedObject;
}
/**
* Recursively validate the structure in an uploaded files array.
*
* @throws \InvalidArgumentException if any leaf is not an UploadedFileInterface instance.
*/
protected function validateUploadedFiles(array $uploadedFiles): void
{
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);
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use TYPO3\CMS\Core\Core\Environment;
/**
* Class to create ServerRequest objects
*
* Highly inspired by https://github.com/phly/http/
*
* @internal Note that this is not public API yet.
*/
#[AsAlias(ServerRequestFactoryInterface::class, public: true)]
readonly class ServerRequestFactory implements ServerRequestFactoryInterface
{
/**
* Create a new server request.
*
* Note that server-params are taken precisely as given - no parsing/processing
* of the given values is performed, and, in particular, no attempt is made to
* determine the HTTP method or URI, which must be provided explicitly.
*
* @param string $method The HTTP method associated with the request.
* @param UriInterface|string $uri The URI associated with the request.
* @param array $serverParams Array of SAPI parameters with which to seed the generated request instance.
*/
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
{
return new ServerRequest($uri, $method, null, [], $serverParams);
}
/**
* 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.
*/
public static function fromGlobals()
{
$serverParameters = $_SERVER;
$headers = static::prepareHeaders($serverParameters);
$method = $serverParameters['REQUEST_METHOD'] ?? 'GET';
try {
// Note an early middleware creates NormalizedParams again to attach it as request attribute.
// Doing this twice per request is considered ok for now since NormalizedParams is constructed
// quite quickly with just a few string operations in it. We also may not want ot attach NormalizedParams
// as request attribute here already to not 'pollute' ServerRequest early. Another option is to
// hand over NormalizedParams to this method and have callers handle the decision if the attribute
// is attached as request attribute on their own.
$uri = new Uri(NormalizedParams::createFromServerParams($serverParameters)->getRequestUrl());
} catch (\InvalidArgumentException $e) {
if (Environment::isCli()) {
throw new InvalidRequestUrlOnCliException(
'Usage of ' . __METHOD__ . ' on CLI is discouraged. In case you rely on the method, you have to fake a valid request URL using $_SERVER.',
1701105725,
$e,
);
}
throw $e;
}
$request = new ServerRequest(
$uri,
$method,
'php://input',
$headers,
$serverParameters,
static::normalizeUploadedFiles($_FILES)
);
if (!empty($_COOKIE)) {
$request = $request->withCookieParams($_COOKIE);
}
if (!empty($_GET)) {
$request = $request->withQueryParams($_GET);
}
$parsedBody = $_POST;
if (empty($parsedBody) && in_array($method, ['PUT', 'PATCH', 'DELETE'])) {
parse_str((string)file_get_contents('php://input'), $parsedBody);
}
if (!empty($parsedBody)) {
$request = $request->withParsedBody($parsedBody);
}
return $request;
}
/**
* Fetch headers from $_SERVER variables
* which are only the ones starting with HTTP_* and CONTENT_*
*
* @return array
*/
protected static function prepareHeaders(array $server)
{
$headers = [];
foreach ($server as $key => $value) {
if (!is_string($key)) {
continue;
}
if (str_starts_with($key, 'HTTP_COOKIE')) {
// Cookies are handled using the $_COOKIE superglobal
continue;
}
if ($value !== '') {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', ' ', substr($key, 5));
$name = str_replace(' ', '-', ucwords(strtolower($name)));
$name = strtolower($name);
$headers[$name] = $value;
} elseif (str_starts_with($key, 'CONTENT_')) {
$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 = [];
foreach ($files as $key => $value) {
if ($value instanceof UploadedFileInterface) {
$normalizedFileUploads[$key] = $value;
} elseif (is_array($value)) {
if (isset($value['tmp_name'])) {
$uploadedFiles = self::createUploadedFile($value);
if ($uploadedFiles) {
$normalizedFileUploads[$key] = $uploadedFiles;
}
} 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
* recursively resolve uploaded files.
*
* @param array $value $_FILES structure
* @return UploadedFileInterface[]|UploadedFileInterface|null
*/
protected static function createUploadedFile(array $value)
{
if (is_array($value['tmp_name'])) {
$files = [];
foreach (array_keys($value['tmp_name']) as $key) {
$data = [
'tmp_name' => $value['tmp_name'][$key],
'error' => $value['error'][$key],
'name' => $value['name'][$key],
'type' => $value['type'][$key],
];
if (isset($value['size'][$key])) {
$data['size'] = $value['size'][$key];
}
$result = self::createUploadedFile($data);
if ($result) {
$files[$key] = $result;
}
}
return $files;
}
if (!empty($value['tmp_name'])) {
return new UploadedFile($value['tmp_name'], $value['size'] ?? 0, $value['error'], $value['name'], $value['type']);
}
return null;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
/**
* Define whether a Set-Cookie header should be sent (it's a Directive)
*/
enum SetCookieBehavior
{
case None;
case Send;
case Remove;
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Cookie;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Session\UserSession;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Class that is used to apply a SetCookie to a response,
* based on the current (authenticated or stateful) session,
* in order to use the same session across multiple HTTP requests.
*/
class SetCookieService
{
use CookieHeaderTrait;
use CookieScopeTrait;
protected readonly LoggerInterface $logger;
public static function create(string $name, string $type): self
{
$lifetime = (int)($GLOBALS['TYPO3_CONF_VARS'][$type]['lifetime'] ?? 0);
return new self($name, $type, $lifetime);
}
private function __construct(
protected readonly string $name,
protected readonly string $loginType,
/**
* Lifetime for the session-cookie (on the client)
*
* If >0: permanent cookie with given lifetime
* If 0: session-cookie
* Session-cookie means the browser will remove it when the browser is closed.
*/
protected readonly int $lifetime
) {
$this->logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__);
}
/**
* Sets the session cookie for the current disposal.
*/
public function setSessionCookie(UserSession $userSession, NormalizedParams $normalizedParams): ?Cookie
{
$setCookie = null;
$isRefreshTimeBasedCookie = $this->isRefreshTimeBasedCookie($userSession);
if ($this->isSetSessionCookie($userSession) || $isRefreshTimeBasedCookie) {
// Get the domain to be used for the cookie (if any):
$cookieScope = $this->getCookieScope($normalizedParams);
// If the cookie lifetime is set, use it:
$cookieExpire = $isRefreshTimeBasedCookie ? $GLOBALS['EXEC_TIME'] + $this->lifetime : 0;
// Valid options are "strict", "lax" or "none", whereas "none" only works in HTTPS requests (default & fallback is "strict")
$cookieSameSite = $this->sanitizeSameSiteCookieValue(
strtolower($GLOBALS['TYPO3_CONF_VARS'][$this->loginType]['cookieSameSite'] ?? Cookie::SAMESITE_STRICT)
);
// Use the secure option when the current request is served by a secure connection:
// SameSite "none" needs the secure option (only allowed on HTTPS)
$isSecure = $cookieSameSite === Cookie::SAMESITE_NONE || $normalizedParams->isHttps();
$sessionId = $userSession->getIdentifier();
$cookieValue = $userSession->getJwt($cookieScope);
$setCookie = new Cookie(
$this->name,
$cookieValue,
$cookieExpire,
$cookieScope->path,
// Host-Only cookies need to be provided without an explicit domain,
// see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3
// and https://datatracker.ietf.org/doc/html/rfc6265#section-5.3
// | * If the value of the Domain attribute is "example.com", the user agent will include the cookie
// | in the Cookie header when making HTTP requests to example.com, www.example.com, and www.corp.example.com
// | * If the server omits the Domain attribute, the user agent will return the cookie only to the origin server.
$cookieScope->hostOnly ? null : $cookieScope->domain,
$isSecure,
true,
false,
$cookieSameSite
);
$message = $isRefreshTimeBasedCookie ? 'Updated Cookie: {session}, {domain}' : 'Set Cookie: {session}, {domain}';
$this->logger->debug($message, [
'session' => sha1($sessionId),
'domain' => $cookieScope->domain,
]);
}
return $setCookie;
}
/**
* Determine whether a session cookie needs to be set (lifetime=0)
*/
public function isSetSessionCookie(UserSession $userSession, bool $forceSetCookie = false): bool
{
if ($this->loginType === 'FE') {
return ($userSession->isNew() || $forceSetCookie)
&& ($this->lifetime === 0 || !$userSession->isPermanent());
}
return $userSession->isNew() && $this->lifetime === 0;
}
/**
* Determine whether a non-session cookie needs to be set (lifetime>0)
*
* @internal
*/
public function isRefreshTimeBasedCookie(UserSession $userSession): bool
{
if ($this->loginType === 'FE') {
return $this->lifetime > 0 && $userSession->isPermanent();
}
return $this->lifetime > 0;
}
/**
* Returns whether this request is going to set a cookie
* or a cookie was already found in the system
*
* @return bool Returns TRUE if a cookie is set
*/
public function isCookieSet(?ServerRequestInterface $request, ?UserSession $userSession): bool
{
$isRefreshTimeBasedCookie = $userSession && $this->isRefreshTimeBasedCookie($userSession);
if ($isRefreshTimeBasedCookie || $this->isSetSessionCookie($userSession)) {
return true;
}
if ($request && isset($request->getCookieParams()[$this->name])) {
return true;
}
return false;
}
/**
* Empty / unset the cookie
*/
public function removeCookie(NormalizedParams $normalizedParams): Cookie
{
$scope = $this->getCookieScope($normalizedParams);
return new Cookie(
$this->name,
'',
-1,
$scope->path,
$scope->domain
);
}
}
+356
View File
@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
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|null
*/
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, string $mode = 'r')
{
$this->stream = $stream;
if (is_resource($stream)) {
$this->resource = $stream;
} elseif (is_string($stream)) {
$this->resource = fopen($stream, $mode) ?: null;
} 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 https://php.net/manual/en/language.oop5.magic.php#object.tostring
* @return string
*/
public function __toString(): string
{
if (!$this->isReadable()) {
return '';
}
try {
$this->rewind();
return $this->getContents();
} catch (\RuntimeException $e) {
return '';
}
}
/**
* Closes the stream and any underlying resources.
*/
public function close(): void
{
if (!is_resource($this->resource)) {
return;
}
$resource = $this->detach();
if ($resource === null) {
return;
}
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(): ?int
{
if ($this->resource === null) {
return null;
}
$stats = fstat($this->resource);
if ($stats === false) {
return null;
}
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(): int
{
if (!is_resource($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.
*/
public function eof(): bool
{
if (!is_resource($this->resource)) {
return true;
}
return feof($this->resource);
}
/**
* Returns whether the stream is seekable.
*/
public function isSeekable(): bool
{
if (!is_resource($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(int $offset, int $whence = SEEK_SET): void
{
if (!is_resource($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(): void
{
$this->seek(0);
}
/**
* Returns whether the stream is writable.
*/
public function isWritable(): bool
{
if (!is_resource($this->resource)) {
return false;
}
$uri = $this->getMetadata('uri');
$mode = $this->getMetadata('mode');
return is_writable($uri) || strpbrk($mode, 'aw+') !== false;
}
/**
* 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 $string): int
{
if (!is_resource($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 the stream is readable.
*/
public function isReadable(): bool
{
if (!is_resource($this->resource)) {
return false;
}
$mode = $this->getMetadata('mode');
return str_contains($mode, 'r') || str_contains($mode, '+');
}
/**
* 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(int $length): string
{
if (!is_resource($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
*/
public function getContents(): string
{
if (!is_resource($this->resource) || !$this->isReadable()) {
return '';
}
// Will always return a string; "false" was only possible if offset > 0, which we do not use.
return stream_get_contents($this->resource, null, -1);
}
/**
* 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 https://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(?string $key = null)
{
if (!is_resource($this->resource)) {
return 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, string $mode = 'r')
{
$error = null;
if (!is_resource($resource) && is_string($resource)) {
set_error_handler(static function ($e) use (&$error): bool {
$error = $e;
return true;
}, 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;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
/**
* @internal Note that this is not public API, use PSR-17 interfaces instead.
*/
#[AsAlias(StreamFactoryInterface::class, public: true)]
readonly class StreamFactory implements StreamFactoryInterface
{
/**
* Create a new stream from a string.
*
* @param string $content String content with which to populate the stream.
*/
public function createStream(string $content = ''): StreamInterface
{
$stream = new Stream('php://temp', 'r+');
if ($content !== '') {
$stream->write($content);
}
return $stream;
}
/**
* Create a stream from an existing file.
*
* The `$filename` MAY be any string supported by `fopen()`.
*
* @param string $filename Filename or stream URI to use as basis of stream.
* @param string $mode Mode with which to open the underlying filename/stream.
* @throws \RuntimeException If the file cannot be opened.
* @throws \InvalidArgumentException If the mode is invalid.
*/
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
$resource = @fopen($filename, $mode);
if ($resource === false) {
if ($mode === '' || in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true) === false) {
throw new \InvalidArgumentException('The mode ' . $mode . ' is invalid.', 1566823434);
}
throw new \RuntimeException('The file ' . $filename . ' cannot be opened.', 1566823435);
}
return new Stream($resource);
}
/**
* Create a new stream from an existing resource.
*
* The stream MUST be readable and may be writable.
*
* @param resource $resource PHP resource to use as basis of stream.
* @throws \InvalidArgumentException
*/
public function createStreamFromResource($resource): StreamInterface
{
if (!is_resource($resource) || get_resource_type($resource) !== 'stream') {
throw new \InvalidArgumentException('Invalid stream provided; must be a stream resource', 1566853697);
}
return new Stream($resource);
}
}
+259
View File
@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use TYPO3\CMS\Core\Resource\Exception\UploadSizeException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Class which represents one uploaded file, usually coming
* from $_FILES, according to PSR-7 standard.
*
* Highly inspired by https://github.com/phly/http/
*/
class UploadedFile implements UploadedFileInterface
{
protected ?string $file = null;
protected ?StreamInterface $stream = null;
protected ?string $clientFilename;
protected ?string $clientMediaType;
protected int $error;
protected bool $moved = false;
protected int $size;
/**
* Constructor method
*
* @param string|resource|StreamInterface $input is either a stream or a filename
* @param int $size see $_FILES['size'] from PHP
* @param int $errorStatus see $_FILES['error']
* @param string|null $clientFilename the original filename handed over from the client
* @param string|null $clientMediaType the media type (optional)
*
* @throws \InvalidArgumentException
*/
public function __construct($input, int $size, int $errorStatus, ?string $clientFilename = null, ?string $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);
}
$this->size = $size;
if ($errorStatus < 0 || $errorStatus > 8) {
throw new \InvalidArgumentException('Invalid error status for an uploaded file. See UPLOAD_ERR_* constant in PHP.', 1436717303);
}
$this->error = $errorStatus;
if ($clientFilename !== null) {
$clientFilename = \Normalizer::normalize($clientFilename);
}
$this->clientFilename = is_string($clientFilename) ? $clientFilename : null;
$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(): StreamInterface
{
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((string)$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 https://php.net/is_uploaded_file
* @see https://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(string $targetPath): void
{
if (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 the target path is empty or invalid.', 1436717309);
}
// Max upload size (kb) for files.
$maxUploadFileSize = GeneralUtility::getMaxUploadFileSize() * 1024;
if ($this->size > 0 && $maxUploadFileSize > 0 && $this->size >= $maxUploadFileSize) {
unlink($this->file);
throw new UploadSizeException('The uploaded file exceeds the size-limit of ' . $maxUploadFileSize . ' bytes', 1647338094);
}
if (!empty($this->file) && is_uploaded_file($this->file)) {
if (GeneralUtility::upload_copy_move($this->file, $targetPath) === 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(): ?int
{
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 https://php.net/manual/en/features.file-upload.errors.php
* @return int One of PHP's UPLOAD_ERR_XXX constants.
*/
public function getError(): int
{
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(): ?string
{
return $this->clientFilename;
}
/**
* Retrieve the temporary file name (for example /tmp/tmp_foo_filexyz
* If the file has been moved (by moveTo) an exception is thrown.
*
* @internal Not part of the PSR interface - used for legacy code in the core
*/
public function getTemporaryFileName(): ?string
{
if ($this->moved) {
throw new \RuntimeException('Cannot return temporary file name, as it was already moved.', 1436717337);
}
return $this->file;
}
/**
* 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(): ?string
{
return $this->clientMediaType;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
/**
* @internal Note that this is not public API, use PSR-17 interfaces instead.
*/
#[AsAlias(UploadedFileFactoryInterface::class, public: true)]
readonly class UploadedFileFactory implements UploadedFileFactoryInterface
{
/**
* Create a new uploaded file.
*
* If a size is not provided it will be determined by checking the size of
* the file.
*
* @see https://php.net/manual/features.file-upload.post-method.php
* @see https://php.net/manual/features.file-upload.errors.php
*
* @param StreamInterface $stream Underlying stream representing the uploaded file content.
* @param int|null $size in bytes
* @param int $error PHP file upload error
* @param string|null $clientFilename Filename as provided by the client, if any.
* @param string|null $clientMediaType Media type as provided by the client, if any.
* @throws \InvalidArgumentException If the file resource is not readable.
*/
public function createUploadedFile(
StreamInterface $stream,
?int $size = null,
int $error = \UPLOAD_ERR_OK,
?string $clientFilename = null,
?string $clientMediaType = null
): UploadedFileInterface {
if ($size === null) {
$size = $stream->getSize();
if ($size === null) {
throw new \InvalidArgumentException('Stream size could not be determined.', 1566823423);
}
}
return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType);
}
}
+761
View File
@@ -0,0 +1,761 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
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.
*
* @var string
*/
public const SUBDELIMITER_CHARLIST = '!\$&\'\(\)\*\+,;=';
/**
* Unreserved characters used in paths, query strings, and fragments.
*
* @var string
*/
public const UNRESERVED_CHARLIST = 'a-zA-Z0-9_\-\.~';
/**
* The default scheme for the URI
*/
protected string $scheme = '';
/**
* @var int[] Associative array containing schemes and their default ports.
*/
protected array $supportedSchemes = [
'http' => 80,
'https' => 443,
'ws' => 80,
'wss' => 443,
];
/**
* The authority part of the URI
*/
protected string $authority = '';
/**
* The userInfo part of the URI
*/
protected string $userInfo = '';
/**
* The host part of the URI
*/
protected string $host = '';
/**
* The port of the URI (empty if it is the standard port for the scheme)
*/
protected ?int $port = null;
/**
* The path part of the URI (can be empty or /)
*/
protected string $path = '';
/**
* The query part of the URI without the ?
*/
protected string $query = '';
/**
* The fragment part of the URI without the # before
*/
protected string $fragment = '';
/**
* Instructs the parser to skip the scheme validation.
*/
protected bool $allowAnyScheme = false;
/**
* Instructs the parser to skip the validation for `$supportedSchemes`.
* Use this factory method carefully in web contexts, since URIs
* might contain PHP stream wrappers (`phar://`, `php://`), which
* have a different meaning and are not considered as URI.
*
* @param string $uri The full URI including query string and fragment
*/
public static function fromAnyScheme(string $uri = ''): self
{
$target = new self();
$target->allowAnyScheme = true;
if (!empty($uri)) {
$target->parseUri($uri);
}
return $target;
}
/**
* @param string $uri The full URI including query string and fragment
* @throws \InvalidArgumentException if the URI is malformed.
*/
public function __construct(string $uri = '')
{
if (!empty($uri)) {
$this->parseUri($uri);
}
}
/**
* Helper to parse the full URI string
* @throws \InvalidArgumentException if the URI is malformed.
*/
protected function parseUri(string $uri): void
{
$uriParts = parse_url($uri);
if ($uriParts === false) {
throw new \InvalidArgumentException('The parsedUri "' . $uri . '" 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 (filter_var($this->host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$this->host = '[' . $this->host . ']';
}
}
if (isset($uriParts['port'])) {
$port = (int)$uriParts['port'];
if (!$this->validatePort($port)) {
throw new \InvalidArgumentException(
'The URI "' . $uri . '" appears to be malformed, invalid port "' . $port . '" specified, must be a valid TCP/UDP port',
1728057215
);
}
$this->port = $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']);
}
if (!$this->validate()) {
throw new \InvalidArgumentException('The URI "' . $uri . '" appears to be malformed', 1728057216);
}
}
protected function validate(): bool
{
$url = clone $this;
if ($url->scheme === '') {
// filter_var will mark //example.com/ as invalid, let's pretend it's https in this case
$url->scheme = 'https';
}
if ($url->host === '') {
// filter_var will mark /mypath/ as invalid, let's pretend it's localhost in this case
$url->host = 'localhost';
} else {
// filter_var can not validate UTF8 encoded hosts
$host = idn_to_ascii($url->host);
if ($host !== false) {
$url->host = $host;
}
}
return filter_var($url->__toString(), FILTER_VALIDATE_URL) !== false;
}
/**
* 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(): string
{
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(): string
{
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(): string
{
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 https://tools.ietf.org/html/rfc3986#section-3.2.2
* @return string The URI host.
*/
public function getHost(): string
{
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 int|null The URI port.
*/
public function getPort(): ?int
{
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(): string
{
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(): string
{
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(): string
{
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 static A new instance with the specified scheme.
* @throws \InvalidArgumentException for invalid or unsupported schemes.
*/
public function withScheme(string $scheme): UriInterface
{
$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 username to use for authority.
* @param string|null $password The password associated with $user.
*
* @return static A new instance with the specified user information.
*/
public function withUserInfo(string $user, ?string $password = null): UriInterface
{
$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 static A new instance with the specified host.
* @throws \InvalidArgumentException for invalid hostnames.
*/
public function withHost(string $host): UriInterface
{
if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$host = '[' . $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 int|null $port The port to use with the new instance; a null value
* removes the port information.
* @return static A new instance with the specified port.
* @throws \InvalidArgumentException for invalid ports.
*/
public function withPort(?int $port): UriInterface
{
if ($port !== null && !$this->validatePort($port)) {
throw new \InvalidArgumentException('Invalid port "' . $port . '" specified, must be a valid TCP/UDP port.', 1436717326);
}
$clonedObject = clone $this;
$clonedObject->port = $port;
return $clonedObject;
}
protected function validatePort(int $port): bool
{
if ($port < 1 || $port > 65535) {
return false;
}
return true;
}
/**
* 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 static A new instance with the specified path.
* @throws \InvalidArgumentException for invalid paths.
*/
public function withPath(string $path): UriInterface
{
if (str_contains($path, '?')) {
throw new \InvalidArgumentException('Invalid path provided. Must not contain a query string.', 1436717330);
}
if (str_contains($path, '#')) {
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 static A new instance with the specified query string.
* @throws \InvalidArgumentException for invalid query strings.
*/
public function withQuery(string $query): UriInterface
{
if (str_contains($query, '#')) {
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 static A new instance with the specified fragment.
*/
public function withFragment(string $fragment): UriInterface
{
$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 https://tools.ietf.org/html/rfc3986#section-4.1
*/
public function __toString(): string
{
$uri = '';
if (!empty($this->scheme)) {
$uri .= $this->scheme . ':';
}
$authority = $this->getAuthority();
if (!empty($authority)) {
$uri .= '//' . $authority;
}
$uri .= $this->normalizePathForStringification($authority, $this->getPath());
if ($this->query) {
$uri .= '?' . $this->query;
}
if ($this->fragment) {
$uri .= '#' . $this->fragment;
}
return $uri;
}
private function normalizePathForStringification(string $authority, string $path): string
{
$isRootless = $path !== '' && !str_starts_with($path, '/');
if ($isRootless) {
if ($authority === '') {
// See: https://datatracker.ietf.org/doc/html/rfc3986#page-26:~:text=A%20path%20segment%20that%20contains%20a%20colon%20character%20(e.g.%2C%20%22this%3Athat%22)
$pathParts = explode('/', $path, 2);
if (str_contains($pathParts[0], ':')) {
$path = './' . $path;
}
} else {
$path = '/' . $path;
}
}
return $path;
}
/**
* Is a given port non-standard for the current scheme?
*/
protected function isNonStandardPort(string $scheme, string $host, ?int $port): bool
{
if (empty($scheme)) {
return empty($host) || !empty($port);
}
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(string $scheme): string
{
$scheme = strtolower($scheme);
$scheme = preg_replace('#:(//)?$#', '', $scheme);
if (empty($scheme)) {
return '';
}
if (!$this->allowAnyScheme && !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.
*/
protected function sanitizePath(string $path): string
{
return preg_replace_callback(
'/(?:[^' . self::UNRESERVED_CHARLIST . ':@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/',
static function ($matches) {
return rawurlencode($matches[0]);
},
$path
);
}
/**
* Filter a query string to ensure it is properly encoded.
* Ensures that the values in the query string are properly urlencoded.
*/
protected function sanitizeQuery(string $query): string
{
if (!empty($query) && str_starts_with($query, '?')) {
$query = substr($query, 1);
}
$parts = explode('&', $query);
foreach ($parts as $index => $part) {
[$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.
*
* @return array A value with exactly two elements, key and value
*/
protected function splitQueryValue(string $value): array
{
$data = explode('=', $value, 2);
if (count($data) === 1) {
$data[] = null;
}
return $data;
}
/**
* Filter a fragment value to ensure it is properly encoded.
*/
protected function sanitizeFragment(string $fragment): string
{
if (!empty($fragment) && str_starts_with($fragment, '#')) {
$fragment = substr($fragment, 1);
}
return $this->sanitizeQueryOrFragment($fragment);
}
/**
* Filter a query string key or value, or a fragment.
*/
protected function sanitizeQueryOrFragment(string $value): string
{
return preg_replace_callback(
'/(?:[^' . self::UNRESERVED_CHARLIST . self::SUBDELIMITER_CHARLIST . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/',
static function ($matches) {
return rawurlencode($matches[0]);
},
$value
);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Core\Http;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
/**
* @internal Note that this is not public API, use PSR-17 interfaces instead.
*/
#[AsAlias(UriFactoryInterface::class, public: true)]
readonly class UriFactory implements UriFactoryInterface
{
/**
* Create a new URI.
*
* @throws \InvalidArgumentException If the given URI cannot be parsed.
*/
public function createUri(string $uri = ''): UriInterface
{
return new Uri($uri);
}
}