Allow PHP-8.2 and up Compatibility instead of just PHP-8.4

This commit is contained in:
johnnyq
2026-06-12 17:06:10 -04:00
parent 2204bd52f4
commit d3a93652f3
220 changed files with 7198 additions and 2635 deletions

View File

@@ -25,7 +25,7 @@ class_exists(AcceptHeaderItem::class);
class AcceptHeader
{
/**
* @var AcceptHeaderItem[]
* @var array<string, AcceptHeaderItem>
*/
private array $items = [];
@@ -46,18 +46,15 @@ class AcceptHeader
*/
public static function fromString(?string $headerValue): self
{
$parts = HeaderUtils::split($headerValue ?? '', ',;=');
$items = [];
foreach (HeaderUtils::split($headerValue ?? '', ',;=') as $i => $parts) {
$part = array_shift($parts);
$item = new AcceptHeaderItem($part[0], HeaderUtils::combine($parts));
return new self(array_map(function ($subParts) {
static $index = 0;
$part = array_shift($subParts);
$attributes = HeaderUtils::combine($subParts);
$items[] = $item->setIndex($i);
}
$item = new AcceptHeaderItem($part[0], $attributes);
$item->setIndex($index++);
return $item;
}, $parts));
return new self($items);
}
/**
@@ -73,7 +70,9 @@ class AcceptHeader
*/
public function has(string $value): bool
{
return isset($this->items[$value]);
$canonicalKey = $this->getCanonicalKey(AcceptHeaderItem::fromString($value));
return isset($this->items[$canonicalKey]);
}
/**
@@ -81,7 +80,26 @@ class AcceptHeader
*/
public function get(string $value): ?AcceptHeaderItem
{
return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null;
$queryItem = AcceptHeaderItem::fromString($value.';q=1');
$canonicalKey = $this->getCanonicalKey($queryItem);
if (isset($this->items[$canonicalKey])) {
return $this->items[$canonicalKey];
}
// Collect and filter matching candidates
if (!$candidates = array_filter($this->items, fn (AcceptHeaderItem $item) => $this->matches($item, $queryItem))) {
return null;
}
usort(
$candidates,
fn ($a, $b) => $this->getSpecificity($b, $queryItem) <=> $this->getSpecificity($a, $queryItem) // Descending specificity
?: $b->getQuality() <=> $a->getQuality() // Descending quality
?: $a->getIndex() <=> $b->getIndex() // Ascending index (stability)
);
return reset($candidates);
}
/**
@@ -91,7 +109,7 @@ class AcceptHeader
*/
public function add(AcceptHeaderItem $item): static
{
$this->items[$item->getValue()] = $item;
$this->items[$this->getCanonicalKey($item)] = $item;
$this->sorted = false;
return $this;
@@ -114,7 +132,7 @@ class AcceptHeader
*/
public function filter(string $pattern): self
{
return new self(array_filter($this->items, fn (AcceptHeaderItem $item) => preg_match($pattern, $item->getValue())));
return new self(array_filter($this->items, static fn ($item) => preg_match($pattern, $item->getValue())));
}
/**
@@ -133,18 +151,154 @@ class AcceptHeader
private function sort(): void
{
if (!$this->sorted) {
uasort($this->items, function (AcceptHeaderItem $a, AcceptHeaderItem $b) {
$qA = $a->getQuality();
$qB = $b->getQuality();
if ($qA === $qB) {
return $a->getIndex() > $b->getIndex() ? 1 : -1;
}
return $qA > $qB ? -1 : 1;
});
uasort($this->items, static fn ($a, $b) => $b->getQuality() <=> $a->getQuality() ?: $a->getIndex() <=> $b->getIndex());
$this->sorted = true;
}
}
/**
* Generates the canonical key for storing/retrieving an item.
*/
private function getCanonicalKey(AcceptHeaderItem $item): string
{
$parts = [];
// Normalize and sort attributes for consistent key generation
$attributes = $this->getMediaParams($item);
ksort($attributes);
foreach ($attributes as $name => $value) {
if (null === $value) {
$parts[] = $name; // Flag parameter (e.g., "flowed")
continue;
}
// Quote values containing spaces, commas, semicolons, or equals per RFC 9110
// This handles cases like 'format="value with space"' or similar.
$quotedValue = \is_string($value) && preg_match('/[\s;,=]/', $value) ? '"'.addcslashes($value, '"\\').'"' : $value;
$parts[] = $name.'='.$quotedValue;
}
return $item->getValue().($parts ? ';'.implode(';', $parts) : '');
}
/**
* Checks if a given header item (range) matches a queried item (value).
*
* @param AcceptHeaderItem $rangeItem The item from the Accept header (e.g., text/*;format=flowed)
* @param AcceptHeaderItem $queryItem The item being queried (e.g., text/plain;format=flowed;charset=utf-8)
*/
private function matches(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool
{
$rangeValue = strtolower($rangeItem->getValue());
$queryValue = strtolower($queryItem->getValue());
// Handle universal wildcard ranges
if ('*' === $rangeValue || '*/*' === $rangeValue) {
return $this->rangeParametersMatch($rangeItem, $queryItem);
}
// Queries for '*' only match wildcard ranges (handled above)
if ('*' === $queryValue) {
return false;
}
// Ensure media vs. non-media consistency
$isQueryMedia = str_contains($queryValue, '/');
$isRangeMedia = str_contains($rangeValue, '/');
if ($isQueryMedia !== $isRangeMedia) {
return false;
}
// Non-media: exact match only (wildcards handled above)
if (!$isQueryMedia) {
return $rangeValue === $queryValue && $this->rangeParametersMatch($rangeItem, $queryItem);
}
// Media type: type/subtype with wildcards
[$queryType, $querySubtype] = explode('/', $queryValue, 2);
[$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*'];
if ('*' !== $rangeType && $rangeType !== $queryType) {
return false;
}
if ('*' !== $rangeSubtype && $rangeSubtype !== $querySubtype) {
return false;
}
// Parameters must match
return $this->rangeParametersMatch($rangeItem, $queryItem);
}
/**
* Checks if the parameters of a range item are satisfied by the query item.
*
* Parameters are case-insensitive; range params must be a subset of query params.
*/
private function rangeParametersMatch(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool
{
$queryAttributes = $this->getMediaParams($queryItem);
$rangeAttributes = $this->getMediaParams($rangeItem);
foreach ($rangeAttributes as $name => $rangeValue) {
if (!\array_key_exists($name, $queryAttributes)) {
return false; // Missing required param
}
$queryValue = $queryAttributes[$name];
if (null === $rangeValue) {
return null === $queryValue; // Both flags or neither
}
if (null === $queryValue || strtolower($queryValue) !== strtolower($rangeValue)) {
return false;
}
}
return true;
}
/**
* Calculates a specificity score for sorting: media precision + param count.
*/
private function getSpecificity(AcceptHeaderItem $item, AcceptHeaderItem $queryItem): int
{
$rangeValue = strtolower($item->getValue());
$queryValue = strtolower($queryItem->getValue());
$paramCount = \count($this->getMediaParams($item));
$isQueryMedia = str_contains($queryValue, '/');
$isRangeMedia = str_contains($rangeValue, '/');
if (!$isQueryMedia && !$isRangeMedia) {
return ('*' !== $rangeValue ? 2000 : 1000) + $paramCount;
}
[$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*'];
$specificity = match (true) {
'*' !== $rangeSubtype => 3000, // Exact subtype (text/plain)
'*' !== $rangeType => 2000, // Type wildcard (text/*)
default => 1000, // Full wildcard (*/* or *)
};
return $specificity + $paramCount;
}
/**
* Returns normalized attributes: keys lowercased, excluding 'q'.
*/
private function getMediaParams(AcceptHeaderItem $item): array
{
$attributes = array_change_key_case($item->getAttributes(), \CASE_LOWER);
unset($attributes['q']);
return $attributes;
}
}

View File

@@ -268,7 +268,7 @@ class BinaryFileResponse extends Response
if ($start < 0 || $start > $end) {
$this->setStatusCode(416);
$this->headers->set('Content-Range', \sprintf('bytes */%s', $fileSize));
} elseif ($end - $start < $fileSize - 1) {
} else {
$this->maxlen = $end < $fileSize ? $end - $start + 1 : -1;
$this->offset = $start;

View File

@@ -1,6 +1,19 @@
CHANGELOG
=========
7.4
---
* Add `#[WithHttpStatus]` to define status codes: 404 for `SignedUriException` and 403 for `ExpiredSignedUriException`
* Add support for the `QUERY` HTTP method
* Add support for structured MIME suffix
* Add `Request::set/getAllowedHttpMethodOverride()` to list which HTTP methods can be overridden
* Deprecate using `Request::sendHeaders()` after headers have already been sent; use a `StreamedResponse` instead
* Deprecate method `Request::get()`, use properties `->attributes`, `query` or `request` directly instead
* Make `Request::createFromGlobals()` parse the body of PUT, DELETE, PATCH and QUERY requests
* Deprecate HTTP method override for methods GET, HEAD, CONNECT and TRACE; it will be ignored in Symfony 8.0
* Deprecate accepting null `$format` argument to `Request::setFormat()`
7.3
---

View File

@@ -48,7 +48,7 @@ class EventStreamResponse extends StreamedResponse
'Cache-Control' => 'private, no-cache, no-store, must-revalidate, max-age=0',
'X-Accel-Buffering' => 'no',
'Pragma' => 'no-cache',
'Expire' => '0',
'Expires' => '0',
];
parent::__construct($callback, $status, $headers);

View File

@@ -11,9 +11,12 @@
namespace Symfony\Component\HttpFoundation\Exception;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[WithHttpStatus(403)]
final class ExpiredSignedUriException extends SignedUriException
{
/**

View File

@@ -11,9 +11,12 @@
namespace Symfony\Component\HttpFoundation\Exception;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[WithHttpStatus(404)]
abstract class SignedUriException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -86,7 +86,7 @@ class File extends \SplFileInfo
{
$target = $this->getTargetFile($directory, $name);
set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
set_error_handler(static function ($type, $msg) use (&$error) { $error = $msg; });
try {
$renamed = rename($this->getPathname(), $target);
} finally {
@@ -96,7 +96,7 @@ class File extends \SplFileInfo
throw new FileException(\sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error)));
}
@chmod($target, 0666 & ~umask());
@chmod($target, 0o666 & ~umask());
return $target;
}
@@ -114,10 +114,11 @@ class File extends \SplFileInfo
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));
if (!is_dir($directory) && !@mkdir($directory, 0o777, true) && !is_dir($directory)) {
if (is_file($directory)) {
throw new FileException(\sprintf('Unable to create the "%s" directory: a similarly-named file exists.', $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));
}

View File

@@ -187,7 +187,7 @@ class UploadedFile extends File
$target = $this->getTargetFile($directory, $name);
set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
set_error_handler(static function ($type, $msg) use (&$error) { $error = $msg; });
try {
$moved = move_uploaded_file($this->getPathname(), $target);
} finally {
@@ -197,29 +197,37 @@ class UploadedFile extends File
throw new FileException(\sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error)));
}
@chmod($target, 0666 & ~umask());
@chmod($target, 0o666 & ~umask());
return $target;
}
switch ($this->error) {
case \UPLOAD_ERR_INI_SIZE:
throw new IniSizeFileException($this->getErrorMessage());
throw new IniSizeFileException($this->getExceptionMessage());
case \UPLOAD_ERR_FORM_SIZE:
throw new FormSizeFileException($this->getErrorMessage());
throw new FormSizeFileException($this->getExceptionMessage());
case \UPLOAD_ERR_PARTIAL:
throw new PartialFileException($this->getErrorMessage());
throw new PartialFileException($this->getExceptionMessage());
case \UPLOAD_ERR_NO_FILE:
throw new NoFileException($this->getErrorMessage());
throw new NoFileException($this->getExceptionMessage());
case \UPLOAD_ERR_CANT_WRITE:
throw new CannotWriteFileException($this->getErrorMessage());
throw new CannotWriteFileException($this->getExceptionMessage());
case \UPLOAD_ERR_NO_TMP_DIR:
throw new NoTmpDirFileException($this->getErrorMessage());
throw new NoTmpDirFileException($this->getExceptionMessage());
case \UPLOAD_ERR_EXTENSION:
throw new ExtensionFileException($this->getErrorMessage());
throw new ExtensionFileException($this->getExceptionMessage());
}
throw new FileException($this->getErrorMessage());
throw new FileException($this->getExceptionMessage());
}
/**
* Retrieves a user-friendly error message for file upload issues, if any.
*/
public function getErrorMessage(): string
{
return \UPLOAD_ERR_OK !== $this->error ? $this->getExceptionMessage() : '';
}
/**
@@ -268,7 +276,7 @@ class UploadedFile extends File
/**
* Returns an informative upload error message.
*/
public function getErrorMessage(): string
private function getExceptionMessage(): string
{
static $errors = [
\UPLOAD_ERR_INI_SIZE => 'The file "%s" exceeds your upload_max_filesize ini directive (limit is %d KiB).',

View File

@@ -164,7 +164,7 @@ class HeaderUtils
*/
public static function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string
{
if (!\in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE])) {
if (!\in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE], true)) {
throw new \InvalidArgumentException(\sprintf('The disposition must be either "%s" or "%s".', self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE));
}

View File

@@ -17,6 +17,8 @@ use Symfony\Component\HttpFoundation\Exception\UnexpectedValueException;
/**
* InputBag is a container for user input values such as $_GET, $_POST, $_REQUEST, and $_COOKIE.
*
* @template TInput of string|int|float|bool|null
*
* @author Saif Eddin Gmati <azjezz@protonmail.com>
*/
final class InputBag extends ParameterBag
@@ -24,7 +26,11 @@ 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
* @template TDefault of string|int|float|bool|null
*
* @param TDefault $default The default value if the input key does not exist
*
* @return TDefault|TInput
*
* @throws BadRequestException if the input contains a non-scalar value
*/

View File

@@ -29,8 +29,13 @@ class IpUtils
'::1/128', // Loopback
'fc00::/7', // Unique Local Address
'fe80::/10', // Link Local Address
'::ffff:0:0/96', // IPv4 translations
'::ffff:0:0/96', // IPv4-mapped IPv6 addresses (RFC 4291 section 2.5.5.2)
'::/128', // Unspecified address
'::/96', // IPv4-compatible IPv6 addresses (RFC 4291 section 2.5.5.1)
'2002::/16', // 6to4 (RFC 3056)
'2001::/32', // Teredo tunneling (RFC 4380)
'64:ff9b::/96', // NAT64 well-known prefix (RFC 6052)
'64:ff9b:1::/48', // NAT64 local-use prefix (RFC 8215)
];
private static array $checkedIps = [];
@@ -212,7 +217,7 @@ class IpUtils
$ip = substr($ip, 1, -1);
}
$mappedIpV4MaskGenerator = function (string $mask, int $bytesToAnonymize) {
$mappedIpV4MaskGenerator = static function (string $mask, int $bytesToAnonymize) {
$mask .= str_repeat('ff', 4 - $bytesToAnonymize);
$mask .= str_repeat('00', $bytesToAnonymize);

View File

@@ -23,6 +23,9 @@ use Symfony\Component\HttpFoundation\Exception\UnexpectedValueException;
*/
class ParameterBag implements \IteratorAggregate, \Countable
{
/**
* @param array<string, mixed> $parameters
*/
public function __construct(
protected array $parameters = [],
) {
@@ -31,7 +34,11 @@ class ParameterBag implements \IteratorAggregate, \Countable
/**
* Returns the parameters.
*
* @param string|null $key The name of the parameter to return or null to get them all
* @template TKey of string|null
*
* @param TKey $key The name of the parameter to return or null to get them all
*
* @return (TKey is null ? array<string, mixed> : array<mixed>)
*
* @throws BadRequestException if the value is not an array
*/
@@ -50,6 +57,8 @@ class ParameterBag implements \IteratorAggregate, \Countable
/**
* Returns the parameter keys.
*
* @return list<string>
*/
public function keys(): array
{
@@ -58,6 +67,8 @@ class ParameterBag implements \IteratorAggregate, \Countable
/**
* Replaces the current parameters by a new set.
*
* @param array<string, mixed> $parameters
*/
public function replace(array $parameters = []): void
{
@@ -66,6 +77,8 @@ class ParameterBag implements \IteratorAggregate, \Countable
/**
* Adds parameters.
*
* @param array<string, mixed> $parameters
*/
public function add(array $parameters = []): void
{
@@ -188,7 +201,7 @@ class ParameterBag implements \IteratorAggregate, \Countable
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: ', $key).$e->getMessage().'.', $e->getCode(), $e);
}
}

View File

@@ -62,6 +62,7 @@ class Request
public const METHOD_OPTIONS = 'OPTIONS';
public const METHOD_TRACE = 'TRACE';
public const METHOD_CONNECT = 'CONNECT';
public const METHOD_QUERY = 'QUERY';
/**
* @var string[]
@@ -80,6 +81,13 @@ class Request
protected static bool $httpMethodParameterOverride = false;
/**
* The HTTP methods that can be overridden.
*
* @var uppercase-string[]|null
*/
protected static ?array $allowedHttpMethodOverride = null;
/**
* Custom parameters.
*/
@@ -94,6 +102,8 @@ class Request
/**
* Query string parameters ($_GET).
*
* @var InputBag<string>
*/
public InputBag $query;
@@ -109,6 +119,8 @@ class Request
/**
* Cookies ($_COOKIE).
*
* @var InputBag<string>
*/
public InputBag $cookies;
@@ -194,6 +206,28 @@ class Request
self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX',
];
/**
* This mapping is used when no exact MIME match is found in $formats.
*
* It enables mappings like application/soap+xml -> xml.
*
* @see https://datatracker.ietf.org/doc/html/rfc6839
* @see https://datatracker.ietf.org/doc/html/rfc7303
* @see https://www.iana.org/assignments/media-types/media-types.xhtml
*/
private const STRUCTURED_SUFFIX_FORMATS = [
'json' => 'json',
'xml' => 'xml',
'xhtml' => 'html',
'cbor' => 'cbor',
'zip' => 'zip',
'ber' => 'asn1',
'der' => 'asn1',
'tlv' => 'tlv',
'wbxml' => 'xml',
'yaml' => 'yaml',
];
private bool $isIisRewrite = false;
/**
@@ -251,16 +285,30 @@ class Request
*/
public static function createFromGlobals(): static
{
$request = self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER);
if (str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded')
&& \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH'], true)
) {
parse_str($request->getContent(), $data);
$request->request = new InputBag($data);
if (!\in_array($_SERVER['REQUEST_METHOD'] ?? null, ['PUT', 'DELETE', 'PATCH', 'QUERY'], true)) {
return self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER);
}
return $request;
if (\PHP_VERSION_ID < 80400) {
if (!isset($_SERVER['CONTENT_TYPE']) || str_starts_with($_SERVER['CONTENT_TYPE'], 'application/x-www-form-urlencoded')) {
$content = file_get_contents('php://input');
parse_str($content, $post);
} else {
$content = null;
$post = $_POST;
}
return self::createRequestFromFactory($_GET, $post, [], $_COOKIE, $_FILES, $_SERVER, $content);
}
try {
[$post, $files] = request_parse_body();
} catch (\RequestParseBodyException) {
$post = $_POST;
$files = $_FILES;
}
return self::createRequestFromFactory($_GET, $post, [], $_COOKIE, $files, $_SERVER);
}
/**
@@ -353,14 +401,23 @@ class Request
$server['PHP_AUTH_PW'] = $components['pass'];
}
if (!isset($components['path'])) {
if ('' === $path = $components['path'] ?? '') {
$components['path'] = '/';
} elseif (!isset($components['scheme']) && !isset($components['host']) && '/' !== $path[0]) {
if (false !== $pos = strpos($path, '/')) {
$path = substr($path, 0, $pos);
}
if (str_contains($path, ':')) {
throw new BadRequestException('Invalid URI: Path is malformed.');
}
}
switch (strtoupper($method)) {
case 'POST':
case 'PUT':
case 'DELETE':
case 'QUERY':
if (!isset($server['CONTENT_TYPE'])) {
$server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
}
@@ -451,8 +508,8 @@ class Request
$dup->method = null;
$dup->format = null;
if (!$dup->get('_format') && $this->get('_format')) {
$dup->attributes->set('_format', $this->get('_format'));
if (!$dup->attributes->has('_format') && $this->attributes->has('_format')) {
$dup->attributes->set('_format', $this->attributes->get('_format'));
}
if (!$dup->getRequestFormat(null)) {
@@ -596,7 +653,7 @@ class Request
*/
public static function setTrustedHosts(array $hostPatterns): void
{
self::$trustedHostPatterns = array_map(fn ($hostPattern) => \sprintf('{%s}i', $hostPattern), $hostPatterns);
self::$trustedHostPatterns = array_map(static fn ($hostPattern) => \sprintf('{%s}i', $hostPattern), $hostPatterns);
// we need to reset trusted hosts on trusted host patterns change
self::$trustedHosts = [];
}
@@ -653,6 +710,34 @@ class Request
return self::$httpMethodParameterOverride;
}
/**
* Sets the list of HTTP methods that can be overridden.
*
* Set to null to allow all methods to be overridden (default). Set to an
* empty array to disallow overrides entirely. Otherwise, provide the list
* of uppercased method names that are allowed.
*
* @param uppercase-string[]|null $methods
*/
public static function setAllowedHttpMethodOverride(?array $methods): void
{
if (array_intersect($methods ?? [], ['GET', 'HEAD', 'CONNECT', 'TRACE'])) {
throw new \InvalidArgumentException('The HTTP methods "GET", "HEAD", "CONNECT", and "TRACE" cannot be overridden.');
}
self::$allowedHttpMethodOverride = $methods;
}
/**
* Gets the list of HTTP methods that can be overridden.
*
* @return uppercase-string[]|null
*/
public static function getAllowedHttpMethodOverride(): ?array
{
return self::$allowedHttpMethodOverride;
}
/**
* Gets a "parameter" value from any bag.
*
@@ -662,10 +747,12 @@ class Request
*
* Order of precedence: PATH (routing placeholders or custom attributes), GET, POST
*
* @internal use explicit input sources instead
* @deprecated since Symfony 7.4, use properties `->attributes`, `query` or `request` directly instead
*/
public function get(string $key, mixed $default = null): mixed
{
trigger_deprecation('symfony/http-foundation', '7.4', 'Request::get() is deprecated, use properties ->attributes, query or request directly instead.');
if ($this !== $result = $this->attributes->get($key, $this)) {
return $result;
}
@@ -770,10 +857,6 @@ class Request
* being the original client, and each successive proxy that passed the request
* adding the IP address where it received the request from.
*
* If your reverse proxy uses a different header name than "X-Forwarded-For",
* ("Client-Ip" for instance), configure it via the $trustedHeaderSet
* argument of the Request::setTrustedProxies() method instead.
*
* @see getClientIps()
* @see https://wikipedia.org/wiki/X-Forwarded-For
*/
@@ -797,7 +880,7 @@ class Request
*
* Suppose this request is instantiated from /mysite on localhost:
*
* * http://localhost/mysite returns an empty string
* * http://localhost/mysite returns '/'
* * http://localhost/mysite/about returns '/about'
* * http://localhost/mysite/enco%20ded returns '/enco%20ded'
* * http://localhost/mysite/about?var=1 returns '/about'
@@ -1075,7 +1158,7 @@ class Request
$https = $this->server->get('HTTPS');
return $https && 'off' !== strtolower($https);
return $https && (!\is_string($https) || 'off' !== strtolower($https));
}
/**
@@ -1092,10 +1175,8 @@ class Request
{
if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) {
$host = $host[0];
} elseif (!$host = $this->headers->get('HOST')) {
if (!$host = $this->server->get('SERVER_NAME')) {
$host = $this->server->get('SERVER_ADDR', '');
}
} else {
$host = $this->headers->get('HOST') ?: $this->server->get('SERVER_NAME') ?: $this->server->get('SERVER_ADDR', '');
}
// trim and remove port number from host
@@ -1168,7 +1249,7 @@ class Request
$this->method = strtoupper($this->server->get('REQUEST_METHOD', 'GET'));
if ('POST' !== $this->method) {
if ('POST' !== $this->method || !(self::$allowedHttpMethodOverride ?? true)) {
return $this->method;
}
@@ -1184,11 +1265,15 @@ class Request
$method = strtoupper($method);
if (\in_array($method, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'PATCH', 'PURGE', 'TRACE'], true)) {
return $this->method = $method;
if (\in_array($method, ['GET', 'HEAD', 'CONNECT', 'TRACE'], true)) {
trigger_deprecation('symfony/http-foundation', '7.4', 'HTTP method override is deprecated for methods GET, HEAD, CONNECT and TRACE; it will be ignored in Symfony 8.0.', $method);
}
if (!preg_match('/^[A-Z]++$/D', $method)) {
if (self::$allowedHttpMethodOverride && !\in_array($method, self::$allowedHttpMethodOverride, true)) {
return $this->method;
}
if (\strlen($method) !== strspn($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')) {
throw new SuspiciousOperationException('Invalid HTTP method override.');
}
@@ -1233,9 +1318,22 @@ class Request
/**
* Gets the format associated with the mime type.
*
* Resolution order:
* 1) Exact match on the full MIME type (e.g. "application/json").
* 2) Match on the canonical MIME type (i.e. before the first ";" parameter).
* 3) If the type is "application/*+suffix", use the structured syntax suffix
* mapping (e.g. "application/foo+json" → "json"), when available.
* 4) If $subtypeFallback is true and no match was found:
* - return the MIME subtype (without "x-" prefix), provided it does not
* contain a "+" (e.g. "application/x-yaml" → "yaml", "text/csv" → "csv").
*
* @param string|null $mimeType The mime type to check
* @param bool $subtypeFallback Whether to fall back to the subtype if no exact match is found
*/
public function getFormat(?string $mimeType): ?string
public function getFormat(?string $mimeType/* , bool $subtypeFallback = false */): ?string
{
$subtypeFallback = 2 <= \func_num_args() ? func_get_arg(1) : false;
$canonicalMimeType = null;
if ($mimeType && false !== $pos = strpos($mimeType, ';')) {
$canonicalMimeType = trim(substr($mimeType, 0, $pos));
@@ -1261,21 +1359,48 @@ class Request
return $format;
}
if (!$canonicalMimeType ??= $mimeType) {
return null;
}
if (str_starts_with($canonicalMimeType, 'application/') && str_contains($canonicalMimeType, '+')) {
$suffix = substr(strrchr($canonicalMimeType, '+'), 1);
if (isset(self::STRUCTURED_SUFFIX_FORMATS[$suffix])) {
return self::STRUCTURED_SUFFIX_FORMATS[$suffix];
}
}
if ($subtypeFallback && str_contains($canonicalMimeType, '/')) {
[, $subtype] = explode('/', $canonicalMimeType, 2);
if (str_starts_with($subtype, 'x-')) {
$subtype = substr($subtype, 2);
}
if (!str_contains($subtype, '+')) {
return $subtype;
}
}
return null;
}
/**
* Associates a format with mime types.
*
* @param string $format The format to set
* @param string|string[] $mimeTypes The associated mime types (the preferred one must be the first as it will be used as the content type)
*/
public function setFormat(?string $format, string|array $mimeTypes): void
{
if (null === $format) {
trigger_deprecation('symfony/http-foundation', '7.4', 'Passing "null" as the first argument of "%s()" is deprecated. The argument will be non-nullable in Symfony 8.0.', __METHOD__);
$format = '';
}
if (null === static::$formats) {
static::initializeFormats();
}
static::$formats[$format ?? ''] = (array) $mimeTypes;
static::$formats[$format] = (array) $mimeTypes;
}
/**
@@ -1367,7 +1492,7 @@ class Request
*/
public function isMethodSafe(): bool
{
return \in_array($this->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE']);
return \in_array($this->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY'], true);
}
/**
@@ -1375,7 +1500,7 @@ class Request
*/
public function isMethodIdempotent(): bool
{
return \in_array($this->getMethod(), ['HEAD', 'GET', 'PUT', 'DELETE', 'TRACE', 'OPTIONS', 'PURGE']);
return \in_array($this->getMethod(), ['HEAD', 'GET', 'PUT', 'DELETE', 'TRACE', 'OPTIONS', 'PURGE', 'QUERY'], true);
}
/**
@@ -1385,7 +1510,7 @@ class Request
*/
public function isMethodCacheable(): bool
{
return \in_array($this->getMethod(), ['GET', 'HEAD']);
return \in_array($this->getMethod(), ['GET', 'HEAD', 'QUERY'], true);
}
/**
@@ -1421,10 +1546,8 @@ class Request
*/
public function getContent(bool $asResource = false)
{
$currentContentIsResource = \is_resource($this->content);
if (true === $asResource) {
if ($currentContentIsResource) {
if ($asResource) {
if (\is_resource($this->content)) {
rewind($this->content);
return $this->content;
@@ -1444,7 +1567,7 @@ class Request
return fopen('php://input', 'r');
}
if ($currentContentIsResource) {
if (\is_resource($this->content)) {
rewind($this->content);
return stream_get_contents($this->content);
@@ -1932,6 +2055,14 @@ class Request
'atom' => ['application/atom+xml'],
'rss' => ['application/rss+xml'],
'form' => ['application/x-www-form-urlencoded', 'multipart/form-data'],
'soap' => ['application/soap+xml'],
'problem' => ['application/problem+json'],
'hal' => ['application/hal+json', 'application/hal+xml'],
'jsonapi' => ['application/vnd.api+json'],
'yaml' => ['text/yaml', 'application/x-yaml'],
'wbxml' => ['application/vnd.wap.wbxml'],
'pdf' => ['application/pdf'],
'csv' => ['text/csv'],
];
}

View File

@@ -261,7 +261,7 @@ class Response
}
// Fix Content-Type
$charset = $this->charset ?: 'UTF-8';
$charset = $this->charset ?: 'utf-8';
if (!$headers->has('Content-Type')) {
$headers->set('Content-Type', 'text/html; charset='.$charset);
} elseif (0 === stripos($headers->get('Content-Type') ?? '', 'text/') && false === stripos($headers->get('Content-Type') ?? '', 'charset')) {
@@ -317,6 +317,12 @@ class Response
{
// headers have already been sent by the developer
if (headers_sent()) {
if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) {
$statusCode ??= $this->statusCode;
trigger_deprecation('symfony/http-foundation', '7.4', 'Trying to use "%s::sendHeaders()" after headers have already been sent is deprecated and will throw a PHP warning in 8.0. Use a "StreamedResponse" instead.', static::class);
// header(\sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode);
}
return $this;
}
@@ -539,7 +545,7 @@ class Response
*/
public function isCacheable(): bool
{
if (!\in_array($this->statusCode, [200, 203, 300, 301, 302, 404, 410])) {
if (!\in_array($this->statusCode, [200, 203, 300, 301, 302, 404, 410], true)) {
return false;
}
@@ -1248,7 +1254,7 @@ class Response
*/
public function isRedirect(?string $location = null): bool
{
return \in_array($this->statusCode, [201, 301, 302, 303, 307, 308]) && (null === $location ?: $location == $this->headers->get('Location'));
return \in_array($this->statusCode, [201, 301, 302, 303, 307, 308], true) && (null === $location ?: $location == $this->headers->get('Location'));
}
/**
@@ -1258,7 +1264,7 @@ class Response
*/
public function isEmpty(): bool
{
return \in_array($this->statusCode, [204, 304]);
return \in_array($this->statusCode, [204, 304], true);
}
/**

View File

@@ -194,7 +194,7 @@ class ResponseHeaderBag extends HeaderBag
*/
public function getCookies(string $format = self::COOKIES_FLAT): array
{
if (!\in_array($format, [self::COOKIES_FLAT, self::COOKIES_ARRAY])) {
if (!\in_array($format, [self::COOKIES_FLAT, self::COOKIES_ARRAY], true)) {
throw new \InvalidArgumentException(\sprintf('Format "%s" invalid (%s).', $format, implode(', ', [self::COOKIES_FLAT, self::COOKIES_ARRAY])));
}

View File

@@ -72,6 +72,16 @@ abstract class AbstractSessionHandler implements \SessionHandlerInterface, \Sess
return $data;
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$this->igbinaryEmptyData ??= \function_exists('igbinary_serialize') ? igbinary_serialize([]) : '';
if ('' === $data || $this->igbinaryEmptyData === $data) {
return $this->destroy($sessionId);
}
return true;
}
public function write(#[\SensitiveParameter] string $sessionId, string $data): bool
{
// see https://github.com/igbinary/igbinary/issues/146

View File

@@ -41,7 +41,7 @@ class NativeFileSessionHandler extends \SessionHandler
$baseDir = ltrim(strrchr($savePath, ';'), ';');
}
if ($baseDir && !is_dir($baseDir) && !@mkdir($baseDir, 0777, true) && !is_dir($baseDir)) {
if ($baseDir && !is_dir($baseDir) && !@mkdir($baseDir, 0o777, true) && !is_dir($baseDir)) {
throw new \RuntimeException(\sprintf('Session Storage was not able to create directory "%s".', $baseDir));
}

View File

@@ -197,7 +197,6 @@ class PdoSessionHandler extends AbstractSessionHandler
$table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true);
$table->addColumn($this->lifetimeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true);
$table->addColumn($this->timeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true);
$table->addOption('collate', 'utf8mb4_bin');
$table->addOption('engine', 'InnoDB');
break;
case 'sqlite':
@@ -259,7 +258,7 @@ class PdoSessionHandler extends AbstractSessionHandler
// - trailing space removal
// - case-insensitivity
// - language processing like é == e
'mysql' => "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB",
'mysql' => "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) ENGINE = InnoDB",
'sqlite' => "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
'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)",
@@ -802,6 +801,12 @@ class PdoSessionHandler extends AbstractSessionHandler
rewind($data);
$sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, EMPTY_BLOB(), :expiry, :time) RETURNING $this->dataCol into :data";
break;
case 'sqlsrv':
$data = fopen('php://memory', 'r+');
fwrite($data, $sessionData);
rewind($data);
$sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time)";
break;
default:
$data = $sessionData;
$sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time)";
@@ -829,6 +834,12 @@ class PdoSessionHandler extends AbstractSessionHandler
rewind($data);
$sql = "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data";
break;
case 'sqlsrv':
$data = fopen('php://memory', 'r+');
fwrite($data, $sessionData);
rewind($data);
$sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id";
break;
default:
$data = $sessionData;
$sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id";
@@ -876,12 +887,16 @@ class PdoSessionHandler extends AbstractSessionHandler
$mergeStmt = $this->pdo->prepare($mergeSql);
if ('sqlsrv' === $this->driver) {
$dataStream = fopen('php://memory', 'r+');
fwrite($dataStream, $data);
rewind($dataStream);
$mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR);
$mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR);
$mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB);
$mergeStmt->bindParam(3, $dataStream, \PDO::PARAM_LOB);
$mergeStmt->bindValue(4, time() + $maxlifetime, \PDO::PARAM_INT);
$mergeStmt->bindValue(5, time(), \PDO::PARAM_INT);
$mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB);
$mergeStmt->bindParam(6, $dataStream, \PDO::PARAM_LOB);
$mergeStmt->bindValue(7, time() + $maxlifetime, \PDO::PARAM_INT);
$mergeStmt->bindValue(8, time(), \PDO::PARAM_INT);
} else {

View File

@@ -64,6 +64,7 @@ class SessionHandlerFactory
throw new \InvalidArgumentException('Unsupported Redis or Memcached DSN. Try running "composer require symfony/cache".');
}
$handlerClass = str_starts_with($connection, 'memcached:') ? MemcachedSessionHandler::class : RedisSessionHandler::class;
$connection = preg_replace('/([?&])prefix=[^&]*+&?/', '\1', $connection);
$connection = AbstractAdapter::createConnection($connection, ['lazy' => true]);
return new $handlerClass($connection, array_intersect_key($options, ['prefix' => 1, 'ttl' => 1]));

View File

@@ -32,12 +32,14 @@ class MetadataBag implements SessionBagInterface
private int $lastUsed;
/**
* @param string $storageKey The key used to store bag in the session
* @param int $updateThreshold The time to wait between two UPDATED updates
* @param string $storageKey The key used to store bag in the session
* @param int $updateThreshold The time to wait between two UPDATED updates
* @param int|null $cookieLifetime The configured cookie lifetime; null to read from php.ini
*/
public function __construct(
private string $storageKey = '_sf2_meta',
private int $updateThreshold = 0,
private ?int $cookieLifetime = null,
) {
}
@@ -126,6 +128,6 @@ class MetadataBag implements SessionBagInterface
{
$timeStamp = time();
$this->meta[self::CREATED] = $this->meta[self::UPDATED] = $this->lastUsed = $timeStamp;
$this->meta[self::LIFETIME] = $lifetime ?? (int) \ini_get('session.cookie_lifetime');
$this->meta[self::LIFETIME] = $lifetime ?? $this->cookieLifetime ?? (int) \ini_get('session.cookie_lifetime');
}
}

View File

@@ -34,7 +34,7 @@ class MockFileSessionStorage extends MockArraySessionStorage
{
$savePath ??= sys_get_temp_dir();
if (!is_dir($savePath) && !@mkdir($savePath, 0777, true) && !is_dir($savePath)) {
if (!is_dir($savePath) && !@mkdir($savePath, 0o777, true) && !is_dir($savePath)) {
throw new \RuntimeException(\sprintf('Session Storage was not able to create directory "%s".', $savePath));
}
@@ -103,7 +103,7 @@ class MockFileSessionStorage extends MockArraySessionStorage
$this->data = $data;
}
// this is needed when the session object is re-used across multiple requests
// this is needed when the session object is reused across multiple requests
// in functional tests.
$this->started = false;
}

View File

@@ -94,7 +94,7 @@ class StreamedJsonResponse extends StreamedResponse
{
$generators = [];
array_walk_recursive($data, function (&$item, $key) use (&$generators) {
array_walk_recursive($data, static function (&$item, $key) use (&$generators) {
if (self::PLACEHOLDER === $key) {
// if the placeholder is already in the structure it should be replaced with a new one that explode
// works like expected for the structure

View File

@@ -121,19 +121,12 @@ class UriSigner
$uri = self::normalize($uri);
$status = $this->doVerify($uri);
if (self::STATUS_VALID === $status) {
return;
}
if (self::STATUS_MISSING === $status) {
throw new UnsignedUriException();
}
if (self::STATUS_INVALID === $status) {
throw new UnverifiedSignedUriException();
}
throw new ExpiredSignedUriException();
match ($status) {
self::STATUS_VALID => null,
self::STATUS_INVALID => throw new UnverifiedSignedUriException(),
self::STATUS_EXPIRED => throw new ExpiredSignedUriException(),
default => throw new UnsignedUriException(),
};
}
private function computeHash(string $uri): string

View File

@@ -17,20 +17,19 @@
],
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-mbstring": "~1.1",
"symfony/polyfill-php83": "^1.27"
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "^1.1"
},
"require-dev": {
"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",
"symfony/expression-language": "^6.4|^7.0",
"symfony/rate-limiter": "^6.4|^7.0"
"symfony/cache": "^6.4.12|^7.1.5|^8.0",
"symfony/clock": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/mime": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/rate-limiter": "^6.4|^7.0|^8.0"
},
"conflict": {
"doctrine/dbal": "<3.6",