diff --git a/AcceptHeaderItem.php b/AcceptHeaderItem.php index 35ecd4ea2..b8b2b8ad3 100644 --- a/AcceptHeaderItem.php +++ b/AcceptHeaderItem.php @@ -18,14 +18,14 @@ */ class AcceptHeaderItem { - private string $value; private float $quality = 1.0; private int $index = 0; private array $attributes = []; - public function __construct(string $value, array $attributes = []) - { - $this->value = $value; + public function __construct( + private string $value, + array $attributes = [], + ) { foreach ($attributes as $name => $value) { $this->setAttribute($name, $value); } diff --git a/BinaryFileResponse.php b/BinaryFileResponse.php index 0f5b3fca5..a7358183b 100644 --- a/BinaryFileResponse.php +++ b/BinaryFileResponse.php @@ -70,7 +70,7 @@ public function setFile(\SplFileInfo|string $file, ?string $contentDisposition = if ($file instanceof \SplFileInfo) { $file = new File($file->getPathname(), !$isTemporaryFile); } else { - $file = new File((string) $file); + $file = new File($file); } } @@ -110,8 +110,8 @@ public function getFile(): File */ public function setChunkSize(int $chunkSize): static { - if ($chunkSize < 1 || $chunkSize > \PHP_INT_MAX) { - throw new \LogicException('The chunk size of a BinaryFileResponse cannot be less than 1 or greater than PHP_INT_MAX.'); + if ($chunkSize < 1) { + throw new \InvalidArgumentException('The chunk size of a BinaryFileResponse cannot be less than 1.'); } $this->chunkSize = $chunkSize; @@ -237,7 +237,7 @@ public function prepare(Request $request): static $path = $location.substr($path, \strlen($pathPrefix)); // Only set X-Accel-Redirect header if a valid URI can be produced // as nginx does not serve arbitrary file paths. - $this->headers->set($type, $path); + $this->headers->set($type, rawurlencode($path)); $this->maxlen = 0; break; } @@ -267,13 +267,13 @@ public function prepare(Request $request): static $end = min($end, $fileSize - 1); if ($start < 0 || $start > $end) { $this->setStatusCode(416); - $this->headers->set('Content-Range', sprintf('bytes */%s', $fileSize)); + $this->headers->set('Content-Range', \sprintf('bytes */%s', $fileSize)); } elseif ($end - $start < $fileSize - 1) { $this->maxlen = $end < $fileSize ? $end - $start + 1 : -1; $this->offset = $start; $this->setStatusCode(206); - $this->headers->set('Content-Range', sprintf('bytes %s-%s/%s', $start, $end, $fileSize)); + $this->headers->set('Content-Range', \sprintf('bytes %s-%s/%s', $start, $end, $fileSize)); $this->headers->set('Content-Length', $end - $start + 1); } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 003470567..374c31889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ CHANGELOG ========= +7.3 +--- + + * Add support for iterable of string in `StreamedResponse` + * Add `EventStreamResponse` and `ServerEvent` classes to streamline server event streaming + * Add support for `valkey:` / `valkeys:` schemes for sessions + * `Request::getPreferredLanguage()` now favors a more preferred language above exactly matching a locale + * Allow `UriSigner` to use a `ClockInterface` + * Add `UriSigner::verify()` + +7.2 +--- + + * Add optional `$requests` parameter to `RequestStack::__construct()` + * Add optional `$v4Bytes` and `$v6Bytes` parameters to `IpUtils::anonymize()` + * Add `PRIVATE_SUBNETS` as a shortcut for private IP address ranges to `Request::setTrustedProxies()` + * Deprecate passing `referer_check`, `use_only_cookies`, `use_trans_sid`, `trans_sid_hosts`, `trans_sid_tags`, `sid_bits_per_character` and `sid_length` options to `NativeSessionStorage` + 7.1 --- @@ -32,7 +50,7 @@ CHANGELOG * Add `UriSigner` from the HttpKernel component * Add `partitioned` flag to `Cookie` (CHIPS Cookie) * Add argument `bool $flush = true` to `Response::send()` -* Make `MongoDbSessionHandler` instantiable with the mongodb extension directly + * Make `MongoDbSessionHandler` instantiable with the mongodb extension directly 6.3 --- diff --git a/Cookie.php b/Cookie.php index 46be14bae..199287057 100644 --- a/Cookie.php +++ b/Cookie.php @@ -22,17 +22,10 @@ class Cookie public const SAMESITE_LAX = 'lax'; public const SAMESITE_STRICT = 'strict'; - protected string $name; - protected ?string $value; - protected ?string $domain; protected int $expire; protected string $path; - protected ?bool $secure; - protected bool $httpOnly; - private bool $raw; private ?string $sameSite = null; - private bool $partitioned = false; private bool $secureDefault = false; private const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f"; @@ -94,27 +87,30 @@ public static function create(string $name, ?string $value = null, int|string|\D * * @throws \InvalidArgumentException */ - public function __construct(string $name, ?string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX, bool $partitioned = false) - { + public function __construct( + protected string $name, + protected ?string $value = null, + int|string|\DateTimeInterface $expire = 0, + ?string $path = '/', + protected ?string $domain = null, + protected ?bool $secure = null, + protected bool $httpOnly = true, + private bool $raw = false, + ?string $sameSite = self::SAMESITE_LAX, + private bool $partitioned = false, + ) { // from PHP source code if ($raw && false !== strpbrk($name, self::RESERVED_CHARS_LIST)) { - throw new \InvalidArgumentException(sprintf('The cookie name "%s" contains invalid characters.', $name)); + throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $name)); } if (!$name) { throw new \InvalidArgumentException('The cookie name cannot be empty.'); } - $this->name = $name; - $this->value = $value; - $this->domain = $domain; $this->expire = self::expiresTimestamp($expire); $this->path = $path ?: '/'; - $this->secure = $secure; - $this->httpOnly = $httpOnly; - $this->raw = $raw; $this->sameSite = $this->withSameSite($sameSite)->sameSite; - $this->partitioned = $partitioned; } /** @@ -208,7 +204,7 @@ public function withHttpOnly(bool $httpOnly = true): static public function withRaw(bool $raw = true): static { if ($raw && false !== strpbrk($this->name, self::RESERVED_CHARS_LIST)) { - throw new \InvalidArgumentException(sprintf('The cookie name "%s" contains invalid characters.', $this->name)); + throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $this->name)); } $cookie = clone $this; diff --git a/EventStreamResponse.php b/EventStreamResponse.php new file mode 100644 index 000000000..fe1a2872e --- /dev/null +++ b/EventStreamResponse.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation; + +/** + * Represents a streaming HTTP response for sending server events + * as part of the Server-Sent Events (SSE) streaming technique. + * + * To broadcast events to multiple users at once, for long-running + * connections and for high-traffic websites, prefer using the Mercure + * Symfony Component, which relies on Software designed for these use + * cases: https://symfony.com/doc/current/mercure.html + * + * @see ServerEvent + * + * @author Yonel Ceruto + * + * Example usage: + * + * return new EventStreamResponse(function () { + * yield new ServerEvent(time()); + * + * sleep(1); + * + * yield new ServerEvent(time()); + * }); + */ +class EventStreamResponse extends StreamedResponse +{ + /** + * @param int|null $retry The number of milliseconds the client should wait + * before reconnecting in case of network failure + */ + public function __construct(?callable $callback = null, int $status = 200, array $headers = [], private ?int $retry = null) + { + $headers += [ + 'Connection' => 'keep-alive', + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'private, no-cache, no-store, must-revalidate, max-age=0', + 'X-Accel-Buffering' => 'no', + 'Pragma' => 'no-cache', + 'Expire' => '0', + ]; + + parent::__construct($callback, $status, $headers); + } + + public function setCallback(callable $callback): static + { + if ($this->callback) { + return parent::setCallback($callback); + } + + $this->callback = function () use ($callback) { + if (is_iterable($events = $callback($this))) { + foreach ($events as $event) { + $this->sendEvent($event); + + if (connection_aborted()) { + break; + } + } + } + }; + + return $this; + } + + /** + * Sends a server event to the client. + * + * @return $this + */ + public function sendEvent(ServerEvent $event): static + { + if ($this->retry > 0 && !$event->getRetry()) { + $event->setRetry($this->retry); + } + + foreach ($event as $part) { + echo $part; + + if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { + static::closeOutputBuffers(0, true); + flush(); + } + } + + return $this; + } + + public function getRetry(): ?int + { + return $this->retry; + } + + public function setRetry(int $retry): void + { + $this->retry = $retry; + } +} diff --git a/Exception/ExpiredSignedUriException.php b/Exception/ExpiredSignedUriException.php new file mode 100644 index 000000000..613e08ef4 --- /dev/null +++ b/Exception/ExpiredSignedUriException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +final class ExpiredSignedUriException extends SignedUriException +{ + /** + * @internal + */ + public function __construct() + { + parent::__construct('The URI has expired.'); + } +} diff --git a/Exception/SignedUriException.php b/Exception/SignedUriException.php new file mode 100644 index 000000000..17b729d31 --- /dev/null +++ b/Exception/SignedUriException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +abstract class SignedUriException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/Exception/UnsignedUriException.php b/Exception/UnsignedUriException.php new file mode 100644 index 000000000..5eabb806b --- /dev/null +++ b/Exception/UnsignedUriException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +final class UnsignedUriException extends SignedUriException +{ + /** + * @internal + */ + public function __construct() + { + parent::__construct('The URI is not signed.'); + } +} diff --git a/Exception/UnverifiedSignedUriException.php b/Exception/UnverifiedSignedUriException.php new file mode 100644 index 000000000..cc7e98bf2 --- /dev/null +++ b/Exception/UnverifiedSignedUriException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +/** + * @author Kevin Bond + */ +final class UnverifiedSignedUriException extends SignedUriException +{ + /** + * @internal + */ + public function __construct() + { + parent::__construct('The URI signature is invalid.'); + } +} diff --git a/File/Exception/AccessDeniedException.php b/File/Exception/AccessDeniedException.php index 136d2a9f5..79ab0fce3 100644 --- a/File/Exception/AccessDeniedException.php +++ b/File/Exception/AccessDeniedException.php @@ -20,6 +20,6 @@ class AccessDeniedException extends FileException { public function __construct(string $path) { - parent::__construct(sprintf('The file %s could not be accessed', $path)); + parent::__construct(\sprintf('The file %s could not be accessed', $path)); } } diff --git a/File/Exception/FileNotFoundException.php b/File/Exception/FileNotFoundException.php index 31bdf68fe..3a5eb039b 100644 --- a/File/Exception/FileNotFoundException.php +++ b/File/Exception/FileNotFoundException.php @@ -20,6 +20,6 @@ class FileNotFoundException extends FileException { public function __construct(string $path) { - parent::__construct(sprintf('The file "%s" does not exist', $path)); + parent::__construct(\sprintf('The file "%s" does not exist', $path)); } } diff --git a/File/Exception/UnexpectedTypeException.php b/File/Exception/UnexpectedTypeException.php index 905bd5962..09b1c7e18 100644 --- a/File/Exception/UnexpectedTypeException.php +++ b/File/Exception/UnexpectedTypeException.php @@ -15,6 +15,6 @@ class UnexpectedTypeException extends FileException { public function __construct(mixed $value, string $expectedType) { - parent::__construct(sprintf('Expected argument of type %s, %s given', $expectedType, get_debug_type($value))); + parent::__construct(\sprintf('Expected argument of type %s, %s given', $expectedType, get_debug_type($value))); } } diff --git a/File/File.php b/File/File.php index 34ca5a537..2194c178a 100644 --- a/File/File.php +++ b/File/File.php @@ -93,7 +93,7 @@ public function move(string $directory, ?string $name = null): self restore_error_handler(); } if (!$renamed) { - throw new FileException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error))); + throw new FileException(\sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error))); } @chmod($target, 0666 & ~umask()); @@ -106,7 +106,7 @@ public function getContent(): string $content = file_get_contents($this->getPathname()); if (false === $content) { - throw new FileException(sprintf('Could not get the content of the file "%s".', $this->getPathname())); + throw new FileException(\sprintf('Could not get the content of the file "%s".', $this->getPathname())); } return $content; @@ -116,10 +116,10 @@ protected function getTargetFile(string $directory, ?string $name = null): self { if (!is_dir($directory)) { if (false === @mkdir($directory, 0777, true) && !is_dir($directory)) { - throw new FileException(sprintf('Unable to create the "%s" directory.', $directory)); + throw new FileException(\sprintf('Unable to create the "%s" directory.', $directory)); } } elseif (!is_writable($directory)) { - throw new FileException(sprintf('Unable to write in the "%s" directory.', $directory)); + throw new FileException(\sprintf('Unable to write in the "%s" directory.', $directory)); } $target = rtrim($directory, '/\\').\DIRECTORY_SEPARATOR.(null === $name ? $this->getBasename() : $this->getName($name)); @@ -134,8 +134,7 @@ protected function getName(string $name): string { $originalName = str_replace('\\', '/', $name); $pos = strrpos($originalName, '/'); - $originalName = false === $pos ? $originalName : substr($originalName, $pos + 1); - return $originalName; + return false === $pos ? $originalName : substr($originalName, $pos + 1); } } diff --git a/File/UploadedFile.php b/File/UploadedFile.php index 74e929f9b..a27a56f96 100644 --- a/File/UploadedFile.php +++ b/File/UploadedFile.php @@ -31,7 +31,6 @@ */ class UploadedFile extends File { - private bool $test; private string $originalName; private string $mimeType; private int $error; @@ -61,13 +60,17 @@ class UploadedFile extends File * @throws FileException If file_uploads is disabled * @throws FileNotFoundException If the file does not exist */ - public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $error = null, bool $test = false) - { + public function __construct( + string $path, + string $originalName, + ?string $mimeType = null, + ?int $error = null, + private bool $test = false, + ) { $this->originalName = $this->getName($originalName); $this->originalPath = strtr($originalName, '\\', '/'); $this->mimeType = $mimeType ?: 'application/octet-stream'; $this->error = $error ?: \UPLOAD_ERR_OK; - $this->test = $test; parent::__construct($path, \UPLOAD_ERR_OK === $this->error); } @@ -191,7 +194,7 @@ public function move(string $directory, ?string $name = null): File restore_error_handler(); } if (!$moved) { - throw new FileException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error))); + throw new FileException(\sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error))); } @chmod($target, 0666 & ~umask()); @@ -281,6 +284,6 @@ public function getErrorMessage(): string $maxFilesize = \UPLOAD_ERR_INI_SIZE === $errorCode ? self::getMaxFilesize() / 1024 : 0; $message = $errors[$errorCode] ?? 'The file "%s" was not uploaded due to an unknown error.'; - return sprintf($message, $this->getClientOriginalName(), $maxFilesize); + return \sprintf($message, $this->getClientOriginalName(), $maxFilesize); } } diff --git a/HeaderBag.php b/HeaderBag.php index 4bab3764b..c2ede560b 100644 --- a/HeaderBag.php +++ b/HeaderBag.php @@ -51,7 +51,7 @@ public function __toString(): string foreach ($headers as $name => $values) { $name = ucwords($name, '-'); foreach ($values as $value) { - $content .= sprintf("%-{$max}s %s\r\n", $name.':', $value); + $content .= \sprintf("%-{$max}s %s\r\n", $name.':', $value); } } @@ -118,7 +118,7 @@ public function get(string $key, ?string $default = null): ?string return null; } - return (string) $headers[0]; + return $headers[0]; } /** @@ -194,7 +194,7 @@ public function getDate(string $key, ?\DateTimeInterface $default = null): ?\Dat } if (false === $date = \DateTimeImmutable::createFromFormat(\DATE_RFC2822, $value)) { - throw new \RuntimeException(sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value)); + throw new \RuntimeException(\sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value)); } return $date; diff --git a/HeaderUtils.php b/HeaderUtils.php index 110896e17..a7079be9a 100644 --- a/HeaderUtils.php +++ b/HeaderUtils.php @@ -34,7 +34,7 @@ private function __construct() * Example: * * HeaderUtils::split('da, en-gb;q=0.8', ',;') - * // => ['da'], ['en-gb', 'q=0.8']] + * # returns [['da'], ['en-gb', 'q=0.8']] * * @param string $separators List of characters to split on, ordered by * precedence, e.g. ',', ';=', or ',;=' @@ -165,7 +165,7 @@ public static function unquote(string $s): string public static function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string { if (!\in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE])) { - throw new \InvalidArgumentException(sprintf('The disposition must be either "%s" or "%s".', self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE)); + throw new \InvalidArgumentException(\sprintf('The disposition must be either "%s" or "%s".', self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE)); } if ('' === $filenameFallback) { diff --git a/InputBag.php b/InputBag.php index dc5e12756..7411d755c 100644 --- a/InputBag.php +++ b/InputBag.php @@ -25,17 +25,19 @@ final class InputBag extends ParameterBag * Returns a scalar input value by name. * * @param string|int|float|bool|null $default The default value if the input key does not exist + * + * @throws BadRequestException if the input contains a non-scalar value */ public function get(string $key, mixed $default = null): string|int|float|bool|null { if (null !== $default && !\is_scalar($default) && !$default instanceof \Stringable) { - throw new \InvalidArgumentException(sprintf('Expected a scalar value as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($default))); + throw new \InvalidArgumentException(\sprintf('Expected a scalar value as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($default))); } $value = parent::get($key, $this); if (null !== $value && $this !== $value && !\is_scalar($value) && !$value instanceof \Stringable) { - throw new BadRequestException(sprintf('Input value "%s" contains a non-scalar value.', $key)); + throw new BadRequestException(\sprintf('Input value "%s" contains a non-scalar value.', $key)); } return $this === $value ? $default : $value; @@ -68,7 +70,7 @@ public function add(array $inputs = []): void public function set(string $key, mixed $value): void { if (null !== $value && !\is_scalar($value) && !\is_array($value) && !$value instanceof \Stringable) { - throw new \InvalidArgumentException(sprintf('Expected a scalar, or an array as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($value))); + throw new \InvalidArgumentException(\sprintf('Expected a scalar, or an array as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($value))); } $this->parameters[$key] = $value; @@ -85,6 +87,8 @@ public function set(string $key, mixed $value): void * @return ?T * * @psalm-return ($default is null ? T|null : T) + * + * @throws BadRequestException if the input cannot be converted to an enum */ public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum { @@ -97,6 +101,8 @@ public function getEnum(string $key, string $class, ?\BackedEnum $default = null /** * Returns the parameter value converted to string. + * + * @throws BadRequestException if the input contains a non-scalar value */ public function getString(string $key, string $default = ''): string { @@ -104,6 +110,10 @@ public function getString(string $key, string $default = ''): string return (string) $this->get($key, $default); } + /** + * @throws BadRequestException if the input value is an array and \FILTER_REQUIRE_ARRAY or \FILTER_FORCE_ARRAY is not set + * @throws BadRequestException if the input value is invalid and \FILTER_NULL_ON_FAILURE is not set + */ public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed { $value = $this->has($key) ? $this->all()[$key] : $default; @@ -114,11 +124,11 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER } if (\is_array($value) && !(($options['flags'] ?? 0) & (\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY))) { - throw new BadRequestException(sprintf('Input value "%s" contains an array, but "FILTER_REQUIRE_ARRAY" or "FILTER_FORCE_ARRAY" flags were not set.', $key)); + throw new BadRequestException(\sprintf('Input value "%s" contains an array, but "FILTER_REQUIRE_ARRAY" or "FILTER_FORCE_ARRAY" flags were not set.', $key)); } if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { - throw new \InvalidArgumentException(sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); + throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); } $options['flags'] ??= 0; @@ -131,6 +141,6 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER return $value; } - throw new BadRequestException(sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); + throw new BadRequestException(\sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); } } diff --git a/IpUtils.php b/IpUtils.php index 18b1c5faf..11a43238b 100644 --- a/IpUtils.php +++ b/IpUtils.php @@ -102,7 +102,7 @@ public static function checkIp4(string $requestIp, string $ip): bool return self::setCacheResult($cacheKey, false); } - return self::setCacheResult($cacheKey, 0 === substr_compare(sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address)), 0, $netmask)); + return self::setCacheResult($cacheKey, 0 === substr_compare(\sprintf('%032b', ip2long($requestIp)), \sprintf('%032b', ip2long($address)), 0, $netmask)); } /** @@ -178,10 +178,24 @@ public static function checkIp6(string $requestIp, string $ip): bool /** * Anonymizes an IP/IPv6. * - * Removes the last byte for v4 and the last 8 bytes for v6 IPs + * Removes the last bytes of IPv4 and IPv6 addresses (1 byte for IPv4 and 8 bytes for IPv6 by default). + * + * @param int<0, 4> $v4Bytes + * @param int<0, 16> $v6Bytes */ - public static function anonymize(string $ip): string + public static function anonymize(string $ip/* , int $v4Bytes = 1, int $v6Bytes = 8 */): string { + $v4Bytes = 1 < \func_num_args() ? func_get_arg(1) : 1; + $v6Bytes = 2 < \func_num_args() ? func_get_arg(2) : 8; + + if ($v4Bytes < 0 || $v6Bytes < 0) { + throw new \InvalidArgumentException('Cannot anonymize less than 0 bytes.'); + } + + if ($v4Bytes > 4 || $v6Bytes > 16) { + throw new \InvalidArgumentException('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + } + /** * If the IP contains a % symbol, then it is a local-link address with scoping according to RFC 4007 * In that case, we only care about the part before the % symbol, as the following functions, can only work with @@ -198,15 +212,23 @@ public static function anonymize(string $ip): string $ip = substr($ip, 1, -1); } + $mappedIpV4MaskGenerator = function (string $mask, int $bytesToAnonymize) { + $mask .= str_repeat('ff', 4 - $bytesToAnonymize); + $mask .= str_repeat('00', $bytesToAnonymize); + + return '::'.implode(':', str_split($mask, 4)); + }; + $packedAddress = inet_pton($ip); if (4 === \strlen($packedAddress)) { - $mask = '255.255.255.0'; + $mask = rtrim(str_repeat('255.', 4 - $v4Bytes).str_repeat('0.', $v4Bytes), '.'); } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff:ffff'))) { - $mask = '::ffff:ffff:ff00'; + $mask = $mappedIpV4MaskGenerator('ffff', $v4Bytes); } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff'))) { - $mask = '::ffff:ff00'; + $mask = $mappedIpV4MaskGenerator('', $v4Bytes); } else { - $mask = 'ffff:ffff:ffff:ffff:0000:0000:0000:0000'; + $mask = str_repeat('ff', 16 - $v6Bytes).str_repeat('00', $v6Bytes); + $mask = implode(':', str_split($mask, 4)); } $ip = inet_ntop($packedAddress & inet_pton($mask)); diff --git a/JsonResponse.php b/JsonResponse.php index 4571d22c7..187173b68 100644 --- a/JsonResponse.php +++ b/JsonResponse.php @@ -40,8 +40,8 @@ public function __construct(mixed $data = null, int $status = 200, array $header { parent::__construct('', $status, $headers); - if ($json && !\is_string($data) && !is_numeric($data) && !\is_callable([$data, '__toString'])) { - throw new \TypeError(sprintf('"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', __METHOD__, get_debug_type($data))); + if ($json && !\is_string($data) && !is_numeric($data) && !$data instanceof \Stringable) { + throw new \TypeError(\sprintf('"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', __METHOD__, get_debug_type($data))); } $data ??= new \ArrayObject(); @@ -173,7 +173,7 @@ protected function update(): static // Not using application/javascript for compatibility reasons with older browsers. $this->headers->set('Content-Type', 'text/javascript'); - return $this->setContent(sprintf('/**/%s(%s);', $this->callback, $this->data)); + return $this->setContent(\sprintf('/**/%s(%s);', $this->callback, $this->data)); } // Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback) diff --git a/ParameterBag.php b/ParameterBag.php index d60d3bda2..f37d7b3e2 100644 --- a/ParameterBag.php +++ b/ParameterBag.php @@ -23,17 +23,17 @@ */ class ParameterBag implements \IteratorAggregate, \Countable { - protected array $parameters; - - public function __construct(array $parameters = []) - { - $this->parameters = $parameters; + public function __construct( + protected array $parameters = [], + ) { } /** * Returns the parameters. * * @param string|null $key The name of the parameter to return or null to get them all + * + * @throws BadRequestException if the value is not an array */ public function all(?string $key = null): array { @@ -42,7 +42,7 @@ public function all(?string $key = null): array } if (!\is_array($value = $this->parameters[$key] ?? [])) { - throw new BadRequestException(sprintf('Unexpected value for parameter "%s": expecting "array", got "%s".', $key, get_debug_type($value))); + throw new BadRequestException(\sprintf('Unexpected value for parameter "%s": expecting "array", got "%s".', $key, get_debug_type($value))); } return $value; @@ -100,6 +100,8 @@ public function remove(string $key): void /** * Returns the alphabetic characters of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string */ public function getAlpha(string $key, string $default = ''): string { @@ -108,6 +110,8 @@ public function getAlpha(string $key, string $default = ''): string /** * Returns the alphabetic characters and digits of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string */ public function getAlnum(string $key, string $default = ''): string { @@ -116,6 +120,8 @@ public function getAlnum(string $key, string $default = ''): string /** * Returns the digits of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string */ public function getDigits(string $key, string $default = ''): string { @@ -124,12 +130,14 @@ public function getDigits(string $key, string $default = ''): string /** * Returns the parameter as string. + * + * @throws UnexpectedValueException if the value cannot be converted to string */ public function getString(string $key, string $default = ''): string { $value = $this->get($key, $default); if (!\is_scalar($value) && !$value instanceof \Stringable) { - throw new UnexpectedValueException(sprintf('Parameter value "%s" cannot be converted to "string".', $key)); + throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be converted to "string".', $key)); } return (string) $value; @@ -137,6 +145,8 @@ public function getString(string $key, string $default = ''): string /** * Returns the parameter value converted to integer. + * + * @throws UnexpectedValueException if the value cannot be converted to integer */ public function getInt(string $key, int $default = 0): int { @@ -145,6 +155,8 @@ public function getInt(string $key, int $default = 0): int /** * Returns the parameter value converted to boolean. + * + * @throws UnexpectedValueException if the value cannot be converted to a boolean */ public function getBoolean(string $key, bool $default = false): bool { @@ -162,6 +174,8 @@ public function getBoolean(string $key, bool $default = false): bool * @return ?T * * @psalm-return ($default is null ? T|null : T) + * + * @throws UnexpectedValueException if the parameter value cannot be converted to an enum */ public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum { @@ -174,7 +188,7 @@ public function getEnum(string $key, string $class, ?\BackedEnum $default = null try { return $class::from($value); } catch (\ValueError|\TypeError $e) { - throw new UnexpectedValueException(sprintf('Parameter "%s" cannot be converted to enum: %s.', $key, $e->getMessage()), $e->getCode(), $e); + throw new UnexpectedValueException(\sprintf('Parameter "%s" cannot be converted to enum: %s.', $key, $e->getMessage()), $e->getCode(), $e); } } @@ -185,6 +199,9 @@ public function getEnum(string $key, string $class, ?\BackedEnum $default = null * @param int|array{flags?: int, options?: array} $options Flags from FILTER_* constants * * @see https://php.net/filter-var + * + * @throws UnexpectedValueException if the parameter value is a non-stringable object + * @throws UnexpectedValueException if the parameter value is invalid and \FILTER_NULL_ON_FAILURE is not set */ public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed { @@ -201,11 +218,11 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER } if (\is_object($value) && !$value instanceof \Stringable) { - throw new UnexpectedValueException(sprintf('Parameter value "%s" cannot be filtered.', $key)); + throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be filtered.', $key)); } if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { - throw new \InvalidArgumentException(sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); + throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); } $options['flags'] ??= 0; @@ -218,7 +235,7 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER return $value; } - throw new \UnexpectedValueException(sprintf('Parameter value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); + throw new \UnexpectedValueException(\sprintf('Parameter value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); } /** diff --git a/RedirectResponse.php b/RedirectResponse.php index 3c2e4b620..b1b1cf3c3 100644 --- a/RedirectResponse.php +++ b/RedirectResponse.php @@ -39,7 +39,7 @@ public function __construct(string $url, int $status = 302, array $headers = []) $this->setTargetUrl($url); if (!$this->isRedirect()) { - throw new \InvalidArgumentException(sprintf('The HTTP status code is not a redirect ("%s" given).', $status)); + throw new \InvalidArgumentException(\sprintf('The HTTP status code is not a redirect ("%s" given).', $status)); } if (301 == $status && !\array_key_exists('cache-control', array_change_key_case($headers, \CASE_LOWER))) { @@ -71,7 +71,7 @@ public function setTargetUrl(string $url): static $this->targetUrl = $url; $this->setContent( - sprintf(' + \sprintf(' diff --git a/Request.php b/Request.php index 0b5cca82b..dba930a24 100644 --- a/Request.php +++ b/Request.php @@ -194,8 +194,7 @@ class Request self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX', ]; - /** @var bool */ - private $isIisRewrite = false; + private bool $isIisRewrite = false; /** * @param array $query The GET parameters @@ -485,7 +484,7 @@ public function __toString(): string } return - sprintf('%s %s %s', $this->getMethod(), $this->getRequestUri(), $this->server->get('SERVER_PROTOCOL'))."\r\n". + \sprintf('%s %s %s', $this->getMethod(), $this->getRequestUri(), $this->server->get('SERVER_PROTOCOL'))."\r\n". $this->headers. $cookieHeader."\r\n". $content; @@ -534,20 +533,26 @@ public function overrideGlobals(): void * * You should only list the reverse proxies that you manage directly. * - * @param array $proxies A list of trusted proxies, the string 'REMOTE_ADDR' will be replaced with $_SERVER['REMOTE_ADDR'] - * @param int $trustedHeaderSet A bit field of Request::HEADER_*, to set which headers to trust from your proxies + * @param array $proxies A list of trusted proxies, the string 'REMOTE_ADDR' will be replaced with $_SERVER['REMOTE_ADDR'] and 'PRIVATE_SUBNETS' by IpUtils::PRIVATE_SUBNETS + * @param int-mask-of $trustedHeaderSet A bit field to set which headers to trust from your proxies */ public static function setTrustedProxies(array $proxies, int $trustedHeaderSet): void { - self::$trustedProxies = array_reduce($proxies, function ($proxies, $proxy) { - if ('REMOTE_ADDR' !== $proxy) { - $proxies[] = $proxy; - } elseif (isset($_SERVER['REMOTE_ADDR'])) { - $proxies[] = $_SERVER['REMOTE_ADDR']; + if (false !== $i = array_search('REMOTE_ADDR', $proxies, true)) { + if (isset($_SERVER['REMOTE_ADDR'])) { + $proxies[$i] = $_SERVER['REMOTE_ADDR']; + } else { + unset($proxies[$i]); + $proxies = array_values($proxies); } + } + + if (false !== ($i = array_search('PRIVATE_SUBNETS', $proxies, true)) || false !== ($i = array_search('private_ranges', $proxies, true))) { + unset($proxies[$i]); + $proxies = array_merge($proxies, IpUtils::PRIVATE_SUBNETS); + } - return $proxies; - }, []); + self::$trustedProxies = $proxies; self::$trustedHeaderSet = $trustedHeaderSet; } @@ -580,7 +585,7 @@ public static function getTrustedHeaderSet(): int */ public static function setTrustedHosts(array $hostPatterns): void { - self::$trustedHostPatterns = array_map(fn ($hostPattern) => sprintf('{%s}i', $hostPattern), $hostPatterns); + self::$trustedHostPatterns = array_map(fn ($hostPattern) => \sprintf('{%s}i', $hostPattern), $hostPatterns); // we need to reset trusted hosts on trusted host patterns change self::$trustedHosts = []; } @@ -763,9 +768,7 @@ public function getClientIps(): array */ public function getClientIp(): ?string { - $ipAddresses = $this->getClientIps(); - - return $ipAddresses[0]; + return $this->getClientIps()[0]; } /** @@ -1097,7 +1100,7 @@ public function getHost(): string } $this->isHostValid = false; - throw new SuspiciousOperationException(sprintf('Invalid Host "%s".', $host)); + throw new SuspiciousOperationException(\sprintf('Invalid Host "%s".', $host)); } if (\count(self::$trustedHostPatterns) > 0) { @@ -1120,7 +1123,7 @@ public function getHost(): string } $this->isHostValid = false; - throw new SuspiciousOperationException(sprintf('Untrusted Host "%s".', $host)); + throw new SuspiciousOperationException(\sprintf('Untrusted Host "%s".', $host)); } return $host; @@ -1381,7 +1384,7 @@ public function isMethodCacheable(): bool public function getProtocolVersion(): ?string { if ($this->isFromTrustedProxy()) { - preg_match('~^(HTTP/)?([1-9]\.[0-9]) ~', $this->headers->get('Via') ?? '', $matches); + preg_match('~^(HTTP/)?([1-9]\.[0-9])\b~', $this->headers->get('Via') ?? '', $matches); if ($matches) { return 'HTTP/'.$matches[2]; @@ -1460,7 +1463,7 @@ public function getPayload(): InputBag } if (!\is_array($content)) { - throw new JsonException(sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); + throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); } return new InputBag($content); @@ -1486,7 +1489,7 @@ public function toArray(): array } if (!\is_array($content)) { - throw new JsonException(sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); + throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); } return $content; @@ -1545,15 +1548,11 @@ public function getPreferredLanguage(?array $locales = null): ?string return $preferredLanguages[0] ?? null; } - $locales = array_map($this->formatLocale(...), $locales ?? []); + $locales = array_map($this->formatLocale(...), $locales); if (!$preferredLanguages) { return $locales[0]; } - if ($matches = array_intersect($preferredLanguages, $locales)) { - return current($matches); - } - $combinations = array_merge(...array_map($this->getLanguageCombinations(...), $preferredLanguages)); foreach ($combinations as $combination) { foreach ($locales as $locale) { @@ -1581,7 +1580,7 @@ public function getLanguages(): array $this->languages = []; foreach ($languages as $acceptHeaderItem) { $lang = $acceptHeaderItem->getValue(); - $this->languages[] = $this->formatLocale($lang); + $this->languages[] = self::formatLocale($lang); } $this->languages = array_unique($this->languages); @@ -1893,7 +1892,7 @@ protected function preparePathInfo(): string } $pathInfo = substr($requestUri, \strlen($baseUrl)); - if (false === $pathInfo || '' === $pathInfo) { + if ('' === $pathInfo) { // If substr() returns false then PATH_INFO is set to an empty string return '/'; } @@ -1952,7 +1951,7 @@ private function getUrlencodedPrefix(string $string, string $prefix): ?string $len = \strlen($prefix); - if (preg_match(sprintf('#^(%%[[:xdigit:]]{2}|.){%d}#', $len), $string, $match)) { + if (preg_match(\sprintf('#^(%%[[:xdigit:]]{2}|.){%d}#', $len), $string, $match)) { return $match[0]; } @@ -2044,7 +2043,7 @@ private function getTrustedValues(int $type, ?string $ip = null): array } $this->isForwardedValid = false; - throw new ConflictingHeadersException(sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', self::TRUSTED_HEADERS[self::HEADER_FORWARDED], self::TRUSTED_HEADERS[$type])); + throw new ConflictingHeadersException(\sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', self::TRUSTED_HEADERS[self::HEADER_FORWARDED], self::TRUSTED_HEADERS[$type])); } private function normalizeAndFilterClientIps(array $clientIps, string $ip): array diff --git a/RequestStack.php b/RequestStack.php index 613c439dd..153bd9ad7 100644 --- a/RequestStack.php +++ b/RequestStack.php @@ -26,6 +26,16 @@ class RequestStack */ private array $requests = []; + /** + * @param Request[] $requests + */ + public function __construct(array $requests = []) + { + foreach ($requests as $request) { + $this->push($request); + } + } + /** * Pushes a Request on the stack. * diff --git a/Response.php b/Response.php index 22c09a01f..6766f2c77 100644 --- a/Response.php +++ b/Response.php @@ -121,6 +121,8 @@ class Response * (last updated 2021-10-01). * * Unless otherwise noted, the status code is defined in RFC2616. + * + * @var array */ public static array $statusTexts = [ 100 => 'Continue', @@ -217,7 +219,7 @@ public function __construct(?string $content = '', int $status = 200, array $hea public function __toString(): string { return - sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText)."\r\n". + \sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText)."\r\n". $this->headers."\r\n". $this->getContent(); } @@ -315,6 +317,11 @@ public function sendHeaders(?int $statusCode = null): static { // headers have already been sent by the developer if (headers_sent()) { + if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { + $statusCode ??= $this->statusCode; + header(\sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode); + } + return $this; } @@ -365,7 +372,7 @@ public function sendHeaders(?int $statusCode = null): static $statusCode ??= $this->statusCode; // status - header(sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode); + header(\sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode); return $this; } @@ -470,7 +477,7 @@ public function setStatusCode(int $code, ?string $text = null): static { $this->statusCode = $code; if ($this->isInvalid()) { - throw new \InvalidArgumentException(sprintf('The HTTP status code "%s" is not valid.', $code)); + throw new \InvalidArgumentException(\sprintf('The HTTP status code "%s" is not valid.', $code)); } if (null === $text) { @@ -973,7 +980,7 @@ public function setEtag(?string $etag, bool $weak = false): static public function setCache(array $options): static { if ($diff = array_diff(array_keys($options), array_keys(self::HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES))) { - throw new \InvalidArgumentException(sprintf('Response does not support the following options: "%s".', implode('", "', $diff))); + throw new \InvalidArgumentException(\sprintf('Response does not support the following options: "%s".', implode('", "', $diff))); } if (isset($options['etag'])) { diff --git a/ResponseHeaderBag.php b/ResponseHeaderBag.php index c8f08438d..b2bdb500c 100644 --- a/ResponseHeaderBag.php +++ b/ResponseHeaderBag.php @@ -195,7 +195,7 @@ public function removeCookie(string $name, ?string $path = '/', ?string $domain public function getCookies(string $format = self::COOKIES_FLAT): array { if (!\in_array($format, [self::COOKIES_FLAT, self::COOKIES_ARRAY])) { - throw new \InvalidArgumentException(sprintf('Format "%s" invalid (%s).', $format, implode(', ', [self::COOKIES_FLAT, self::COOKIES_ARRAY]))); + throw new \InvalidArgumentException(\sprintf('Format "%s" invalid (%s).', $format, implode(', ', [self::COOKIES_FLAT, self::COOKIES_ARRAY]))); } if (self::COOKIES_ARRAY === $format) { @@ -221,7 +221,7 @@ public function getCookies(string $format = self::COOKIES_FLAT): array */ public function clearCookie(string $name, ?string $path = '/', ?string $domain = null, bool $secure = false, bool $httpOnly = true, ?string $sameSite = null /* , bool $partitioned = false */): void { - $partitioned = 6 < \func_num_args() ? \func_get_arg(6) : false; + $partitioned = 6 < \func_num_args() ? func_get_arg(6) : false; $this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite, $partitioned)); } diff --git a/ServerEvent.php b/ServerEvent.php new file mode 100644 index 000000000..ea2b5c885 --- /dev/null +++ b/ServerEvent.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation; + +/** + * An event generated on the server intended for streaming to the client + * as part of the SSE streaming technique. + * + * @implements \IteratorAggregate + * + * @author Yonel Ceruto + */ +class ServerEvent implements \IteratorAggregate +{ + /** + * @param string|iterable $data The event data field for the message + * @param string|null $type The event type + * @param int|null $retry The number of milliseconds the client should wait + * before reconnecting in case of network failure + * @param string|null $id The event ID to set the EventSource object's last event ID value + * @param string|null $comment The event comment + */ + public function __construct( + private string|iterable $data, + private ?string $type = null, + private ?int $retry = null, + private ?string $id = null, + private ?string $comment = null, + ) { + } + + public function getData(): iterable|string + { + return $this->data; + } + + /** + * @return $this + */ + public function setData(iterable|string $data): static + { + $this->data = $data; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + /** + * @return $this + */ + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + public function getRetry(): ?int + { + return $this->retry; + } + + /** + * @return $this + */ + public function setRetry(?int $retry): static + { + $this->retry = $retry; + + return $this; + } + + public function getId(): ?string + { + return $this->id; + } + + /** + * @return $this + */ + public function setId(string $id): static + { + $this->id = $id; + + return $this; + } + + public function getComment(): ?string + { + return $this->comment; + } + + public function setComment(string $comment): static + { + $this->comment = $comment; + + return $this; + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + static $lastRetry = null; + + $head = ''; + if ($this->comment) { + $head .= \sprintf(': %s', $this->comment)."\n"; + } + if ($this->id) { + $head .= \sprintf('id: %s', $this->id)."\n"; + } + if ($this->retry > 0 && $this->retry !== $lastRetry) { + $head .= \sprintf('retry: %s', $lastRetry = $this->retry)."\n"; + } + if ($this->type) { + $head .= \sprintf('event: %s', $this->type)."\n"; + } + yield $head; + + if ($this->data) { + if (is_iterable($this->data)) { + foreach ($this->data as $data) { + yield \sprintf('data: %s', $data)."\n"; + } + } else { + yield \sprintf('data: %s', $this->data)."\n"; + } + } + + yield "\n"; + } +} diff --git a/Session/Attribute/AttributeBag.php b/Session/Attribute/AttributeBag.php index 042f3bd90..e34a497c5 100644 --- a/Session/Attribute/AttributeBag.php +++ b/Session/Attribute/AttributeBag.php @@ -21,14 +21,13 @@ class AttributeBag implements AttributeBagInterface, \IteratorAggregate, \Counta protected array $attributes = []; private string $name = 'attributes'; - private string $storageKey; /** * @param string $storageKey The key used to store attributes in the session */ - public function __construct(string $storageKey = '_sf2_attributes') - { - $this->storageKey = $storageKey; + public function __construct( + private string $storageKey = '_sf2_attributes', + ) { } public function getName(): string diff --git a/Session/Flash/AutoExpireFlashBag.php b/Session/Flash/AutoExpireFlashBag.php index 2eba84330..bfb856d51 100644 --- a/Session/Flash/AutoExpireFlashBag.php +++ b/Session/Flash/AutoExpireFlashBag.php @@ -20,14 +20,13 @@ class AutoExpireFlashBag implements FlashBagInterface { private string $name = 'flashes'; private array $flashes = ['display' => [], 'new' => []]; - private string $storageKey; /** * @param string $storageKey The key used to store flashes in the session */ - public function __construct(string $storageKey = '_symfony_flashes') - { - $this->storageKey = $storageKey; + public function __construct( + private string $storageKey = '_symfony_flashes', + ) { } public function getName(): string diff --git a/Session/Flash/FlashBag.php b/Session/Flash/FlashBag.php index 044639b36..72753a66a 100644 --- a/Session/Flash/FlashBag.php +++ b/Session/Flash/FlashBag.php @@ -20,14 +20,13 @@ class FlashBag implements FlashBagInterface { private string $name = 'flashes'; private array $flashes = []; - private string $storageKey; /** * @param string $storageKey The key used to store flashes in the session */ - public function __construct(string $storageKey = '_symfony_flashes') - { - $this->storageKey = $storageKey; + public function __construct( + private string $storageKey = '_symfony_flashes', + ) { } public function getName(): string diff --git a/Session/SessionBagProxy.php b/Session/SessionBagProxy.php index e759d94db..a389bd8b1 100644 --- a/Session/SessionBagProxy.php +++ b/Session/SessionBagProxy.php @@ -18,13 +18,16 @@ */ final class SessionBagProxy implements SessionBagInterface { - private SessionBagInterface $bag; private array $data; private ?int $usageIndex; private ?\Closure $usageReporter; - public function __construct(SessionBagInterface $bag, array &$data, ?int &$usageIndex, ?callable $usageReporter) - { + public function __construct( + private SessionBagInterface $bag, + array &$data, + ?int &$usageIndex, + ?callable $usageReporter, + ) { $this->bag = $bag; $this->data = &$data; $this->usageIndex = &$usageIndex; diff --git a/Session/SessionFactory.php b/Session/SessionFactory.php index c06ed4b7d..b875a23c5 100644 --- a/Session/SessionFactory.php +++ b/Session/SessionFactory.php @@ -22,14 +22,13 @@ class_exists(Session::class); */ class SessionFactory implements SessionFactoryInterface { - private RequestStack $requestStack; - private SessionStorageFactoryInterface $storageFactory; private ?\Closure $usageReporter; - public function __construct(RequestStack $requestStack, SessionStorageFactoryInterface $storageFactory, ?callable $usageReporter = null) - { - $this->requestStack = $requestStack; - $this->storageFactory = $storageFactory; + public function __construct( + private RequestStack $requestStack, + private SessionStorageFactoryInterface $storageFactory, + ?callable $usageReporter = null, + ) { $this->usageReporter = null === $usageReporter ? null : $usageReporter(...); } diff --git a/Session/SessionUtils.php b/Session/SessionUtils.php index 504c5848e..57aa565ff 100644 --- a/Session/SessionUtils.php +++ b/Session/SessionUtils.php @@ -28,8 +28,8 @@ final class SessionUtils public static function popSessionCookie(string $sessionName, #[\SensitiveParameter] string $sessionId): ?string { $sessionCookie = null; - $sessionCookiePrefix = sprintf(' %s=', urlencode($sessionName)); - $sessionCookieWithId = sprintf('%s%s;', $sessionCookiePrefix, urlencode($sessionId)); + $sessionCookiePrefix = \sprintf(' %s=', urlencode($sessionName)); + $sessionCookieWithId = \sprintf('%s%s;', $sessionCookiePrefix, urlencode($sessionId)); $otherCookies = []; foreach (headers_list() as $h) { if (0 !== stripos($h, 'Set-Cookie:')) { diff --git a/Session/Storage/Handler/AbstractSessionHandler.php b/Session/Storage/Handler/AbstractSessionHandler.php index 288c24232..fd8562377 100644 --- a/Session/Storage/Handler/AbstractSessionHandler.php +++ b/Session/Storage/Handler/AbstractSessionHandler.php @@ -32,7 +32,7 @@ public function open(string $savePath, string $sessionName): bool { $this->sessionName = $sessionName; if (!headers_sent() && !\ini_get('session.cache_limiter') && '0' !== \ini_get('session.cache_limiter')) { - header(sprintf('Cache-Control: max-age=%d, private, must-revalidate', 60 * (int) \ini_get('session.cache_expire'))); + header(\sprintf('Cache-Control: max-age=%d, private, must-revalidate', 60 * (int) \ini_get('session.cache_expire'))); } return true; @@ -88,7 +88,7 @@ public function destroy(#[\SensitiveParameter] string $sessionId): bool { if (!headers_sent() && filter_var(\ini_get('session.use_cookies'), \FILTER_VALIDATE_BOOL)) { if (!isset($this->sessionName)) { - throw new \LogicException(sprintf('Session name cannot be empty, did you forget to call "parent::open()" in "%s"?.', static::class)); + throw new \LogicException(\sprintf('Session name cannot be empty, did you forget to call "parent::open()" in "%s"?.', static::class)); } $cookie = SessionUtils::popSessionCookie($this->sessionName, $sessionId); diff --git a/Session/Storage/Handler/IdentityMarshaller.php b/Session/Storage/Handler/IdentityMarshaller.php index 411a8d1f0..70ac76248 100644 --- a/Session/Storage/Handler/IdentityMarshaller.php +++ b/Session/Storage/Handler/IdentityMarshaller.php @@ -22,7 +22,7 @@ public function marshall(array $values, ?array &$failed): array { foreach ($values as $key => $value) { if (!\is_string($value)) { - throw new \LogicException(sprintf('%s accepts only string as data.', __METHOD__)); + throw new \LogicException(\sprintf('%s accepts only string as data.', __METHOD__)); } } diff --git a/Session/Storage/Handler/MarshallingSessionHandler.php b/Session/Storage/Handler/MarshallingSessionHandler.php index 1567f5433..8e82f184d 100644 --- a/Session/Storage/Handler/MarshallingSessionHandler.php +++ b/Session/Storage/Handler/MarshallingSessionHandler.php @@ -18,13 +18,10 @@ */ class MarshallingSessionHandler implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface { - private AbstractSessionHandler $handler; - private MarshallerInterface $marshaller; - - public function __construct(AbstractSessionHandler $handler, MarshallerInterface $marshaller) - { - $this->handler = $handler; - $this->marshaller = $marshaller; + public function __construct( + private AbstractSessionHandler $handler, + private MarshallerInterface $marshaller, + ) { } public function open(string $savePath, string $name): bool diff --git a/Session/Storage/Handler/MemcachedSessionHandler.php b/Session/Storage/Handler/MemcachedSessionHandler.php index 91a023ddb..4b95d8878 100644 --- a/Session/Storage/Handler/MemcachedSessionHandler.php +++ b/Session/Storage/Handler/MemcachedSessionHandler.php @@ -21,8 +21,6 @@ */ class MemcachedSessionHandler extends AbstractSessionHandler { - private \Memcached $memcached; - /** * Time to live in seconds. */ @@ -42,12 +40,12 @@ class MemcachedSessionHandler extends AbstractSessionHandler * * @throws \InvalidArgumentException When unsupported options are passed */ - public function __construct(\Memcached $memcached, array $options = []) - { - $this->memcached = $memcached; - + public function __construct( + private \Memcached $memcached, + array $options = [], + ) { if ($diff = array_diff(array_keys($options), ['prefix', 'expiretime', 'ttl'])) { - throw new \InvalidArgumentException(sprintf('The following options are not supported "%s".', implode(', ', $diff))); + throw new \InvalidArgumentException(\sprintf('The following options are not supported "%s".', implode(', ', $diff))); } $this->ttl = $options['expiretime'] ?? $options['ttl'] ?? null; diff --git a/Session/Storage/Handler/NativeFileSessionHandler.php b/Session/Storage/Handler/NativeFileSessionHandler.php index f8c6151a4..284cd869d 100644 --- a/Session/Storage/Handler/NativeFileSessionHandler.php +++ b/Session/Storage/Handler/NativeFileSessionHandler.php @@ -34,7 +34,7 @@ public function __construct(?string $savePath = null) if ($count = substr_count($savePath, ';')) { if ($count > 2) { - throw new \InvalidArgumentException(sprintf('Invalid argument $savePath \'%s\'.', $savePath)); + throw new \InvalidArgumentException(\sprintf('Invalid argument $savePath \'%s\'.', $savePath)); } // characters after last ';' are the path @@ -42,7 +42,7 @@ public function __construct(?string $savePath = null) } if ($baseDir && !is_dir($baseDir) && !@mkdir($baseDir, 0777, true) && !is_dir($baseDir)) { - throw new \RuntimeException(sprintf('Session Storage was not able to create directory "%s".', $baseDir)); + throw new \RuntimeException(\sprintf('Session Storage was not able to create directory "%s".', $baseDir)); } if ($savePath !== \ini_get('session.save_path')) { diff --git a/Session/Storage/Handler/PdoSessionHandler.php b/Session/Storage/Handler/PdoSessionHandler.php index aa8ab5690..e2fb4f129 100644 --- a/Session/Storage/Handler/PdoSessionHandler.php +++ b/Session/Storage/Handler/PdoSessionHandler.php @@ -11,6 +11,9 @@ namespace Symfony\Component\HttpFoundation\Session\Storage\Handler; +use Doctrine\DBAL\Schema\Name\Identifier; +use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\PrimaryKeyConstraint; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; @@ -155,7 +158,7 @@ public function __construct(#[\SensitiveParameter] \PDO|string|null $pdoOrDsn = { if ($pdoOrDsn instanceof \PDO) { if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { - throw new \InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)).', __CLASS__)); + throw new \InvalidArgumentException(\sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)).', __CLASS__)); } $this->pdo = $pdoOrDsn; @@ -222,9 +225,15 @@ public function configureSchema(Schema $schema, ?\Closure $isSameDatabase = null $table->addColumn($this->timeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true); break; default: - throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); + throw new \DomainException(\sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); } - $table->setPrimaryKey([$this->idCol]); + + if (class_exists(PrimaryKeyConstraint::class)) { + $table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted($this->idCol))], true)); + } else { + $table->setPrimaryKey([$this->idCol]); + } + $table->addIndex([$this->lifetimeCol], $this->lifetimeCol.'_idx'); } @@ -255,7 +264,7 @@ public function createTable(): void 'pgsql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)", 'oci' => "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)", 'sqlsrv' => "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)", - default => throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)), + default => throw new \DomainException(\sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)), }; try { @@ -536,7 +545,7 @@ private function buildDsnFromUrl(#[\SensitiveParameter] string $dsnOrUrl): strin return $dsn; default: - throw new \InvalidArgumentException(sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.', $params['scheme'])); + throw new \InvalidArgumentException(\sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.', $params['scheme'])); } } @@ -732,7 +741,7 @@ private function doAdvisoryLock(#[\SensitiveParameter] string $sessionId): \PDOS case 'sqlite': throw new \DomainException('SQLite does not support advisory locks.'); default: - throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver)); + throw new \DomainException(\sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver)); } } @@ -774,7 +783,7 @@ private function getSelectSql(): string // we already locked when starting transaction break; default: - throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver)); + throw new \DomainException(\sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver)); } } diff --git a/Session/Storage/Handler/RedisSessionHandler.php b/Session/Storage/Handler/RedisSessionHandler.php index b696eee4b..78cd4e7c2 100644 --- a/Session/Storage/Handler/RedisSessionHandler.php +++ b/Session/Storage/Handler/RedisSessionHandler.php @@ -44,7 +44,7 @@ public function __construct( array $options = [], ) { if ($diff = array_diff(array_keys($options), ['prefix', 'ttl'])) { - throw new \InvalidArgumentException(sprintf('The following options are not supported "%s".', implode(', ', $diff))); + throw new \InvalidArgumentException(\sprintf('The following options are not supported "%s".', implode(', ', $diff))); } $this->prefix = $options['prefix'] ?? 'sf_s'; diff --git a/Session/Storage/Handler/SessionHandlerFactory.php b/Session/Storage/Handler/SessionHandlerFactory.php index 3f1d03267..cb0b6f8a9 100644 --- a/Session/Storage/Handler/SessionHandlerFactory.php +++ b/Session/Storage/Handler/SessionHandlerFactory.php @@ -49,7 +49,7 @@ public static function createHandler(object|string $connection, array $options = return new PdoSessionHandler($connection); case !\is_string($connection): - throw new \InvalidArgumentException(sprintf('Unsupported Connection: "%s".', get_debug_type($connection))); + throw new \InvalidArgumentException(\sprintf('Unsupported Connection: "%s".', get_debug_type($connection))); case str_starts_with($connection, 'file://'): $savePath = substr($connection, 7); @@ -57,6 +57,8 @@ public static function createHandler(object|string $connection, array $options = case str_starts_with($connection, 'redis:'): case str_starts_with($connection, 'rediss:'): + case str_starts_with($connection, 'valkey:'): + case str_starts_with($connection, 'valkeys:'): case str_starts_with($connection, 'memcached:'): if (!class_exists(AbstractAdapter::class)) { throw new \InvalidArgumentException('Unsupported Redis or Memcached DSN. Try running "composer require symfony/cache".'); @@ -90,6 +92,6 @@ public static function createHandler(object|string $connection, array $options = return new PdoSessionHandler($connection, $options); } - throw new \InvalidArgumentException(sprintf('Unsupported Connection: "%s".', $connection)); + throw new \InvalidArgumentException(\sprintf('Unsupported Connection: "%s".', $connection)); } } diff --git a/Session/Storage/Handler/StrictSessionHandler.php b/Session/Storage/Handler/StrictSessionHandler.php index 1f8668744..0d84eac34 100644 --- a/Session/Storage/Handler/StrictSessionHandler.php +++ b/Session/Storage/Handler/StrictSessionHandler.php @@ -18,16 +18,14 @@ */ class StrictSessionHandler extends AbstractSessionHandler { - private \SessionHandlerInterface $handler; private bool $doDestroy; - public function __construct(\SessionHandlerInterface $handler) - { + public function __construct( + private \SessionHandlerInterface $handler, + ) { if ($handler instanceof \SessionUpdateTimestampHandlerInterface) { - throw new \LogicException(sprintf('"%s" is already an instance of "SessionUpdateTimestampHandlerInterface", you cannot wrap it with "%s".', get_debug_type($handler), self::class)); + throw new \LogicException(\sprintf('"%s" is already an instance of "SessionUpdateTimestampHandlerInterface", you cannot wrap it with "%s".', get_debug_type($handler), self::class)); } - - $this->handler = $handler; } /** diff --git a/Session/Storage/MetadataBag.php b/Session/Storage/MetadataBag.php index 3e80f7dd8..c9e0bdd33 100644 --- a/Session/Storage/MetadataBag.php +++ b/Session/Storage/MetadataBag.php @@ -29,18 +29,16 @@ class MetadataBag implements SessionBagInterface protected array $meta = [self::CREATED => 0, self::UPDATED => 0, self::LIFETIME => 0]; private string $name = '__metadata'; - private string $storageKey; private int $lastUsed; - private int $updateThreshold; /** * @param string $storageKey The key used to store bag in the session * @param int $updateThreshold The time to wait between two UPDATED updates */ - public function __construct(string $storageKey = '_sf2_meta', int $updateThreshold = 0) - { - $this->storageKey = $storageKey; - $this->updateThreshold = $updateThreshold; + public function __construct( + private string $storageKey = '_sf2_meta', + private int $updateThreshold = 0, + ) { } public function initialize(array &$array): void diff --git a/Session/Storage/MockArraySessionStorage.php b/Session/Storage/MockArraySessionStorage.php index 617e51e63..a32a43d45 100644 --- a/Session/Storage/MockArraySessionStorage.php +++ b/Session/Storage/MockArraySessionStorage.php @@ -28,7 +28,6 @@ class MockArraySessionStorage implements SessionStorageInterface { protected string $id = ''; - protected string $name; protected bool $started = false; protected bool $closed = false; protected array $data = []; @@ -39,9 +38,10 @@ class MockArraySessionStorage implements SessionStorageInterface */ protected array $bags = []; - public function __construct(string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null) - { - $this->name = $name; + public function __construct( + protected string $name = 'MOCKSESSID', + ?MetadataBag $metaBag = null, + ) { $this->setMetadataBag($metaBag); } @@ -133,7 +133,7 @@ public function registerBag(SessionBagInterface $bag): void public function getBag(string $name): SessionBagInterface { if (!isset($this->bags[$name])) { - throw new \InvalidArgumentException(sprintf('The SessionBagInterface "%s" is not registered.', $name)); + throw new \InvalidArgumentException(\sprintf('The SessionBagInterface "%s" is not registered.', $name)); } if (!$this->started) { diff --git a/Session/Storage/MockFileSessionStorage.php b/Session/Storage/MockFileSessionStorage.php index 48dd74dfb..c230c701a 100644 --- a/Session/Storage/MockFileSessionStorage.php +++ b/Session/Storage/MockFileSessionStorage.php @@ -35,7 +35,7 @@ public function __construct(?string $savePath = null, string $name = 'MOCKSESSID $savePath ??= sys_get_temp_dir(); if (!is_dir($savePath) && !@mkdir($savePath, 0777, true) && !is_dir($savePath)) { - throw new \RuntimeException(sprintf('Session Storage was not able to create directory "%s".', $savePath)); + throw new \RuntimeException(\sprintf('Session Storage was not able to create directory "%s".', $savePath)); } $this->savePath = $savePath; diff --git a/Session/Storage/MockFileSessionStorageFactory.php b/Session/Storage/MockFileSessionStorageFactory.php index 6727cf14f..77ee7b658 100644 --- a/Session/Storage/MockFileSessionStorageFactory.php +++ b/Session/Storage/MockFileSessionStorageFactory.php @@ -21,18 +21,14 @@ class_exists(MockFileSessionStorage::class); */ class MockFileSessionStorageFactory implements SessionStorageFactoryInterface { - private ?string $savePath; - private string $name; - private ?MetadataBag $metaBag; - /** * @see MockFileSessionStorage constructor. */ - public function __construct(?string $savePath = null, string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null) - { - $this->savePath = $savePath; - $this->name = $name; - $this->metaBag = $metaBag; + public function __construct( + private ?string $savePath = null, + private string $name = 'MOCKSESSID', + private ?MetadataBag $metaBag = null, + ) { } public function createStorage(?Request $request): SessionStorageInterface diff --git a/Session/Storage/NativeSessionStorage.php b/Session/Storage/NativeSessionStorage.php index d32292ae0..3d08f5f6d 100644 --- a/Session/Storage/NativeSessionStorage.php +++ b/Session/Storage/NativeSessionStorage.php @@ -62,16 +62,16 @@ class NativeSessionStorage implements SessionStorageInterface * gc_probability, "1" * lazy_write, "1" * name, "PHPSESSID" - * referer_check, "" + * referer_check, "" (deprecated since Symfony 7.2, to be removed in Symfony 8.0) * serialize_handler, "php" * use_strict_mode, "1" * use_cookies, "1" - * use_only_cookies, "1" - * use_trans_sid, "0" - * sid_length, "32" - * sid_bits_per_character, "5" - * trans_sid_hosts, $_SERVER['HTTP_HOST'] - * trans_sid_tags, "a=href,area=href,frame=src,form=" + * use_only_cookies, "1" (deprecated since Symfony 7.2, to be removed in Symfony 8.0) + * use_trans_sid, "0" (deprecated since Symfony 7.2, to be removed in Symfony 8.0) + * sid_length, "32" (@deprecated since Symfony 7.2, to be removed in 8.0) + * sid_bits_per_character, "5" (@deprecated since Symfony 7.2, to be removed in 8.0) + * trans_sid_hosts, $_SERVER['HTTP_HOST'] (deprecated since Symfony 7.2, to be removed in Symfony 8.0) + * trans_sid_tags, "a=href,area=href,frame=src,form=" (deprecated since Symfony 7.2, to be removed in Symfony 8.0) */ public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null) { @@ -113,7 +113,7 @@ public function start(): bool } if (filter_var(\ini_get('session.use_cookies'), \FILTER_VALIDATE_BOOL) && headers_sent($file, $line)) { - throw new \RuntimeException(sprintf('Failed to start the session because headers have already been sent by "%s" at line %d.', $file, $line)); + throw new \RuntimeException(\sprintf('Failed to start the session because headers have already been sent by "%s" at line %d.', $file, $line)); } $sessionId = $_COOKIE[session_name()] ?? null; @@ -126,8 +126,8 @@ public function start(): bool * See https://www.php.net/manual/en/session.configuration.php#ini.session.sid-bits-per-character. * Allowed values are integers such as: * - 4 for range `a-f0-9` - * - 5 for range `a-v0-9` - * - 6 for range `a-zA-Z0-9,-` + * - 5 for range `a-v0-9` (@deprecated since Symfony 7.2, it will default to 4 and the option will be ignored in Symfony 8.0) + * - 6 for range `a-zA-Z0-9,-` (@deprecated since Symfony 7.2, it will default to 4 and the option will be ignored in Symfony 8.0) * * ---------- Part 2 * @@ -139,6 +139,8 @@ public function start(): bool * - The length of Windows and Linux filenames is limited to 255 bytes. Then the max must not exceed 255. * - The session filename prefix is `sess_`, a 5 bytes string. Then the max must not exceed 255 - 5 = 250. * + * This is @deprecated since Symfony 7.2, the sid length will default to 32 and the option will be ignored in Symfony 8.0. + * * ---------- Conclusion * * The parts 1 and 2 prevent the warning below: @@ -224,7 +226,7 @@ public function save(): void $previousHandler = set_error_handler(function ($type, $msg, $file, $line) use (&$previousHandler) { if (\E_WARNING === $type && str_starts_with($msg, 'session_write_close():')) { $handler = $this->saveHandler instanceof SessionHandlerProxy ? $this->saveHandler->getHandler() : $this->saveHandler; - $msg = sprintf('session_write_close(): Failed to write session data with "%s" handler', $handler::class); + $msg = \sprintf('session_write_close(): Failed to write session data with "%s" handler', $handler::class); } return $previousHandler ? $previousHandler($type, $msg, $file, $line) : false; @@ -271,7 +273,7 @@ public function registerBag(SessionBagInterface $bag): void public function getBag(string $name): SessionBagInterface { if (!isset($this->bags[$name])) { - throw new \InvalidArgumentException(sprintf('The SessionBagInterface "%s" is not registered.', $name)); + throw new \InvalidArgumentException(\sprintf('The SessionBagInterface "%s" is not registered.', $name)); } if (!$this->started && $this->saveHandler->isActive()) { @@ -328,6 +330,10 @@ public function setOptions(array $options): void ]); foreach ($options as $key => $value) { + if (\in_array($key, ['referer_check', 'use_only_cookies', 'use_trans_sid', 'trans_sid_hosts', 'trans_sid_tags', 'sid_length', 'sid_bits_per_character'], true)) { + trigger_deprecation('symfony/http-foundation', '7.2', 'NativeSessionStorage\'s "%s" option is deprecated and will be ignored in Symfony 8.0.', $key); + } + if (isset($validOptions[$key])) { if ('cookie_secure' === $key && 'auto' === $value) { continue; diff --git a/Session/Storage/NativeSessionStorageFactory.php b/Session/Storage/NativeSessionStorageFactory.php index 6463a4c1b..cb8c53539 100644 --- a/Session/Storage/NativeSessionStorageFactory.php +++ b/Session/Storage/NativeSessionStorageFactory.php @@ -22,20 +22,15 @@ class_exists(NativeSessionStorage::class); */ class NativeSessionStorageFactory implements SessionStorageFactoryInterface { - private array $options; - private AbstractProxy|\SessionHandlerInterface|null $handler; - private ?MetadataBag $metaBag; - private bool $secure; - /** * @see NativeSessionStorage constructor. */ - public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null, bool $secure = false) - { - $this->options = $options; - $this->handler = $handler; - $this->metaBag = $metaBag; - $this->secure = $secure; + public function __construct( + private array $options = [], + private AbstractProxy|\SessionHandlerInterface|null $handler = null, + private ?MetadataBag $metaBag = null, + private bool $secure = false, + ) { } public function createStorage(?Request $request): SessionStorageInterface diff --git a/Session/Storage/PhpBridgeSessionStorageFactory.php b/Session/Storage/PhpBridgeSessionStorageFactory.php index aa4f800d3..357e5c713 100644 --- a/Session/Storage/PhpBridgeSessionStorageFactory.php +++ b/Session/Storage/PhpBridgeSessionStorageFactory.php @@ -22,15 +22,11 @@ class_exists(PhpBridgeSessionStorage::class); */ class PhpBridgeSessionStorageFactory implements SessionStorageFactoryInterface { - private AbstractProxy|\SessionHandlerInterface|null $handler; - private ?MetadataBag $metaBag; - private bool $secure; - - public function __construct(AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null, bool $secure = false) - { - $this->handler = $handler; - $this->metaBag = $metaBag; - $this->secure = $secure; + public function __construct( + private AbstractProxy|\SessionHandlerInterface|null $handler = null, + private ?MetadataBag $metaBag = null, + private bool $secure = false, + ) { } public function createStorage(?Request $request): SessionStorageInterface diff --git a/Session/Storage/Proxy/SessionHandlerProxy.php b/Session/Storage/Proxy/SessionHandlerProxy.php index b8df97f45..0316362f0 100644 --- a/Session/Storage/Proxy/SessionHandlerProxy.php +++ b/Session/Storage/Proxy/SessionHandlerProxy.php @@ -18,11 +18,9 @@ */ class SessionHandlerProxy extends AbstractProxy implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface { - protected \SessionHandlerInterface $handler; - - public function __construct(\SessionHandlerInterface $handler) - { - $this->handler = $handler; + public function __construct( + protected \SessionHandlerInterface $handler, + ) { $this->wrapper = $handler instanceof \SessionHandler; $this->saveHandlerName = $this->wrapper || ($handler instanceof StrictSessionHandler && $handler->isWrapper()) ? \ini_get('session.save_handler') : 'user'; } diff --git a/StreamedResponse.php b/StreamedResponse.php index 3acaade17..4e755a7cd 100644 --- a/StreamedResponse.php +++ b/StreamedResponse.php @@ -14,7 +14,7 @@ /** * StreamedResponse represents a streamed HTTP response. * - * A StreamedResponse uses a callback for its content. + * A StreamedResponse uses a callback or an iterable of strings for its content. * * The callback should use the standard PHP functions like echo * to stream the response back to the client. The flush() function @@ -32,19 +32,38 @@ class StreamedResponse extends Response private bool $headersSent = false; /** - * @param int $status The HTTP status code (200 "OK" by default) + * @param callable|iterable|null $callbackOrChunks + * @param int $status The HTTP status code (200 "OK" by default) */ - public function __construct(?callable $callback = null, int $status = 200, array $headers = []) + public function __construct(callable|iterable|null $callbackOrChunks = null, int $status = 200, array $headers = []) { parent::__construct(null, $status, $headers); - if (null !== $callback) { - $this->setCallback($callback); + if (\is_callable($callbackOrChunks)) { + $this->setCallback($callbackOrChunks); + } elseif ($callbackOrChunks) { + $this->setChunks($callbackOrChunks); } $this->streamed = false; $this->headersSent = false; } + /** + * @param iterable $chunks + */ + public function setChunks(iterable $chunks): static + { + $this->callback = static function () use ($chunks): void { + foreach ($chunks as $chunk) { + echo $chunk; + @ob_flush(); + flush(); + } + }; + + return $this; + } + /** * Sets the PHP callback associated with this Response. * diff --git a/Test/Constraint/RequestAttributeValueSame.php b/Test/Constraint/RequestAttributeValueSame.php index 6e1142681..c570848fb 100644 --- a/Test/Constraint/RequestAttributeValueSame.php +++ b/Test/Constraint/RequestAttributeValueSame.php @@ -16,18 +16,15 @@ final class RequestAttributeValueSame extends Constraint { - private string $name; - private string $value; - - public function __construct(string $name, string $value) - { - $this->name = $name; - $this->value = $value; + public function __construct( + private string $name, + private string $value, + ) { } public function toString(): string { - return sprintf('has attribute "%s" with value "%s"', $this->name, $this->value); + return \sprintf('has attribute "%s" with value "%s"', $this->name, $this->value); } /** diff --git a/Test/Constraint/ResponseCookieValueSame.php b/Test/Constraint/ResponseCookieValueSame.php index 768007b95..dbf9add6f 100644 --- a/Test/Constraint/ResponseCookieValueSame.php +++ b/Test/Constraint/ResponseCookieValueSame.php @@ -17,31 +17,25 @@ final class ResponseCookieValueSame extends Constraint { - private string $name; - private string $value; - private string $path; - private ?string $domain; - - public function __construct(string $name, string $value, string $path = '/', ?string $domain = null) - { - $this->name = $name; - $this->value = $value; - $this->path = $path; - $this->domain = $domain; + public function __construct( + private string $name, + private string $value, + private string $path = '/', + private ?string $domain = null, + ) { } public function toString(): string { - $str = sprintf('has cookie "%s"', $this->name); + $str = \sprintf('has cookie "%s"', $this->name); if ('/' !== $this->path) { - $str .= sprintf(' with path "%s"', $this->path); + $str .= \sprintf(' with path "%s"', $this->path); } if ($this->domain) { - $str .= sprintf(' for domain "%s"', $this->domain); + $str .= \sprintf(' for domain "%s"', $this->domain); } - $str .= sprintf(' with value "%s"', $this->value); - return $str; + return $str.\sprintf(' with value "%s"', $this->value); } /** diff --git a/Test/Constraint/ResponseFormatSame.php b/Test/Constraint/ResponseFormatSame.php index c75321f24..cc3655aef 100644 --- a/Test/Constraint/ResponseFormatSame.php +++ b/Test/Constraint/ResponseFormatSame.php @@ -22,16 +22,11 @@ */ final class ResponseFormatSame extends Constraint { - private Request $request; - private ?string $format; - public function __construct( - Request $request, - ?string $format, + private Request $request, + private ?string $format, private readonly bool $verbose = true, ) { - $this->request = $request; - $this->format = $format; } public function toString(): string diff --git a/Test/Constraint/ResponseHasCookie.php b/Test/Constraint/ResponseHasCookie.php index 8eccea9d1..0bc58036f 100644 --- a/Test/Constraint/ResponseHasCookie.php +++ b/Test/Constraint/ResponseHasCookie.php @@ -17,25 +17,21 @@ final class ResponseHasCookie extends Constraint { - private string $name; - private string $path; - private ?string $domain; - - public function __construct(string $name, string $path = '/', ?string $domain = null) - { - $this->name = $name; - $this->path = $path; - $this->domain = $domain; + public function __construct( + private string $name, + private string $path = '/', + private ?string $domain = null, + ) { } public function toString(): string { - $str = sprintf('has cookie "%s"', $this->name); + $str = \sprintf('has cookie "%s"', $this->name); if ('/' !== $this->path) { - $str .= sprintf(' with path "%s"', $this->path); + $str .= \sprintf(' with path "%s"', $this->path); } if ($this->domain) { - $str .= sprintf(' for domain "%s"', $this->domain); + $str .= \sprintf(' for domain "%s"', $this->domain); } return $str; diff --git a/Test/Constraint/ResponseHasHeader.php b/Test/Constraint/ResponseHasHeader.php index 08522c89c..52fd3c180 100644 --- a/Test/Constraint/ResponseHasHeader.php +++ b/Test/Constraint/ResponseHasHeader.php @@ -16,16 +16,14 @@ final class ResponseHasHeader extends Constraint { - private string $headerName; - - public function __construct(string $headerName) - { - $this->headerName = $headerName; + public function __construct( + private string $headerName, + ) { } public function toString(): string { - return sprintf('has header "%s"', $this->headerName); + return \sprintf('has header "%s"', $this->headerName); } /** diff --git a/Test/Constraint/ResponseHeaderLocationSame.php b/Test/Constraint/ResponseHeaderLocationSame.php index 9286ec715..833ffd9f2 100644 --- a/Test/Constraint/ResponseHeaderLocationSame.php +++ b/Test/Constraint/ResponseHeaderLocationSame.php @@ -23,7 +23,7 @@ public function __construct(private Request $request, private string $expectedVa public function toString(): string { - return sprintf('has header "Location" matching "%s"', $this->expectedValue); + return \sprintf('has header "Location" matching "%s"', $this->expectedValue); } protected function matches($other): bool @@ -53,7 +53,7 @@ private function toFullUrl(string $url): string } if (str_starts_with($url, '//')) { - return sprintf('%s:%s', $this->request->getScheme(), $url); + return \sprintf('%s:%s', $this->request->getScheme(), $url); } if (str_starts_with($url, '/')) { diff --git a/Test/Constraint/ResponseHeaderSame.php b/Test/Constraint/ResponseHeaderSame.php index 8141df972..f2ae27f5b 100644 --- a/Test/Constraint/ResponseHeaderSame.php +++ b/Test/Constraint/ResponseHeaderSame.php @@ -16,18 +16,15 @@ final class ResponseHeaderSame extends Constraint { - private string $headerName; - private string $expectedValue; - - public function __construct(string $headerName, string $expectedValue) - { - $this->headerName = $headerName; - $this->expectedValue = $expectedValue; + public function __construct( + private string $headerName, + private string $expectedValue, + ) { } public function toString(): string { - return sprintf('has header "%s" with value "%s"', $this->headerName, $this->expectedValue); + return \sprintf('has header "%s" with value "%s"', $this->headerName, $this->expectedValue); } /** diff --git a/Test/Constraint/ResponseStatusCodeSame.php b/Test/Constraint/ResponseStatusCodeSame.php index 5ca6373ce..1223608b9 100644 --- a/Test/Constraint/ResponseStatusCodeSame.php +++ b/Test/Constraint/ResponseStatusCodeSame.php @@ -16,11 +16,10 @@ final class ResponseStatusCodeSame extends Constraint { - private int $statusCode; - - public function __construct(int $statusCode, private readonly bool $verbose = true) - { - $this->statusCode = $statusCode; + public function __construct( + private int $statusCode, + private readonly bool $verbose = true, + ) { } public function toString(): string diff --git a/Tests/BinaryFileResponseTest.php b/Tests/BinaryFileResponseTest.php index d4d84b305..7627cd5ec 100644 --- a/Tests/BinaryFileResponseTest.php +++ b/Tests/BinaryFileResponseTest.php @@ -314,7 +314,15 @@ public function testXAccelMapping($realpath, $mapping, $virtual) $property->setValue($response, $file); $response->prepare($request); - $this->assertEquals($virtual, $response->headers->get('X-Accel-Redirect')); + $header = $response->headers->get('X-Accel-Redirect'); + + if ($virtual) { + // Making sure the path doesn't contain characters unsupported by nginx + $this->assertMatchesRegularExpression('/^([^?%]|%[0-9A-F]{2})*$/', $header); + $header = rawurldecode($header); + } + + $this->assertEquals($virtual, $header); } public function testDeleteFileAfterSend() @@ -361,6 +369,7 @@ public static function getSampleXAccelMappings() ['/home/Foo/bar.txt', '/var/www/=/files/,/home/Foo/=/baz/', '/baz/bar.txt'], ['/home/Foo/bar.txt', '"/var/www/"="/files/", "/home/Foo/"="/baz/"', '/baz/bar.txt'], ['/tmp/bar.txt', '"/var/www/"="/files/", "/home/Foo/"="/baz/"', null], + ['/var/www/var/www/files/foo%.txt', '/var/www/=/files/', '/files/var/www/files/foo%.txt'], ]; } @@ -459,6 +468,16 @@ public function testCreateFromTemporaryFile() $this->assertSame('foo,bar', $string); } + public function testSetChunkSizeTooSmall() + { + $response = new BinaryFileResponse(__DIR__.'/File/Fixtures/test.gif'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The chunk size of a BinaryFileResponse cannot be less than 1.'); + + $response->setChunkSize(0); + } + public function testCreateFromTemporaryFileWithoutMimeType() { $file = new \SplTempFileObject(); diff --git a/Tests/EventStreamResponseTest.php b/Tests/EventStreamResponseTest.php new file mode 100644 index 000000000..4c430fbe8 --- /dev/null +++ b/Tests/EventStreamResponseTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\EventStreamResponse; +use Symfony\Component\HttpFoundation\ServerEvent; + +class EventStreamResponseTest extends TestCase +{ + public function testInitializationWithDefaultValues() + { + $response = new EventStreamResponse(); + + $this->assertSame('text/event-stream', $response->headers->get('content-type')); + $this->assertSame('max-age=0, must-revalidate, no-cache, no-store, private', $response->headers->get('cache-control')); + $this->assertSame('keep-alive', $response->headers->get('connection')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNull($response->getRetry()); + } + + public function testStreamSingleEvent() + { + $response = new EventStreamResponse(function () { + yield new ServerEvent( + data: 'foo', + type: 'bar', + retry: 100, + id: '1', + comment: 'bla bla', + ); + }); + + $expected = <<assertSameResponseContent($expected, $response); + } + + public function testStreamEventsAndData() + { + $data = static function (): iterable { + yield 'first line'; + yield 'second line'; + yield 'third line'; + }; + + $response = new EventStreamResponse(function () use ($data) { + yield new ServerEvent('single line'); + yield new ServerEvent(['first line', 'second line']); + yield new ServerEvent($data()); + }); + + $expected = <<assertSameResponseContent($expected, $response); + } + + public function testStreamEventsWithRetryFallback() + { + $response = new EventStreamResponse(function () { + yield new ServerEvent('foo'); + yield new ServerEvent('bar'); + yield new ServerEvent('baz', retry: 1000); + }, retry: 1500); + + $expected = <<assertSameResponseContent($expected, $response); + } + + public function testStreamEventWithSendMethod() + { + $response = new EventStreamResponse(function (EventStreamResponse $response) { + $response->sendEvent(new ServerEvent('foo')); + }); + + $this->assertSameResponseContent("data: foo\n\n", $response); + } + + private function assertSameResponseContent(string $expected, EventStreamResponse $response): void + { + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + $this->assertSame($expected, $actual); + } +} diff --git a/Tests/InputBagTest.php b/Tests/InputBagTest.php index 01df837b2..d23f17f71 100644 --- a/Tests/InputBagTest.php +++ b/Tests/InputBagTest.php @@ -20,7 +20,7 @@ class InputBagTest extends TestCase { public function testGet() { - $bag = new InputBag(['foo' => 'bar', 'null' => null, 'int' => 1, 'float' => 1.0, 'bool' => false, 'stringable' => new class() implements \Stringable { + $bag = new InputBag(['foo' => 'bar', 'null' => null, 'int' => 1, 'float' => 1.0, 'bool' => false, 'stringable' => new class implements \Stringable { public function __toString(): string { return 'strval'; @@ -58,7 +58,7 @@ public function testGetBooleanError() public function testGetString() { - $bag = new InputBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class() implements \Stringable { + $bag = new InputBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class implements \Stringable { public function __toString(): string { return 'strval'; @@ -71,7 +71,7 @@ public function __toString(): string $this->assertSame('foo', $bag->getString('unknown', 'foo'), '->getString() returns the default if a parameter is not defined'); $this->assertSame('1', $bag->getString('bool_true'), '->getString() returns "1" if a parameter is true'); $this->assertSame('', $bag->getString('bool_false', 'foo'), '->getString() returns an empty empty string if a parameter is false'); - $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable paramater as string'); + $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable parameter as string'); } public function testGetStringExceptionWithArray() diff --git a/Tests/IpUtilsTest.php b/Tests/IpUtilsTest.php index 2a86fbc2d..5ed3e7b22 100644 --- a/Tests/IpUtilsTest.php +++ b/Tests/IpUtilsTest.php @@ -151,6 +151,70 @@ public static function anonymizedIpData() ]; } + /** + * @dataProvider anonymizedIpDataWithBytes + */ + public function testAnonymizeWithBytes($ip, $expected, $bytesForV4, $bytesForV6) + { + $this->assertSame($expected, IpUtils::anonymize($ip, $bytesForV4, $bytesForV6)); + } + + public static function anonymizedIpDataWithBytes(): array + { + return [ + ['192.168.1.1', '192.168.0.0', 2, 8], + ['192.168.1.1', '192.0.0.0', 3, 8], + ['192.168.1.1', '0.0.0.0', 4, 8], + ['1.2.3.4', '1.2.3.0', 1, 8], + ['1.2.3.4', '1.2.3.4', 0, 8], + ['2a01:198:603:0:396e:4789:8e99:890f', '2a01:198:603:0:396e:4789:8e99:890f', 1, 0], + ['2a01:198:603:0:396e:4789:8e99:890f', '2a01:198:603:0:396e:4789::', 1, 4], + ['2a01:198:603:10:396e:4789:8e99:890f', '2a01:198:603:10:396e:4700::', 1, 5], + ['2a01:198:603:10:396e:4789:8e99:890f', '2a00::', 1, 15], + ['2a01:198:603:10:396e:4789:8e99:890f', '::', 1, 16], + ['::1', '::', 1, 1], + ['0:0:0:0:0:0:0:1', '::', 1, 1], + ['1:0:0:0:0:0:0:1', '1::', 1, 1], + ['0:0:603:50:396e:4789:8e99:0001', '0:0:603::', 1, 10], + ['[0:0:603:50:396e:4789:8e99:0001]', '[::603:50:396e:4789:8e00:0]', 1, 3], + ['[2a01:198::3]', '[2a01:198::]', 1, 2], + ['::ffff:123.234.235.236', '::ffff:123.234.235.0', 1, 8], // IPv4-mapped IPv6 addresses + ['::123.234.235.236', '::123.234.0.0', 2, 8], // deprecated IPv4-compatible IPv6 address + ]; + } + + public function testAnonymizeV4WithNegativeBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize less than 0 bytes.'); + + IpUtils::anonymize('anything', -1, 8); + } + + public function testAnonymizeV6WithNegativeBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize less than 0 bytes.'); + + IpUtils::anonymize('anything', 1, -1); + } + + public function testAnonymizeV4WithTooManyBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + + IpUtils::anonymize('anything', 5, 8); + } + + public function testAnonymizeV6WithTooManyBytes() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + + IpUtils::anonymize('anything', 1, 17); + } + /** * @dataProvider getIp4SubnetMaskZeroData */ diff --git a/Tests/JsonResponseTest.php b/Tests/JsonResponseTest.php index 2058280c5..67a038279 100644 --- a/Tests/JsonResponseTest.php +++ b/Tests/JsonResponseTest.php @@ -171,7 +171,7 @@ public function testConstructorWithNullAsDataThrowsAnUnexpectedValueException() public function testConstructorWithObjectWithToStringMethod() { - $class = new class() { + $class = new class { public function __toString(): string { return '{}'; diff --git a/Tests/ParameterBagTest.php b/Tests/ParameterBagTest.php index c73d70507..d9ce41146 100644 --- a/Tests/ParameterBagTest.php +++ b/Tests/ParameterBagTest.php @@ -206,7 +206,7 @@ public function testGetIntExceptionWithInvalid() public function testGetString() { - $bag = new ParameterBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class() implements \Stringable { + $bag = new ParameterBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class implements \Stringable { public function __toString(): string { return 'strval'; @@ -219,7 +219,7 @@ public function __toString(): string $this->assertSame('foo', $bag->getString('unknown', 'foo'), '->getString() returns the default if a parameter is not defined'); $this->assertSame('1', $bag->getString('bool_true'), '->getString() returns "1" if a parameter is true'); $this->assertSame('', $bag->getString('bool_false', 'foo'), '->getString() returns an empty empty string if a parameter is false'); - $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable paramater as string'); + $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable parameter as string'); } public function testGetStringExceptionWithArray() @@ -251,9 +251,9 @@ public function testFilter() 'dec' => '256', 'hex' => '0x100', 'array' => ['bang'], - ]); + ]); - $this->assertEmpty($bag->filter('nokey'), '->filter() should return empty by default if no key is found'); + $this->assertSame('', $bag->filter('nokey'), '->filter() should return empty by default if no key is found'); $this->assertEquals('0123', $bag->filter('digits', '', \FILTER_SANITIZE_NUMBER_INT), '->filter() gets a value of parameter as integer filtering out invalid characters'); diff --git a/Tests/RateLimiter/AbstractRequestRateLimiterTest.php b/Tests/RateLimiter/AbstractRequestRateLimiterTest.php index 26f2fac90..087d7aeae 100644 --- a/Tests/RateLimiter/AbstractRequestRateLimiterTest.php +++ b/Tests/RateLimiter/AbstractRequestRateLimiterTest.php @@ -14,7 +14,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\RateLimiter\LimiterInterface; -use Symfony\Component\RateLimiter\Policy\NoLimiter; use Symfony\Component\RateLimiter\RateLimit; class AbstractRequestRateLimiterTest extends TestCase diff --git a/Tests/RequestMatcher/SchemeRequestMatcherTest.php b/Tests/RequestMatcher/SchemeRequestMatcherTest.php index 6614bfcc2..933b3d695 100644 --- a/Tests/RequestMatcher/SchemeRequestMatcherTest.php +++ b/Tests/RequestMatcher/SchemeRequestMatcherTest.php @@ -25,18 +25,16 @@ public function test(string $requestScheme, array|string $matcherScheme, bool $i $httpRequest = Request::create(''); $httpsRequest = Request::create('', 'get', [], [], [], ['HTTPS' => 'on']); + $matcher = new SchemeRequestMatcher($matcherScheme); if ($isMatch) { if ('https' === $requestScheme) { - $matcher = new SchemeRequestMatcher($matcherScheme); $this->assertFalse($matcher->matches($httpRequest)); $this->assertTrue($matcher->matches($httpsRequest)); } else { - $matcher = new SchemeRequestMatcher($matcherScheme); $this->assertFalse($matcher->matches($httpsRequest)); $this->assertTrue($matcher->matches($httpRequest)); } } else { - $matcher = new SchemeRequestMatcher($matcherScheme); $this->assertFalse($matcher->matches($httpRequest)); $this->assertFalse($matcher->matches($httpsRequest)); } diff --git a/Tests/RequestStackTest.php b/Tests/RequestStackTest.php index 3b958653f..f97efe480 100644 --- a/Tests/RequestStackTest.php +++ b/Tests/RequestStackTest.php @@ -17,6 +17,13 @@ class RequestStackTest extends TestCase { + public function testConstruct() + { + $request = Request::create('/foo'); + $requestStack = new RequestStack([$request]); + $this->assertSame($request, $requestStack->getCurrentRequest()); + } + public function testGetCurrentRequest() { $requestStack = new RequestStack(); diff --git a/Tests/RequestTest.php b/Tests/RequestTest.php index 60a74a2ae..5cfb980a7 100644 --- a/Tests/RequestTest.php +++ b/Tests/RequestTest.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Exception\JsonException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\InputBag; +use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; @@ -588,7 +589,6 @@ public function testGetUri() $server['REDIRECT_QUERY_STRING'] = 'query=string'; $server['REDIRECT_URL'] = '/path/info'; - $server['SCRIPT_NAME'] = '/index.php'; $server['QUERY_STRING'] = 'query=string'; $server['REQUEST_URI'] = '/path/info?toto=test&1=1'; $server['SCRIPT_NAME'] = '/index.php'; @@ -715,7 +715,6 @@ public function testGetUriForPath() $server['REDIRECT_QUERY_STRING'] = 'query=string'; $server['REDIRECT_URL'] = '/path/info'; - $server['SCRIPT_NAME'] = '/index.php'; $server['QUERY_STRING'] = 'query=string'; $server['REQUEST_URI'] = '/path/info?toto=test&1=1'; $server['SCRIPT_NAME'] = '/index.php'; @@ -1549,16 +1548,16 @@ public static function providePreferredLanguage(): iterable yield '"fr" selected as first choice when no header is present' => ['fr', null, ['fr', 'en']]; yield '"en" selected as first choice when no header is present' => ['en', null, ['en', 'fr']]; yield '"fr_CH" selected as first choice when no header is present' => ['fr_CH', null, ['fr-ch', 'fr-fr']]; - yield '"en_US" is selected as an exact match is found (1)' => ['en_US', 'zh, en-us; q=0.8, en; q=0.6', ['en', 'en-us']]; - yield '"en_US" is selected as an exact match is found (2)' => ['en_US', 'ja-JP,fr_CA;q=0.7,fr;q=0.5,en_US;q=0.3', ['en_US', 'fr_FR']]; - yield '"en" is selected as an exact match is found' => ['en', 'zh, en-us; q=0.8, en; q=0.6', ['fr', 'en']]; - yield '"fr" is selected as an exact match is found' => ['fr', 'zh, en-us; q=0.8, fr-fr; q=0.6, fr; q=0.5', ['fr', 'en']]; + yield '"en_US" is selected as an exact match is found' => ['en_US', 'zh, en-us; q=0.8, en; q=0.6', ['en', 'en-us']]; + yield '"fr_FR" is selected as it has a higher priority than an exact match' => ['fr_FR', 'ja-JP,fr_CA;q=0.7,fr;q=0.5,en_US;q=0.3', ['en_US', 'fr_FR']]; + yield '"en" is selected as an exact match is found (1)' => ['en', 'zh, en-us; q=0.8, en; q=0.6', ['fr', 'en']]; + yield '"en" is selected as an exact match is found (2)' => ['en', 'zh, en-us; q=0.8, fr-fr; q=0.6, fr; q=0.5', ['fr', 'en']]; yield '"en" is selected as "en-us" is a similar dialect' => ['en', 'zh, en-us; q=0.8', ['fr', 'en']]; yield '"fr_FR" is selected as "fr_CA" is a similar dialect (1)' => ['fr_FR', 'ja-JP,fr_CA;q=0.7,fr;q=0.5', ['en_US', 'fr_FR']]; yield '"fr_FR" is selected as "fr_CA" is a similar dialect (2)' => ['fr_FR', 'ja-JP,fr_CA;q=0.7', ['en_US', 'fr_FR']]; - yield '"fr_FR" is selected as "fr" is a similar dialect' => ['fr_FR', 'ja-JP,fr;q=0.5', ['en_US', 'fr_FR']]; + yield '"fr_FR" is selected as "fr" is a similar dialect (1)' => ['fr_FR', 'ja-JP,fr;q=0.5', ['en_US', 'fr_FR']]; + yield '"fr_FR" is selected as "fr" is a similar dialect (2)' => ['fr_FR', 'ja-JP,fr;q=0.5,en_US;q=0.3', ['en_US', 'fr_FR']]; yield '"fr_FR" is selected as "fr_CA" is a similar dialect and has a greater "q" compared to "en_US" (2)' => ['fr_FR', 'ja-JP,fr_CA;q=0.7,ru-ru;q=0.3', ['en_US', 'fr_FR']]; - yield '"en_US" is selected it is an exact match' => ['en_US', 'ja-JP,fr;q=0.5,en_US;q=0.3', ['en_US', 'fr_FR']]; yield '"fr_FR" is selected as "fr_CA" is a similar dialect and has a greater "q" compared to "en"' => ['fr_FR', 'ja-JP,fr_CA;q=0.7,en;q=0.5', ['en_US', 'fr_FR']]; yield '"fr_FR" is selected as is is an exact match as well as "en_US", but with a greater "q" parameter' => ['fr_FR', 'en-us;q=0.5,fr-fr', ['en_US', 'fr_FR']]; yield '"hi_IN" is selected as "hi_Latn_IN" is a similar dialect' => ['hi_IN', 'fr-fr,hi_Latn_IN;q=0.5', ['hi_IN', 'en_US']]; @@ -2239,7 +2238,7 @@ public function testFactory() public function testFactoryCallable() { - $requestFactory = new class() { + $requestFactory = new class { public function createRequest(): Request { return new NewRequest(); @@ -2418,6 +2417,8 @@ public static function protocolVersionProvider() 'trusted with via and protocol name' => ['HTTP/2.0', true, 'HTTP/1.0 fred, HTTP/1.1 nowhere.com (Apache/1.1)', 'HTTP/1.0'], 'trusted with broken via' => ['HTTP/2.0', true, 'HTTP/1^0 foo', 'HTTP/2.0'], 'trusted with partially-broken via' => ['HTTP/2.0', true, '1.0 fred, foo', 'HTTP/1.0'], + 'trusted with simple via' => ['HTTP/2.0', true, 'HTTP/1.0', 'HTTP/1.0'], + 'trusted with only version via' => ['HTTP/2.0', true, '1.0', 'HTTP/1.0'], ]; } @@ -2590,6 +2591,26 @@ public function testTrustedProxiesRemoteAddr($serverRemoteAddr, $trustedProxies, $this->assertSame($result, Request::getTrustedProxies()); } + public static function trustedProxiesRemoteAddr() + { + return [ + ['1.1.1.1', ['REMOTE_ADDR'], ['1.1.1.1']], + ['1.1.1.1', ['REMOTE_ADDR', '2.2.2.2'], ['1.1.1.1', '2.2.2.2']], + [null, ['REMOTE_ADDR'], []], + [null, ['REMOTE_ADDR', '2.2.2.2'], ['2.2.2.2']], + ]; + } + + /** + * @testWith ["PRIVATE_SUBNETS"] + * ["private_ranges"] + */ + public function testTrustedProxiesPrivateSubnets(string $key) + { + Request::setTrustedProxies([$key], Request::HEADER_X_FORWARDED_FOR); + $this->assertSame(IpUtils::PRIVATE_SUBNETS, Request::getTrustedProxies()); + } + public function testTrustedValuesCache() { $request = Request::create('http://example.com/'); @@ -2607,16 +2628,6 @@ public function testTrustedValuesCache() $this->assertFalse($request->isSecure()); } - public static function trustedProxiesRemoteAddr() - { - return [ - ['1.1.1.1', ['REMOTE_ADDR'], ['1.1.1.1']], - ['1.1.1.1', ['REMOTE_ADDR', '2.2.2.2'], ['1.1.1.1', '2.2.2.2']], - [null, ['REMOTE_ADDR'], []], - [null, ['REMOTE_ADDR', '2.2.2.2'], ['2.2.2.2']], - ]; - } - /** * @dataProvider preferSafeContentData */ @@ -2678,7 +2689,7 @@ public static function preferSafeContentData() public function testReservedFlags() { foreach ((new \ReflectionClass(Request::class))->getConstants() as $constant => $value) { - $this->assertNotSame(0b10000000, $value, sprintf('The constant "%s" should not use the reserved value "0b10000000".', $constant)); + $this->assertNotSame(0b10000000, $value, \sprintf('The constant "%s" should not use the reserved value "0b10000000".', $constant)); } } } diff --git a/Tests/ResponseFunctionalTest.php b/Tests/ResponseFunctionalTest.php index 1b3566a2c..e5c6c2428 100644 --- a/Tests/ResponseFunctionalTest.php +++ b/Tests/ResponseFunctionalTest.php @@ -45,9 +45,9 @@ public static function tearDownAfterClass(): void */ public function testCookie($fixture) { - $result = file_get_contents(sprintf('http://localhost:8054/%s.php', $fixture)); + $result = file_get_contents(\sprintf('http://localhost:8054/%s.php', $fixture)); $result = preg_replace_callback('/expires=[^;]++/', fn ($m) => str_replace('-', ' ', $m[0]), $result); - $this->assertStringMatchesFormatFile(__DIR__.sprintf('/Fixtures/response-functional/%s.expected', $fixture), $result); + $this->assertStringMatchesFormatFile(__DIR__.\sprintf('/Fixtures/response-functional/%s.expected', $fixture), $result); } public static function provideCookie() diff --git a/Tests/ResponseTest.php b/Tests/ResponseTest.php index 007842476..2c761a4f8 100644 --- a/Tests/ResponseTest.php +++ b/Tests/ResponseTest.php @@ -127,7 +127,7 @@ public function testSetNotModified() ob_start(); $modified->sendContent(); $string = ob_get_clean(); - $this->assertEmpty($string); + $this->assertSame('', $string); } public function testIsSuccessful() @@ -181,7 +181,7 @@ public function testIsNotModifiedEtag() $etagTwo = 'randomly_generated_etag_2'; $request = new Request(); - $request->headers->set('If-None-Match', sprintf('%s, %s, %s', $etagOne, $etagTwo, 'etagThree')); + $request->headers->set('If-None-Match', \sprintf('%s, %s, %s', $etagOne, $etagTwo, 'etagThree')); $response = new Response(); @@ -235,7 +235,7 @@ public function testIsNotModifiedLastModifiedAndEtag() $etag = 'randomly_generated_etag'; $request = new Request(); - $request->headers->set('If-None-Match', sprintf('%s, %s', $etag, 'etagThree')); + $request->headers->set('If-None-Match', \sprintf('%s, %s', $etag, 'etagThree')); $request->headers->set('If-Modified-Since', $modified); $response = new Response(); @@ -259,7 +259,7 @@ public function testIsNotModifiedIfModifiedSinceAndEtagWithoutLastModified() $etag = 'randomly_generated_etag'; $request = new Request(); - $request->headers->set('If-None-Match', sprintf('%s, %s', $etag, 'etagThree')); + $request->headers->set('If-None-Match', \sprintf('%s, %s', $etag, 'etagThree')); $request->headers->set('If-Modified-Since', $modified); $response = new Response(); diff --git a/Tests/Session/Flash/AutoExpireFlashBagTest.php b/Tests/Session/Flash/AutoExpireFlashBagTest.php index 6a6510a57..8e9ee780b 100644 --- a/Tests/Session/Flash/AutoExpireFlashBagTest.php +++ b/Tests/Session/Flash/AutoExpireFlashBagTest.php @@ -27,7 +27,6 @@ class AutoExpireFlashBagTest extends TestCase protected function setUp(): void { - parent::setUp(); $this->bag = new FlashBag(); $this->array = ['new' => ['notice' => ['A previous flash message']]]; $this->bag->initialize($this->array); @@ -36,7 +35,6 @@ protected function setUp(): void protected function tearDown(): void { unset($this->bag); - parent::tearDown(); } public function testInitialize() @@ -46,9 +44,9 @@ public function testInitialize() $bag->initialize($array); $this->assertEquals(['A previous flash message'], $bag->peek('notice')); $array = ['new' => [ - 'notice' => ['Something else'], - 'error' => ['a'], - ]]; + 'notice' => ['Something else'], + 'error' => ['a'], + ]]; $bag->initialize($array); $this->assertEquals(['Something else'], $bag->peek('notice')); $this->assertEquals(['a'], $bag->peek('error')); @@ -106,13 +104,13 @@ public function testPeekAll() $this->assertEquals([ 'notice' => 'Foo', 'error' => 'Bar', - ], $this->bag->peekAll() + ], $this->bag->peekAll() ); $this->assertEquals([ 'notice' => 'Foo', 'error' => 'Bar', - ], $this->bag->peekAll() + ], $this->bag->peekAll() ); } @@ -137,7 +135,7 @@ public function testAll() $this->bag->set('error', 'Bar'); $this->assertEquals([ 'notice' => ['A previous flash message'], - ], $this->bag->all() + ], $this->bag->all() ); $this->assertEquals([], $this->bag->all()); diff --git a/Tests/Session/Flash/FlashBagTest.php b/Tests/Session/Flash/FlashBagTest.php index 59e3f1f0e..efe9a4977 100644 --- a/Tests/Session/Flash/FlashBagTest.php +++ b/Tests/Session/Flash/FlashBagTest.php @@ -27,7 +27,6 @@ class FlashBagTest extends TestCase protected function setUp(): void { - parent::setUp(); $this->bag = new FlashBag(); $this->array = ['notice' => ['A previous flash message']]; $this->bag->initialize($this->array); @@ -36,7 +35,6 @@ protected function setUp(): void protected function tearDown(): void { unset($this->bag); - parent::tearDown(); } public function testInitialize() @@ -141,14 +139,14 @@ public function testPeekAll() $this->assertEquals([ 'notice' => ['Foo'], 'error' => ['Bar'], - ], $this->bag->peekAll() + ], $this->bag->peekAll() ); $this->assertTrue($this->bag->has('notice')); $this->assertTrue($this->bag->has('error')); $this->assertEquals([ 'notice' => ['Foo'], 'error' => ['Bar'], - ], $this->bag->peekAll() + ], $this->bag->peekAll() ); } } diff --git a/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php b/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php index a582909a0..ba489bdea 100644 --- a/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php +++ b/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php @@ -16,8 +16,6 @@ use Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler; /** - * @requires extension redis - * * @group time-sensitive */ abstract class AbstractRedisSessionHandlerTestCase extends TestCase @@ -31,8 +29,6 @@ abstract protected function createRedisClient(string $host): \Redis|Relay|\Redis protected function setUp(): void { - parent::setUp(); - if (!\extension_loaded('redis')) { self::markTestSkipped('Extension redis required.'); } diff --git a/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php b/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php index 27fb57da4..361d3b15d 100644 --- a/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php @@ -45,10 +45,10 @@ public function testSession($fixture) { $context = ['http' => ['header' => "Cookie: sid=123abc\r\n"]]; $context = stream_context_create($context); - $result = file_get_contents(sprintf('http://localhost:8053/%s.php', $fixture), false, $context); + $result = file_get_contents(\sprintf('http://localhost:8053/%s.php', $fixture), false, $context); $result = preg_replace_callback('/expires=[^;]++/', fn ($m) => str_replace('-', ' ', $m[0]), $result); - $this->assertStringEqualsFile(__DIR__.sprintf('/Fixtures/%s.expected', $fixture), $result); + $this->assertStringEqualsFile(__DIR__.\sprintf('/Fixtures/%s.expected', $fixture), $result); } public static function provideSession() diff --git a/Tests/Session/Storage/Handler/Fixtures/common.inc b/Tests/Session/Storage/Handler/Fixtures/common.inc index 5f48d42cb..7aaedf7f8 100644 --- a/Tests/Session/Storage/Handler/Fixtures/common.inc +++ b/Tests/Session/Storage/Handler/Fixtures/common.inc @@ -28,7 +28,6 @@ ini_set('session.cookie_domain', ''); ini_set('session.cookie_secure', ''); ini_set('session.cookie_httponly', ''); ini_set('session.use_cookies', 1); -ini_set('session.use_only_cookies', 1); ini_set('session.cache_expire', 180); ini_set('session.cookie_path', '/'); ini_set('session.cookie_domain', ''); diff --git a/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php b/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php index 60bae22c3..3580bbd9b 100644 --- a/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php @@ -30,8 +30,6 @@ class MemcachedSessionHandlerTest extends TestCase protected function setUp(): void { - parent::setUp(); - if (version_compare(phpversion('memcached'), '2.2.0', '>=') && version_compare(phpversion('memcached'), '3.0.0b1', '<')) { $this->markTestSkipped('Tests can only be run with memcached extension 2.1.0 or lower, or 3.0.0b1 or higher'); } diff --git a/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php b/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php index 04dfdbef8..fa07bd36c 100644 --- a/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php @@ -44,14 +44,12 @@ class MongoDbSessionHandlerTest extends TestCase protected function setUp(): void { - parent::setUp(); - $this->manager = new Manager('mongodb://'.getenv('MONGODB_HOST')); try { $this->manager->executeCommand(self::DABASE_NAME, new Command(['ping' => 1])); } catch (ConnectionException $e) { - $this->markTestSkipped(sprintf('MongoDB Server "%s" not running: %s', getenv('MONGODB_HOST'), $e->getMessage())); + $this->markTestSkipped(\sprintf('MongoDB Server "%s" not running: %s', getenv('MONGODB_HOST'), $e->getMessage())); } $this->options = [ diff --git a/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php b/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php index 55a238732..0ee76ae0b 100644 --- a/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php @@ -30,7 +30,6 @@ protected function tearDown(): void if ($this->dbFile) { @unlink($this->dbFile); } - parent::tearDown(); } protected function getPersistentSqliteDsn() @@ -353,7 +352,7 @@ public function testConfigureSchemaTableExistsPdo() $pdoSessionHandler = new PdoSessionHandler($this->getMemorySqlitePdo()); $pdoSessionHandler->configureSchema($schema, fn () => true); $table = $schema->getTable('sessions'); - $this->assertEmpty($table->getColumns(), 'The table was not overwritten'); + $this->assertSame([], $table->getColumns(), 'The table was not overwritten'); } public static function provideUrlDsnPairs() diff --git a/Tests/Session/Storage/MetadataBagTest.php b/Tests/Session/Storage/MetadataBagTest.php index b2f3de42b..3acdcfcac 100644 --- a/Tests/Session/Storage/MetadataBagTest.php +++ b/Tests/Session/Storage/MetadataBagTest.php @@ -26,7 +26,6 @@ class MetadataBagTest extends TestCase protected function setUp(): void { - parent::setUp(); $this->bag = new MetadataBag(); $this->array = [MetadataBag::CREATED => 1234567, MetadataBag::UPDATED => 12345678, MetadataBag::LIFETIME => 0]; $this->bag->initialize($this->array); diff --git a/Tests/Session/Storage/NativeSessionStorageTest.php b/Tests/Session/Storage/NativeSessionStorageTest.php index a0d54deb7..11c489f6b 100644 --- a/Tests/Session/Storage/NativeSessionStorageTest.php +++ b/Tests/Session/Storage/NativeSessionStorageTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\HttpFoundation\Session\Attribute\AttributeBag; use Symfony\Component\HttpFoundation\Session\Flash\FlashBag; use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler; @@ -32,6 +33,8 @@ */ class NativeSessionStorageTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + private string $savePath; private $initialSessionSaveHandler; @@ -215,10 +218,14 @@ public function testCacheExpireOption() } /** + * @group legacy + * * The test must only be removed when the "session.trans_sid_tags" option is removed from PHP or when the "trans_sid_tags" option is no longer supported by the native session storage. */ public function testTransSidTagsOption() { + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "trans_sid_tags" option is deprecated and will be ignored in Symfony 8.0.'); + $previousErrorHandler = set_error_handler(function ($errno, $errstr) use (&$previousErrorHandler) { if ('ini_set(): Usage of session.trans_sid_tags INI setting is deprecated' !== $errstr) { return $previousErrorHandler ? $previousErrorHandler(...\func_get_args()) : false; @@ -357,4 +364,28 @@ public function testSaveHandlesNullSessionGracefully() $this->addToAssertionCount(1); } + + /** + * @group legacy + */ + public function testPassingDeprecatedOptions() + { + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "sid_length" option is deprecated and will be ignored in Symfony 8.0.'); + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "sid_bits_per_character" option is deprecated and will be ignored in Symfony 8.0.'); + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "referer_check" option is deprecated and will be ignored in Symfony 8.0.'); + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "use_only_cookies" option is deprecated and will be ignored in Symfony 8.0.'); + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "use_trans_sid" option is deprecated and will be ignored in Symfony 8.0.'); + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "trans_sid_hosts" option is deprecated and will be ignored in Symfony 8.0.'); + $this->expectUserDeprecationMessage('Since symfony/http-foundation 7.2: NativeSessionStorage\'s "trans_sid_tags" option is deprecated and will be ignored in Symfony 8.0.'); + + $this->getStorage([ + 'sid_length' => 42, + 'sid_bits_per_character' => 6, + 'referer_check' => 'foo', + 'use_only_cookies' => 'foo', + 'use_trans_sid' => 'foo', + 'trans_sid_hosts' => 'foo', + 'trans_sid_tags' => 'foo', + ]); + } } diff --git a/Tests/Session/Storage/Proxy/AbstractProxyTest.php b/Tests/Session/Storage/Proxy/AbstractProxyTest.php index bb459bb9f..9551d52b4 100644 --- a/Tests/Session/Storage/Proxy/AbstractProxyTest.php +++ b/Tests/Session/Storage/Proxy/AbstractProxyTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Proxy; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy; use Symfony\Component\HttpFoundation\Session\Storage\Proxy\SessionHandlerProxy; @@ -27,7 +26,7 @@ class AbstractProxyTest extends TestCase protected function setUp(): void { - $this->proxy = new class() extends AbstractProxy {}; + $this->proxy = new class extends AbstractProxy {}; } public function testGetSaveHandlerName() diff --git a/Tests/StreamedJsonResponseTest.php b/Tests/StreamedJsonResponseTest.php index db76cd3ae..3d94aa078 100644 --- a/Tests/StreamedJsonResponseTest.php +++ b/Tests/StreamedJsonResponseTest.php @@ -132,14 +132,14 @@ public function testResponseOtherTraversable() { $arrayObject = new \ArrayObject(['__symfony_json__' => '__symfony_json__']); - $iteratorAggregate = new class() implements \IteratorAggregate { + $iteratorAggregate = new class implements \IteratorAggregate { public function getIterator(): \Traversable { return new \ArrayIterator(['__symfony_json__']); } }; - $jsonSerializable = new class() implements \IteratorAggregate, \JsonSerializable { + $jsonSerializable = new class implements \IteratorAggregate, \JsonSerializable { public function getIterator(): \Traversable { return new \ArrayIterator(['This should be ignored']); @@ -187,7 +187,7 @@ public function testResponseStatusCode() public function testPlaceholderAsObjectStructure() { - $object = new class() { + $object = new class { public $__symfony_json__ = 'foo'; public $bar = '__symfony_json__'; }; diff --git a/Tests/StreamedResponseTest.php b/Tests/StreamedResponseTest.php index 2a2b7e731..fdaee3a35 100644 --- a/Tests/StreamedResponseTest.php +++ b/Tests/StreamedResponseTest.php @@ -25,6 +25,21 @@ public function testConstructor() $this->assertEquals('text/plain', $response->headers->get('Content-Type')); } + public function testConstructorWithChunks() + { + $chunks = ['foo', 'bar', 'baz']; + $callback = (new StreamedResponse($chunks))->getCallback(); + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer) { + $buffer .= $chunk; + }); + $callback(); + + ob_get_clean(); + $this->assertSame('foobarbaz', $buffer); + } + public function testPrepareWith11Protocol() { $response = new StreamedResponse(function () { echo 'foo'; }); @@ -122,7 +137,7 @@ public function testSetNotModified() ob_start(); $modified->sendContent(); $string = ob_get_clean(); - $this->assertEmpty($string); + $this->assertSame('', $string); } public function testSendInformationalResponse() diff --git a/Tests/Test/Constraint/RequestAttributeValueSameTest.php b/Tests/Test/Constraint/RequestAttributeValueSameTest.php index c7ee239f3..27dbd895e 100644 --- a/Tests/Test/Constraint/RequestAttributeValueSameTest.php +++ b/Tests/Test/Constraint/RequestAttributeValueSameTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Test\Constraint\RequestAttributeValueSame; @@ -28,14 +27,9 @@ public function testConstraint() $constraint = new RequestAttributeValueSame('bar', 'foo'); $this->assertFalse($constraint->evaluate($request, '', true)); - try { - $constraint->evaluate($request); - } catch (ExpectationFailedException $e) { - $this->assertEquals("Failed asserting that the Request has attribute \"bar\" with value \"foo\".\n", TestFailure::exceptionToString($e)); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that the Request has attribute "bar" with value "foo".'); - return; - } - - $this->fail(); + $constraint->evaluate($request); } } diff --git a/Tests/Test/Constraint/ResponseCookieValueSameTest.php b/Tests/Test/Constraint/ResponseCookieValueSameTest.php index 1b68b20bd..e3ad61000 100644 --- a/Tests/Test/Constraint/ResponseCookieValueSameTest.php +++ b/Tests/Test/Constraint/ResponseCookieValueSameTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseCookieValueSame; @@ -31,15 +30,10 @@ public function testConstraint() $constraint = new ResponseCookieValueSame('foo', 'babar', '/path'); $this->assertFalse($constraint->evaluate($response, '', true)); - try { - $constraint->evaluate($response); - } catch (ExpectationFailedException $e) { - $this->assertEquals("Failed asserting that the Response has cookie \"foo\" with path \"/path\" with value \"babar\".\n", TestFailure::exceptionToString($e)); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that the Response has cookie "foo" with path "/path" with value "babar".'); - return; - } - - $this->fail(); + $constraint->evaluate($response); } public function testCookieWithNullValueIsComparedAsEmptyString() diff --git a/Tests/Test/Constraint/ResponseFormatSameTest.php b/Tests/Test/Constraint/ResponseFormatSameTest.php index aed9285f2..9ac6a1cf5 100644 --- a/Tests/Test/Constraint/ResponseFormatSameTest.php +++ b/Tests/Test/Constraint/ResponseFormatSameTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseFormatSame; @@ -32,15 +31,10 @@ public function testConstraint() $this->assertTrue($constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/vnd.myformat']), '', true)); $this->assertFalse($constraint->evaluate(new Response(), '', true)); - try { - $constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/ld+json'])); - } catch (ExpectationFailedException $e) { - $this->assertStringContainsString("Failed asserting that the Response format is custom.\nHTTP/1.0 200 OK", TestFailure::exceptionToString($e)); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage("Failed asserting that the Response format is custom.\nHTTP/1.0 200 OK"); - return; - } - - $this->fail(); + $constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/ld+json'])); } public function testNullFormat() @@ -48,14 +42,9 @@ public function testNullFormat() $constraint = new ResponseFormatSame(new Request(), null); $this->assertTrue($constraint->evaluate(new Response(), '', true)); - try { - $constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/ld+json'])); - } catch (ExpectationFailedException $e) { - $this->assertStringContainsString("Failed asserting that the Response format is null.\nHTTP/1.0 200 OK", TestFailure::exceptionToString($e)); - - return; - } + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage("Failed asserting that the Response format is null.\nHTTP/1.0 200 OK"); - $this->fail(); + $constraint->evaluate(new Response('', 200, ['Content-Type' => 'application/ld+json'])); } } diff --git a/Tests/Test/Constraint/ResponseHasCookieTest.php b/Tests/Test/Constraint/ResponseHasCookieTest.php index ba1d7f38a..380502aba 100644 --- a/Tests/Test/Constraint/ResponseHasCookieTest.php +++ b/Tests/Test/Constraint/ResponseHasCookieTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHasCookie; @@ -29,14 +28,9 @@ public function testConstraint() $constraint = new ResponseHasCookie('bar'); $this->assertFalse($constraint->evaluate($response, '', true)); - try { - $constraint->evaluate($response); - } catch (ExpectationFailedException $e) { - $this->assertEquals("Failed asserting that the Response has cookie \"bar\".\n", TestFailure::exceptionToString($e)); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that the Response has cookie "bar".'); - return; - } - - $this->fail(); + $constraint->evaluate($response); } } diff --git a/Tests/Test/Constraint/ResponseHasHeaderTest.php b/Tests/Test/Constraint/ResponseHasHeaderTest.php index 9a8fc2560..5959451eb 100644 --- a/Tests/Test/Constraint/ResponseHasHeaderTest.php +++ b/Tests/Test/Constraint/ResponseHasHeaderTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHasHeader; @@ -26,14 +25,9 @@ public function testConstraint() $constraint = new ResponseHasHeader('X-Date'); $this->assertFalse($constraint->evaluate(new Response(), '', true)); - try { - $constraint->evaluate(new Response()); - } catch (ExpectationFailedException $e) { - $this->assertEquals("Failed asserting that the Response has header \"X-Date\".\n", TestFailure::exceptionToString($e)); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that the Response has header "X-Date".'); - return; - } - - $this->fail(); + $constraint->evaluate(new Response()); } } diff --git a/Tests/Test/Constraint/ResponseHeaderSameTest.php b/Tests/Test/Constraint/ResponseHeaderSameTest.php index 17a3f2a99..7c8b4a80b 100644 --- a/Tests/Test/Constraint/ResponseHeaderSameTest.php +++ b/Tests/Test/Constraint/ResponseHeaderSameTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHeaderSame; @@ -26,14 +25,9 @@ public function testConstraint() $constraint = new ResponseHeaderSame('Cache-Control', 'public'); $this->assertFalse($constraint->evaluate(new Response(), '', true)); - try { - $constraint->evaluate(new Response()); - } catch (ExpectationFailedException $e) { - $this->assertEquals("Failed asserting that the Response has header \"Cache-Control\" with value \"public\".\n", TestFailure::exceptionToString($e)); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that the Response has header "Cache-Control" with value "public".'); - return; - } - - $this->fail(); + $constraint->evaluate(new Response()); } } diff --git a/Tests/Test/Constraint/ResponseIsRedirectedTest.php b/Tests/Test/Constraint/ResponseIsRedirectedTest.php index c4df3bc93..7e011ca10 100644 --- a/Tests/Test/Constraint/ResponseIsRedirectedTest.php +++ b/Tests/Test/Constraint/ResponseIsRedirectedTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsRedirected; @@ -26,17 +25,10 @@ public function testConstraint() $this->assertTrue($constraint->evaluate(new Response('', 301), '', true)); $this->assertFalse($constraint->evaluate(new Response(), '', true)); - try { - $constraint->evaluate(new Response('Body content')); - } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response is redirected.\nHTTP/1.0 200 OK", $exceptionMessage); - $this->assertStringContainsString('Body content', $exceptionMessage); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessageMatches('/Failed asserting that the Response is redirected.\nHTTP\/1.0 200 OK.+Body content/s'); - return; - } - - $this->fail(); + $constraint->evaluate(new Response('Body content')); } public function testReducedVerbosity() @@ -45,9 +37,8 @@ public function testReducedVerbosity() try { $constraint->evaluate(new Response('Body content')); } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response is redirected.\nHTTP/1.0 200 OK", $exceptionMessage); - $this->assertStringNotContainsString('Body content', $exceptionMessage); + $this->assertStringContainsString("Failed asserting that the Response is redirected.\nHTTP/1.0 200 OK", $e->getMessage()); + $this->assertStringNotContainsString('Body content', $e->getMessage()); return; } diff --git a/Tests/Test/Constraint/ResponseIsSuccessfulTest.php b/Tests/Test/Constraint/ResponseIsSuccessfulTest.php index 9ecafae7e..18e1c5e30 100644 --- a/Tests/Test/Constraint/ResponseIsSuccessfulTest.php +++ b/Tests/Test/Constraint/ResponseIsSuccessfulTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsSuccessful; @@ -26,17 +25,10 @@ public function testConstraint() $this->assertTrue($constraint->evaluate(new Response(), '', true)); $this->assertFalse($constraint->evaluate(new Response('', 404), '', true)); - try { - $constraint->evaluate(new Response('Response body', 404)); - } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response is successful.\nHTTP/1.0 404 Not Found", $exceptionMessage); - $this->assertStringContainsString('Response body', $exceptionMessage); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessageMatches('/Failed asserting that the Response is successful.\nHTTP\/1.0 404 Not Found.+Response body/s'); - return; - } - - $this->fail(); + $constraint->evaluate(new Response('Response body', 404)); } public function testReducedVerbosity() @@ -46,9 +38,8 @@ public function testReducedVerbosity() try { $constraint->evaluate(new Response('Response body', 404)); } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response is successful.\nHTTP/1.0 404 Not Found", $exceptionMessage); - $this->assertStringNotContainsString('Response body', $exceptionMessage); + $this->assertStringContainsString("Failed asserting that the Response is successful.\nHTTP/1.0 404 Not Found", $e->getMessage()); + $this->assertStringNotContainsString('Response body', $e->getMessage()); return; } diff --git a/Tests/Test/Constraint/ResponseIsUnprocessableTest.php b/Tests/Test/Constraint/ResponseIsUnprocessableTest.php index a142ec4b0..38454c1d0 100644 --- a/Tests/Test/Constraint/ResponseIsUnprocessableTest.php +++ b/Tests/Test/Constraint/ResponseIsUnprocessableTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsUnprocessable; @@ -26,17 +25,10 @@ public function testConstraint() $this->assertTrue($constraint->evaluate(new Response('', 422), '', true)); $this->assertFalse($constraint->evaluate(new Response(), '', true)); - try { - $constraint->evaluate(new Response('Response body')); - } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response is unprocessable.\nHTTP/1.0 200 OK", $exceptionMessage); - $this->assertStringContainsString('Response body', $exceptionMessage); + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessageMatches('/Failed asserting that the Response is unprocessable.\nHTTP\/1.0 200 OK.+Response body/s'); - return; - } - - $this->fail(); + $constraint->evaluate(new Response('Response body')); } public function testReducedVerbosity() @@ -46,9 +38,8 @@ public function testReducedVerbosity() try { $constraint->evaluate(new Response('Response body')); } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response is unprocessable.\nHTTP/1.0 200 OK", $exceptionMessage); - $this->assertStringNotContainsString('Response body', $exceptionMessage); + $this->assertStringContainsString("Failed asserting that the Response is unprocessable.\nHTTP/1.0 200 OK", $e->getMessage()); + $this->assertStringNotContainsString('Response body', $e->getMessage()); return; } diff --git a/Tests/Test/Constraint/ResponseStatusCodeSameTest.php b/Tests/Test/Constraint/ResponseStatusCodeSameTest.php index bde8ebe28..acd067dce 100644 --- a/Tests/Test/Constraint/ResponseStatusCodeSameTest.php +++ b/Tests/Test/Constraint/ResponseStatusCodeSameTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestFailure; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseStatusCodeSame; @@ -28,17 +27,11 @@ public function testConstraint() $this->assertTrue($constraint->evaluate(new Response('', 404), '', true)); $constraint = new ResponseStatusCodeSame(200); - try { - $constraint->evaluate(new Response('Response body', 404)); - } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response status code is 200.\nHTTP/1.0 404 Not Found", TestFailure::exceptionToString($e)); - $this->assertStringContainsString('Response body', $exceptionMessage); - return; - } + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessageMatches('/Failed asserting that the Response status code is 200.\nHTTP\/1.0 404 Not Found.+Response body/s'); - $this->fail(); + $constraint->evaluate(new Response('Response body', 404)); } public function testReducedVerbosity() @@ -48,9 +41,8 @@ public function testReducedVerbosity() try { $constraint->evaluate(new Response('Response body', 404)); } catch (ExpectationFailedException $e) { - $exceptionMessage = TestFailure::exceptionToString($e); - $this->assertStringContainsString("Failed asserting that the Response status code is 200.\nHTTP/1.0 404 Not Found", TestFailure::exceptionToString($e)); - $this->assertStringNotContainsString('Response body', $exceptionMessage); + $this->assertStringContainsString("Failed asserting that the Response status code is 200.\nHTTP/1.0 404 Not Found", $e->getMessage()); + $this->assertStringNotContainsString('Response body', $e->getMessage()); return; } diff --git a/Tests/UriSignerTest.php b/Tests/UriSignerTest.php index e39c0a292..81b35c28e 100644 --- a/Tests/UriSignerTest.php +++ b/Tests/UriSignerTest.php @@ -12,7 +12,11 @@ namespace Symfony\Component\HttpFoundation\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\HttpFoundation\Exception\ExpiredSignedUriException; use Symfony\Component\HttpFoundation\Exception\LogicException; +use Symfony\Component\HttpFoundation\Exception\UnsignedUriException; +use Symfony\Component\HttpFoundation\Exception\UnverifiedSignedUriException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\UriSigner; @@ -64,20 +68,25 @@ public function testCheck() public function testCheckWithDifferentArgSeparator() { - $this->iniSet('arg_separator.output', '&'); - $signer = new UriSigner('foobar'); - - $this->assertSame( - 'http://example.com/foo?_hash=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D&baz=bay&foo=bar', - $signer->sign('http://example.com/foo?foo=bar&baz=bay') - ); - $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); - - $this->assertSame( - 'http://example.com/foo?_expiration=2145916800&_hash=xLhnPMzV3KqqHaaUffBUJvtRDAZ4%2FZ9Y8Sw%2BgmS%2B82Q%3D&baz=bay&foo=bar', - $signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2038-01-01 00:00:00', new \DateTimeZone('UTC'))) - ); - $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2099-01-01 00:00:00')))); + $oldArgSeparatorOutputValue = ini_set('arg_separator.output', '&'); + + try { + $signer = new UriSigner('foobar'); + + $this->assertSame( + 'http://example.com/foo?_hash=rIOcC_F3DoEGo_vnESjSp7uU9zA9S_-OLhxgMexoPUM&baz=bay&foo=bar', + $signer->sign('http://example.com/foo?foo=bar&baz=bay') + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); + + $this->assertSame( + 'http://example.com/foo?_expiration=2145916800&_hash=xLhnPMzV3KqqHaaUffBUJvtRDAZ4_Z9Y8Sw-gmS-82Q&baz=bay&foo=bar', + $signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2038-01-01 00:00:00', new \DateTimeZone('UTC'))) + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2099-01-01 00:00:00')))); + } finally { + ini_set('arg_separator.output', $oldArgSeparatorOutputValue); + } } public function testCheckWithRequest() @@ -98,13 +107,13 @@ public function testCheckWithDifferentParameter() $signer = new UriSigner('foobar', 'qux', 'abc'); $this->assertSame( - 'http://example.com/foo?baz=bay&foo=bar&qux=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D', + 'http://example.com/foo?baz=bay&foo=bar&qux=rIOcC_F3DoEGo_vnESjSp7uU9zA9S_-OLhxgMexoPUM', $signer->sign('http://example.com/foo?foo=bar&baz=bay') ); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); $this->assertSame( - 'http://example.com/foo?abc=2145916800&baz=bay&foo=bar&qux=kE4rK2MzeiwrYAKy%2B%2FGKvKA6bnzqCbACBdpC3yGnPVU%3D', + 'http://example.com/foo?abc=2145916800&baz=bay&foo=bar&qux=kE4rK2MzeiwrYAKy-_GKvKA6bnzqCbACBdpC3yGnPVU', $signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2038-01-01 00:00:00', new \DateTimeZone('UTC'))) ); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2099-01-01 00:00:00')))); @@ -115,14 +124,14 @@ public function testSignerWorksWithFragments() $signer = new UriSigner('foobar'); $this->assertSame( - 'http://example.com/foo?_hash=EhpAUyEobiM3QTrKxoLOtQq5IsWyWedoXDPqIjzNj5o%3D&bar=foo&foo=bar#foobar', + 'http://example.com/foo?_hash=EhpAUyEobiM3QTrKxoLOtQq5IsWyWedoXDPqIjzNj5o&bar=foo&foo=bar#foobar', $signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar') ); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar'))); $this->assertSame( - 'http://example.com/foo?_expiration=2145916800&_hash=jTdrIE9MJSorNpQmkX6tmOtocxXtHDzIJawcAW4IFYo%3D&bar=foo&foo=bar#foobar', + 'http://example.com/foo?_expiration=2145916800&_hash=jTdrIE9MJSorNpQmkX6tmOtocxXtHDzIJawcAW4IFYo&bar=foo&foo=bar#foobar', $signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar', new \DateTimeImmutable('2038-01-01 00:00:00', new \DateTimeZone('UTC'))) ); @@ -193,4 +202,63 @@ public function testCheckWithUriExpiration() $this->assertFalse($signer->check($relativeUriFromNow2)); $this->assertFalse($signer->check($relativeUriFromNow3)); } + + public function testCheckWithUriExpirationWithClock() + { + $clock = new MockClock(); + $signer = new UriSigner('foobar', clock: $clock); + + $this->assertFalse($signer->check($signer->sign('http://example.com/foo', new \DateTimeImmutable('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar', new \DateTimeImmutable('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTimeImmutable('2000-01-01 00:00:00')))); + + $this->assertFalse($signer->check($signer->sign('http://example.com/foo', 1577836800))); // 2000-01-01 + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar', 1577836800))); // 2000-01-01 + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', 1577836800))); // 2000-01-01 + + $relativeUriFromNow1 = $signer->sign('http://example.com/foo', new \DateInterval('PT3S')); + $relativeUriFromNow2 = $signer->sign('http://example.com/foo?foo=bar', new \DateInterval('PT3S')); + $relativeUriFromNow3 = $signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateInterval('PT3S')); + $clock->sleep(10); + + $this->assertFalse($signer->check($relativeUriFromNow1)); + $this->assertFalse($signer->check($relativeUriFromNow2)); + $this->assertFalse($signer->check($relativeUriFromNow3)); + } + + public function testNonUrlSafeBase64() + { + $signer = new UriSigner('foobar'); + $this->assertTrue($signer->check('http://example.com/foo?_hash=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D&baz=bay&foo=bar')); + } + + public function testVerifyUnSignedUri() + { + $signer = new UriSigner('foobar'); + $uri = 'http://example.com/foo'; + + $this->expectException(UnsignedUriException::class); + + $signer->verify($uri); + } + + public function testVerifyUnverifiedUri() + { + $signer = new UriSigner('foobar'); + $uri = 'http://example.com/foo?_hash=invalid'; + + $this->expectException(UnverifiedSignedUriException::class); + + $signer->verify($uri); + } + + public function testVerifyExpiredUri() + { + $signer = new UriSigner('foobar'); + $uri = $signer->sign('http://example.com/foo', 123456); + + $this->expectException(ExpiredSignedUriException::class); + + $signer->verify($uri); + } } diff --git a/UriSigner.php b/UriSigner.php index 4415026b1..bb870e43c 100644 --- a/UriSigner.php +++ b/UriSigner.php @@ -11,30 +11,36 @@ namespace Symfony\Component\HttpFoundation; +use Psr\Clock\ClockInterface; +use Symfony\Component\HttpFoundation\Exception\ExpiredSignedUriException; use Symfony\Component\HttpFoundation\Exception\LogicException; +use Symfony\Component\HttpFoundation\Exception\SignedUriException; +use Symfony\Component\HttpFoundation\Exception\UnsignedUriException; +use Symfony\Component\HttpFoundation\Exception\UnverifiedSignedUriException; /** * @author Fabien Potencier */ class UriSigner { - private string $secret; - private string $hashParameter; - private string $expirationParameter; + private const STATUS_VALID = 1; + private const STATUS_INVALID = 2; + private const STATUS_MISSING = 3; + private const STATUS_EXPIRED = 4; /** * @param string $hashParameter Query string parameter to use * @param string $expirationParameter Query string parameter to use for expiration */ - public function __construct(#[\SensitiveParameter] string $secret, string $hashParameter = '_hash', string $expirationParameter = '_expiration') - { + public function __construct( + #[\SensitiveParameter] private string $secret, + private string $hashParameter = '_hash', + private string $expirationParameter = '_expiration', + private ?ClockInterface $clock = null, + ) { if (!$secret) { throw new \InvalidArgumentException('A non-empty secret is required.'); } - - $this->secret = $secret; - $this->hashParameter = $hashParameter; - $this->expirationParameter = $expirationParameter; } /** @@ -51,7 +57,7 @@ public function __construct(#[\SensitiveParameter] string $secret, string $hashP * * The expiration is added as a query string parameter. */ - public function sign(string $uri/*, \DateTimeInterface|\DateInterval|int|null $expiration = null*/): string + public function sign(string $uri/* , \DateTimeInterface|\DateInterval|int|null $expiration = null */): string { $expiration = null; @@ -60,7 +66,7 @@ public function sign(string $uri/*, \DateTimeInterface|\DateInterval|int|null $e } if (null !== $expiration && !$expiration instanceof \DateTimeInterface && !$expiration instanceof \DateInterval && !\is_int($expiration)) { - throw new \TypeError(sprintf('The second argument of %s() must be an instance of %s or %s, an integer or null (%s given).', __METHOD__, \DateTimeInterface::class, \DateInterval::class, get_debug_type($expiration))); + throw new \TypeError(\sprintf('The second argument of "%s()" must be an instance of "%s" or "%s", an integer or null (%s given).', __METHOD__, \DateTimeInterface::class, \DateInterval::class, get_debug_type($expiration))); } $url = parse_url($uri); @@ -71,11 +77,11 @@ public function sign(string $uri/*, \DateTimeInterface|\DateInterval|int|null $e } if (isset($params[$this->hashParameter])) { - throw new LogicException(sprintf('URI query parameter conflict: parameter name "%s" is reserved.', $this->hashParameter)); + throw new LogicException(\sprintf('URI query parameter conflict: parameter name "%s" is reserved.', $this->hashParameter)); } if (isset($params[$this->expirationParameter])) { - throw new LogicException(sprintf('URI query parameter conflict: parameter name "%s" is reserved.', $this->expirationParameter)); + throw new LogicException(\sprintf('URI query parameter conflict: parameter name "%s" is reserved.', $this->expirationParameter)); } if (null !== $expiration) { @@ -94,42 +100,45 @@ public function sign(string $uri/*, \DateTimeInterface|\DateInterval|int|null $e */ public function check(string $uri): bool { - $url = parse_url($uri); - $params = []; - - if (isset($url['query'])) { - parse_str($url['query'], $params); - } + return self::STATUS_VALID === $this->doVerify($uri); + } - if (empty($params[$this->hashParameter])) { - return false; - } + public function checkRequest(Request $request): bool + { + return self::STATUS_VALID === $this->doVerify(self::normalize($request)); + } - $hash = $params[$this->hashParameter]; - unset($params[$this->hashParameter]); + /** + * Verify a Request or string URI. + * + * @throws UnsignedUriException If the URI is not signed + * @throws UnverifiedSignedUriException If the signature is invalid + * @throws ExpiredSignedUriException If the URI has expired + * @throws SignedUriException + */ + public function verify(Request|string $uri): void + { + $uri = self::normalize($uri); + $status = $this->doVerify($uri); - if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), $hash)) { - return false; + if (self::STATUS_VALID === $status) { + return; } - if ($expiration = $params[$this->expirationParameter] ?? false) { - return time() < $expiration; + if (self::STATUS_MISSING === $status) { + throw new UnsignedUriException(); } - return true; - } - - public function checkRequest(Request $request): bool - { - $qs = ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : ''; + if (self::STATUS_INVALID === $status) { + throw new UnverifiedSignedUriException(); + } - // we cannot use $request->getUri() here as we want to work with the original URI (no query string reordering) - return $this->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$qs); + throw new ExpiredSignedUriException(); } private function computeHash(string $uri): string { - return base64_encode(hash_hmac('sha256', $uri, $this->secret, true)); + return strtr(rtrim(base64_encode(hash_hmac('sha256', $uri, $this->secret, true)), '='), ['/' => '_', '+' => '-']); } private function buildUrl(array $url, array $params = []): string @@ -157,9 +166,58 @@ private function getExpirationTime(\DateTimeInterface|\DateInterval|int $expirat } if ($expiration instanceof \DateInterval) { - return \DateTimeImmutable::createFromFormat('U', time())->add($expiration)->format('U'); + return $this->now()->add($expiration)->format('U'); } return (string) $expiration; } + + private function now(): \DateTimeImmutable + { + return $this->clock?->now() ?? \DateTimeImmutable::createFromFormat('U', time()); + } + + /** + * @return self::STATUS_* + */ + private function doVerify(string $uri): int + { + $url = parse_url($uri); + $params = []; + + if (isset($url['query'])) { + parse_str($url['query'], $params); + } + + if (empty($params[$this->hashParameter])) { + return self::STATUS_MISSING; + } + + $hash = $params[$this->hashParameter]; + unset($params[$this->hashParameter]); + + if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), strtr(rtrim($hash, '='), ['/' => '_', '+' => '-']))) { + return self::STATUS_INVALID; + } + + if (!$expiration = $params[$this->expirationParameter] ?? false) { + return self::STATUS_VALID; + } + + if ($this->now()->getTimestamp() < $expiration) { + return self::STATUS_VALID; + } + + return self::STATUS_EXPIRED; + } + + private static function normalize(Request|string $uri): string + { + if ($uri instanceof Request) { + $qs = ($qs = $uri->server->get('QUERY_STRING')) ? '?'.$qs : ''; + $uri = $uri->getSchemeAndHttpHost().$uri->getBaseUrl().$uri->getPathInfo().$qs; + } + + return $uri; + } } diff --git a/composer.json b/composer.json index ee37247be..a86b21b7c 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ ], "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-mbstring": "~1.1", "symfony/polyfill-php83": "^1.27" }, @@ -24,6 +25,7 @@ "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/mime": "^6.4|^7.0",