Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 2fbddd7dbc
366 changed files with 41999 additions and 0 deletions

407
core/lib/Http/Cookie.php Normal file
View File

@@ -0,0 +1,407 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http;
/**
* Represents a cookie.
*
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*/
class Cookie
{
public const SAMESITE_NONE = 'none';
public const SAMESITE_LAX = 'lax';
public const SAMESITE_STRICT = 'strict';
protected int $expire;
protected string $path;
private ?string $sameSite = null;
private bool $secureDefault = false;
private const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f";
private const RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"];
private const RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C'];
/**
* Creates cookie from raw header string.
*/
public static function fromString(string $cookie, bool $decode = false): static
{
$data = [
'expires' => 0,
'path' => '/',
'domain' => null,
'secure' => false,
'httponly' => false,
'raw' => !$decode,
'samesite' => null,
'partitioned' => false,
];
$parts = HeaderUtils::split($cookie, ';=');
$part = array_shift($parts);
$name = $decode ? urldecode($part[0]) : $part[0];
$value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null;
$data = HeaderUtils::combine($parts) + $data;
$data['expires'] = self::expiresTimestamp($data['expires']);
if (isset($data['max-age']) && ($data['max-age'] > 0 || $data['expires'] > time())) {
$data['expires'] = time() + (int) $data['max-age'];
}
return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite'], $data['partitioned']);
}
/**
* @see self::__construct
*
* @param self::SAMESITE_*|''|null $sameSite
*/
public static function create(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): self
{
return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite, $partitioned);
}
/**
* @param string $name The name of the cookie
* @param string|null $value The value of the cookie
* @param int|string|\DateTimeInterface $expire The time the cookie expires
* @param string|null $path The path on the server in which the cookie will be available on
* @param string|null $domain The domain that the cookie is available to
* @param bool|null $secure Whether the client should send back the cookie only over HTTPS or null to auto-enable this when the request is already using HTTPS
* @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol
* @param bool $raw Whether the cookie value should be sent with no url encoding
* @param self::SAMESITE_*|''|null $sameSite Whether the cookie will be available for cross-site requests
*
* @throws \InvalidArgumentException
*/
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));
}
if (!$name) {
throw new \InvalidArgumentException('The cookie name cannot be empty.');
}
$this->expire = self::expiresTimestamp($expire);
$this->path = $path ?: '/';
$this->sameSite = $this->withSameSite($sameSite)->sameSite;
}
/**
* Creates a cookie copy with a new value.
*/
public function withValue(?string $value): static
{
$cookie = clone $this;
$cookie->value = $value;
return $cookie;
}
/**
* Creates a cookie copy with a new domain that the cookie is available to.
*/
public function withDomain(?string $domain): static
{
$cookie = clone $this;
$cookie->domain = $domain;
return $cookie;
}
/**
* Creates a cookie copy with a new time the cookie expires.
*/
public function withExpires(int|string|\DateTimeInterface $expire = 0): static
{
$cookie = clone $this;
$cookie->expire = self::expiresTimestamp($expire);
return $cookie;
}
/**
* Converts expires formats to a unix timestamp.
*/
private static function expiresTimestamp(int|string|\DateTimeInterface $expire = 0): int
{
// convert expiration time to a Unix timestamp
if ($expire instanceof \DateTimeInterface) {
$expire = $expire->format('U');
} elseif (!is_numeric($expire)) {
$expire = strtotime($expire);
if (false === $expire) {
throw new \InvalidArgumentException('The cookie expiration time is not valid.');
}
}
return 0 < $expire ? (int) $expire : 0;
}
/**
* Creates a cookie copy with a new path on the server in which the cookie will be available on.
*/
public function withPath(string $path): static
{
$cookie = clone $this;
$cookie->path = '' === $path ? '/' : $path;
return $cookie;
}
/**
* Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client.
*/
public function withSecure(bool $secure = true): static
{
$cookie = clone $this;
$cookie->secure = $secure;
return $cookie;
}
/**
* Creates a cookie copy that be accessible only through the HTTP protocol.
*/
public function withHttpOnly(bool $httpOnly = true): static
{
$cookie = clone $this;
$cookie->httpOnly = $httpOnly;
return $cookie;
}
/**
* Creates a cookie copy that uses no url encoding.
*/
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));
}
$cookie = clone $this;
$cookie->raw = $raw;
return $cookie;
}
/**
* Creates a cookie copy with SameSite attribute.
*
* @param self::SAMESITE_*|''|null $sameSite
*/
public function withSameSite(?string $sameSite): static
{
if ('' === $sameSite) {
$sameSite = null;
} elseif (null !== $sameSite) {
$sameSite = strtolower($sameSite);
}
if (!\in_array($sameSite, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE, null], true)) {
throw new \InvalidArgumentException('The "sameSite" parameter value is not valid.');
}
$cookie = clone $this;
$cookie->sameSite = $sameSite;
return $cookie;
}
/**
* Creates a cookie copy that is tied to the top-level site in cross-site context.
*/
public function withPartitioned(bool $partitioned = true): static
{
$cookie = clone $this;
$cookie->partitioned = $partitioned;
return $cookie;
}
/**
* Returns the cookie as a string.
*/
public function __toString(): string
{
if ($this->isRaw()) {
$str = $this->getName();
} else {
$str = str_replace(self::RESERVED_CHARS_FROM, self::RESERVED_CHARS_TO, $this->getName());
}
$str .= '=';
if ('' === (string) $this->getValue()) {
$str .= 'deleted; expires='.gmdate('D, d M Y H:i:s T', time() - 31536001).'; Max-Age=0';
} else {
$str .= $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue());
if (0 !== $this->getExpiresTime()) {
$str .= '; expires='.gmdate('D, d M Y H:i:s T', $this->getExpiresTime()).'; Max-Age='.$this->getMaxAge();
}
}
if ($this->getPath()) {
$str .= '; path='.$this->getPath();
}
if ($this->getDomain()) {
$str .= '; domain='.$this->getDomain();
}
if ($this->isSecure()) {
$str .= '; secure';
}
if ($this->isHttpOnly()) {
$str .= '; httponly';
}
if (null !== $this->getSameSite()) {
$str .= '; samesite='.$this->getSameSite();
}
if ($this->isPartitioned()) {
$str .= '; partitioned';
}
return $str;
}
/**
* Gets the name of the cookie.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the value of the cookie.
*/
public function getValue(): ?string
{
return $this->value;
}
/**
* Gets the domain that the cookie is available to.
*/
public function getDomain(): ?string
{
return $this->domain;
}
/**
* Gets the time the cookie expires.
*/
public function getExpiresTime(): int
{
return $this->expire;
}
/**
* Gets the max-age attribute.
*/
public function getMaxAge(): int
{
$maxAge = $this->expire - time();
return max(0, $maxAge);
}
/**
* Gets the path on the server in which the cookie will be available on.
*/
public function getPath(): string
{
return $this->path;
}
/**
* Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client.
*/
public function isSecure(): bool
{
return $this->secure ?? $this->secureDefault;
}
/**
* Checks whether the cookie will be made accessible only through the HTTP protocol.
*/
public function isHttpOnly(): bool
{
return $this->httpOnly;
}
/**
* Whether this cookie is about to be cleared.
*/
public function isCleared(): bool
{
return 0 !== $this->expire && $this->expire < time();
}
/**
* Checks if the cookie value should be sent with no url encoding.
*/
public function isRaw(): bool
{
return $this->raw;
}
/**
* Checks whether the cookie should be tied to the top-level site in cross-site context.
*/
public function isPartitioned(): bool
{
return $this->partitioned;
}
/**
* @return self::SAMESITE_*|null
*/
public function getSameSite(): ?string
{
return $this->sameSite;
}
/**
* @param bool $default The default value of the "secure" flag when it is set to null
*/
public function setSecureDefault(bool $default): void
{
$this->secureDefault = $default;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Exception;
use KTXF\Exception\BaseException;
class BadRequestException extends BaseException
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Exception;
/**
* Exception thrown when request headers conflict with each other.
*/
class ConflictingHeadersException extends \UnexpectedValueException
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Exception;
/**
* Exception thrown when JSON decoding/encoding fails in HTTP context.
*/
class JsonException extends \UnexpectedValueException
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Exception;
/**
* Exception thrown when a session is expected but not available.
*/
class SessionNotFoundException extends \LogicException
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Exception;
/**
* Exception thrown when a suspicious operation is detected (e.g., invalid host).
*/
class SuspiciousOperationException extends \UnexpectedValueException
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Exception;
use KTXF\Exception\RuntimeException;
class UnexpectedValueException extends RuntimeException {}

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\File;
/**
* Represents a file uploaded through an HTTP request.
*/
class UploadedFile extends \SplFileInfo
{
private string $originalName;
private ?string $mimeType;
private int $error;
private bool $test;
/**
* Accepts the information of the uploaded file as provided by the PHP global $_FILES.
*
* @param string $path The full temporary path to the file
* @param string $originalName The original file name of the uploaded file
* @param string|null $mimeType The type of the file as provided by PHP; null defaults to application/octet-stream
* @param int|null $error The error constant of the upload (one of PHP's UPLOAD_ERR_XXX constants); null defaults to UPLOAD_ERR_OK
* @param bool $test Whether the test mode is active (used for testing)
*
* @throws \InvalidArgumentException If the file is not readable
*/
public function __construct(
string $path,
string $originalName,
?string $mimeType = null,
?int $error = null,
bool $test = false
) {
$this->originalName = $this->getName($originalName);
$this->mimeType = $mimeType ?? 'application/octet-stream';
$this->error = $error ?? \UPLOAD_ERR_OK;
$this->test = $test;
parent::__construct($path);
}
/**
* Returns the original file name.
*
* It is extracted from the request from which the file has been uploaded.
* This should not be considered as a safe value to use for a file name on your servers.
*
* @return string The original name
*/
public function getClientOriginalName(): string
{
return $this->originalName;
}
/**
* Returns the original file extension.
*
* It is extracted from the original file name that was uploaded.
* This should not be considered as a safe value to use for a file name on your servers.
*
* @return string The extension
*/
public function getClientOriginalExtension(): string
{
return pathinfo($this->originalName, \PATHINFO_EXTENSION);
}
/**
* Returns the file mime type.
*
* The client mime type is extracted from the request from which the file was uploaded,
* so it should not be considered as a safe value.
*
* @return string The mime type
*/
public function getClientMimeType(): string
{
return $this->mimeType;
}
/**
* Returns the extension based on the client mime type.
*
* If the mime type is unknown, returns null.
*
* This method uses a built-in list of mime type / extension pairs.
*
* @return string|null The guessed extension or null if it cannot be guessed
*/
public function guessClientExtension(): ?string
{
return self::mimeToExtension($this->mimeType);
}
/**
* Returns the upload error.
*
* If the upload was successful, the constant UPLOAD_ERR_OK is returned.
* Otherwise one of the other UPLOAD_ERR_XXX constants is returned.
*
* @return int The upload error
*/
public function getError(): int
{
return $this->error;
}
/**
* Returns whether the file has been uploaded with HTTP and no error occurred.
*
* @return bool True if the file is valid, false otherwise
*/
public function isValid(): bool
{
$isOk = \UPLOAD_ERR_OK === $this->error;
return $this->test ? $isOk : $isOk && is_uploaded_file($this->getPathname());
}
/**
* Moves the file to a new location.
*
* @param string $directory The destination folder
* @param string|null $name The new file name
*
* @return \SplFileInfo A SplFileInfo object for the new file
*
* @throws \RuntimeException if the file cannot be moved
*/
public function move(string $directory, ?string $name = null): \SplFileInfo
{
if ($this->isValid()) {
if ($this->test) {
return $this->doMove($directory, $name);
}
$target = $this->getTargetFile($directory, $name);
if (!@move_uploaded_file($this->getPathname(), $target)) {
$error = error_get_last();
throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error')));
}
@chmod($target, 0666 & ~umask());
return new \SplFileInfo($target);
}
throw new \RuntimeException($this->getErrorMessage());
}
/**
* Returns the maximum size of an uploaded file as configured in php.ini.
*
* @return int|float The maximum size of an uploaded file in bytes (returns float on 32-bit for large values)
*/
public static function getMaxFilesize(): int|float
{
$sizePostMax = self::parseFilesize(\ini_get('post_max_size'));
$sizeUploadMax = self::parseFilesize(\ini_get('upload_max_filesize'));
return min($sizePostMax ?: \PHP_INT_MAX, $sizeUploadMax ?: \PHP_INT_MAX);
}
/**
* Returns an informative upload error message.
*
* @return string The error message regarding the specified error code
*/
public function getErrorMessage(): string
{
return match ($this->error) {
\UPLOAD_ERR_INI_SIZE => 'The file "%s" exceeds your upload_max_filesize ini directive.',
\UPLOAD_ERR_FORM_SIZE => 'The file "%s" exceeds the upload limit defined in your form.',
\UPLOAD_ERR_PARTIAL => 'The file "%s" was only partially uploaded.',
\UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
\UPLOAD_ERR_CANT_WRITE => 'The file "%s" could not be written on disk.',
\UPLOAD_ERR_NO_TMP_DIR => 'File could not be uploaded: missing temporary directory.',
\UPLOAD_ERR_EXTENSION => 'File upload was stopped by a PHP extension.',
default => 'The file "%s" was not uploaded due to an unknown error.',
};
}
/**
* Returns locale independent base name of the given path.
*
* @param string $name The new file name
*
* @return string The base name
*/
protected function getName(string $name): string
{
$originalName = str_replace('\\', '/', $name);
$pos = strrpos($originalName, '/');
$originalName = false === $pos ? $originalName : substr($originalName, $pos + 1);
return $originalName;
}
protected function getTargetFile(string $directory, ?string $name = null): string
{
if (!is_dir($directory)) {
if (false === @mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException(sprintf('Unable to create the "%s" directory.', $directory));
}
} elseif (!is_writable($directory)) {
throw new \RuntimeException(sprintf('Unable to write in the "%s" directory.', $directory));
}
$target = rtrim($directory, '/\\') . \DIRECTORY_SEPARATOR . (null === $name ? $this->getBasename() : $this->getName($name));
return $target;
}
/**
* Moves the file to a new location (used in test mode).
*/
protected function doMove(string $directory, ?string $name = null): \SplFileInfo
{
$target = $this->getTargetFile($directory, $name);
if (!@rename($this->getPathname(), $target)) {
$error = error_get_last();
throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error')));
}
@chmod($target, 0666 & ~umask());
return new \SplFileInfo($target);
}
private static function parseFilesize(string $size): int|float
{
if ('' === $size) {
return 0;
}
$size = strtolower($size);
$max = ltrim($size, '+');
if (str_starts_with($max, '0x')) {
$max = \intval($max, 16);
} elseif (str_starts_with($max, '0')) {
$max = \intval($max, 8);
} else {
$max = (int) $max;
}
switch (substr($size, -1)) {
case 't': $max *= 1024;
// no break
case 'g': $max *= 1024;
// no break
case 'm': $max *= 1024;
// no break
case 'k': $max *= 1024;
}
return $max;
}
private static function mimeToExtension(string $mimeType): ?string
{
$map = [
'application/pdf' => 'pdf',
'application/zip' => 'zip',
'application/json' => 'json',
'application/xml' => 'xml',
'application/octet-stream' => 'bin',
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/svg+xml' => 'svg',
'text/plain' => 'txt',
'text/html' => 'html',
'text/css' => 'css',
'text/javascript' => 'js',
'audio/mpeg' => 'mp3',
'audio/wav' => 'wav',
'video/mp4' => 'mp4',
'video/webm' => 'webm',
];
return $map[$mimeType] ?? null;
}
}

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http;
/**
* HeaderBag is a container for HTTP headers.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @implements \IteratorAggregate<string, list<string|null>>
*/
class HeaderParameters implements \IteratorAggregate, \Countable, \Stringable
{
protected const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
protected const LOWER = '-abcdefghijklmnopqrstuvwxyz';
/**
* @var array<string, list<string|null>>
*/
protected array $headers = [];
protected array $cacheControl = [];
public function __construct(array $headers = [])
{
foreach ($headers as $key => $values) {
$this->set($key, $values);
}
}
/**
* Returns the headers as a string.
*/
public function __toString(): string
{
if (!$headers = $this->all()) {
return '';
}
ksort($headers);
$max = max(array_map('strlen', array_keys($headers))) + 1;
$content = '';
foreach ($headers as $name => $values) {
$name = ucwords($name, '-');
foreach ($values as $value) {
$content .= \sprintf("%-{$max}s %s\r\n", $name.':', $value);
}
}
return $content;
}
/**
* Returns the headers.
*
* @param string|null $key The name of the headers to return or null to get them all
*
* @return ($key is null ? array<string, list<string|null>> : list<string|null>)
*/
public function all(?string $key = null): array
{
if (null !== $key) {
return $this->headers[strtr($key, self::UPPER, self::LOWER)] ?? [];
}
return $this->headers;
}
/**
* Returns the parameter keys.
*
* @return string[]
*/
public function keys(): array
{
return array_keys($this->all());
}
/**
* Replaces the current HTTP headers by a new set.
*/
public function replace(array $headers = []): void
{
$this->headers = [];
$this->add($headers);
}
/**
* Adds new headers the current HTTP headers set.
*/
public function add(array $headers): void
{
foreach ($headers as $key => $values) {
$this->set($key, $values);
}
}
/**
* Returns the first header by name or the default one.
*/
public function get(string $key, ?string $default = null): ?string
{
$headers = $this->all($key);
if (!$headers) {
return $default;
}
if (null === $headers[0]) {
return null;
}
return $headers[0];
}
/**
* Sets a header by name.
*
* @param string|string[]|null $values The value or an array of values
* @param bool $replace Whether to replace the actual value or not (true by default)
*/
public function set(string $key, string|array|null $values, bool $replace = true): void
{
$key = strtr($key, self::UPPER, self::LOWER);
if (\is_array($values)) {
$values = array_values($values);
if (true === $replace || !isset($this->headers[$key])) {
$this->headers[$key] = $values;
} else {
$this->headers[$key] = array_merge($this->headers[$key], $values);
}
} else {
if (true === $replace || !isset($this->headers[$key])) {
$this->headers[$key] = [$values];
} else {
$this->headers[$key][] = $values;
}
}
if ('cache-control' === $key) {
$this->cacheControl = $this->parseCacheControl(implode(', ', $this->headers[$key]));
}
}
/**
* Returns true if the HTTP header is defined.
*/
public function has(string $key): bool
{
return \array_key_exists(strtr($key, self::UPPER, self::LOWER), $this->all());
}
/**
* Returns true if the given HTTP header contains the given value.
*/
public function contains(string $key, string $value): bool
{
return \in_array($value, $this->all($key), true);
}
/**
* Removes a header.
*/
public function remove(string $key): void
{
$key = strtr($key, self::UPPER, self::LOWER);
unset($this->headers[$key]);
if ('cache-control' === $key) {
$this->cacheControl = [];
}
}
/**
* Returns the HTTP header value converted to a date.
*
* @throws \RuntimeException When the HTTP header is not parseable
*/
public function getDate(string $key, ?\DateTimeInterface $default = null): ?\DateTimeImmutable
{
if (null === $value = $this->get($key)) {
return null !== $default ? \DateTimeImmutable::createFromInterface($default) : null;
}
if (false === $date = \DateTimeImmutable::createFromFormat(\DATE_RFC2822, $value)) {
throw new \RuntimeException(\sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value));
}
return $date;
}
/**
* Adds a custom Cache-Control directive.
*/
public function addCacheControlDirective(string $key, bool|string $value = true): void
{
$this->cacheControl[$key] = $value;
$this->set('Cache-Control', $this->getCacheControlHeader());
}
/**
* Returns true if the Cache-Control directive is defined.
*/
public function hasCacheControlDirective(string $key): bool
{
return \array_key_exists($key, $this->cacheControl);
}
/**
* Returns a Cache-Control directive value by name.
*/
public function getCacheControlDirective(string $key): bool|string|null
{
return $this->cacheControl[$key] ?? null;
}
/**
* Removes a Cache-Control directive.
*/
public function removeCacheControlDirective(string $key): void
{
unset($this->cacheControl[$key]);
$this->set('Cache-Control', $this->getCacheControlHeader());
}
/**
* Returns an iterator for headers.
*
* @return \ArrayIterator<string, list<string|null>>
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->headers);
}
/**
* Returns the number of headers.
*/
public function count(): int
{
return \count($this->headers);
}
protected function getCacheControlHeader(): string
{
ksort($this->cacheControl);
return HeaderUtils::toString($this->cacheControl, ',');
}
/**
* Parses a Cache-Control HTTP header.
*/
protected function parseCacheControl(string $header): array
{
$parts = HeaderUtils::split($header, ',=');
return HeaderUtils::combine($parts);
}
}

View File

@@ -0,0 +1,298 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http;
/**
* HTTP header utility functions.
*
* @author Christian Schmidt <github@chsc.dk>
*/
class HeaderUtils
{
public const DISPOSITION_ATTACHMENT = 'attachment';
public const DISPOSITION_INLINE = 'inline';
/**
* This class should not be instantiated.
*/
private function __construct()
{
}
/**
* Splits an HTTP header by one or more separators.
*
* Example:
*
* HeaderUtils::split('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 ',;='
*
* @return array Nested array with as many levels as there are characters in
* $separators
*/
public static function split(string $header, string $separators): array
{
if ('' === $separators) {
throw new \InvalidArgumentException('At least one separator must be specified.');
}
$quotedSeparators = preg_quote($separators, '/');
preg_match_all('
/
(?!\s)
(?:
# quoted-string
"(?:[^"\\\\]|\\\\.)*(?:"|\\\\|$)
|
# token
[^"'.$quotedSeparators.']+
)+
(?<!\s)
|
# separator
\s*
(?<separator>['.$quotedSeparators.'])
\s*
/x', trim($header), $matches, \PREG_SET_ORDER);
return self::groupParts($matches, $separators);
}
/**
* Combines an array of arrays into one associative array.
*
* Each of the nested arrays should have one or two elements. The first
* value will be used as the keys in the associative array, and the second
* will be used as the values, or true if the nested array only contains one
* element. Array keys are lowercased.
*
* Example:
*
* HeaderUtils::combine([['foo', 'abc'], ['bar']])
* // => ['foo' => 'abc', 'bar' => true]
*/
public static function combine(array $parts): array
{
$assoc = [];
foreach ($parts as $part) {
$name = strtolower($part[0]);
$value = $part[1] ?? true;
$assoc[$name] = $value;
}
return $assoc;
}
/**
* Joins an associative array into a string for use in an HTTP header.
*
* The key and value of each entry are joined with '=', and all entries
* are joined with the specified separator and an additional space (for
* readability). Values are quoted if necessary.
*
* Example:
*
* HeaderUtils::toString(['foo' => 'abc', 'bar' => true, 'baz' => 'a b c'], ',')
* // => 'foo=abc, bar, baz="a b c"'
*/
public static function toString(array $assoc, string $separator): string
{
$parts = [];
foreach ($assoc as $name => $value) {
if (true === $value) {
$parts[] = $name;
} else {
$parts[] = $name.'='.self::quote($value);
}
}
return implode($separator.' ', $parts);
}
/**
* Encodes a string as a quoted string, if necessary.
*
* If a string contains characters not allowed by the "token" construct in
* the HTTP specification, it is backslash-escaped and enclosed in quotes
* to match the "quoted-string" construct.
*/
public static function quote(string $s): string
{
if (preg_match('/^[a-z0-9!#$%&\'*.^_`|~-]+$/i', $s)) {
return $s;
}
return '"'.addcslashes($s, '"\\"').'"';
}
/**
* Decodes a quoted string.
*
* If passed an unquoted string that matches the "token" construct (as
* defined in the HTTP specification), it is passed through verbatim.
*/
public static function unquote(string $s): string
{
return preg_replace('/\\\\(.)|"/', '$1', $s);
}
/**
* Generates an HTTP Content-Disposition field-value.
*
* @param string $disposition One of "inline" or "attachment"
* @param string $filename A unicode string
* @param string $filenameFallback A string containing only ASCII characters that
* is semantically equivalent to $filename. If the filename is already ASCII,
* it can be omitted, or just copied from $filename
*
* @throws \InvalidArgumentException
*
* @see RFC 6266
*/
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));
}
if ('' === $filenameFallback) {
$filenameFallback = $filename;
}
// filenameFallback is not ASCII.
if (!preg_match('/^[\x20-\x7e]*$/', $filenameFallback)) {
throw new \InvalidArgumentException('The filename fallback must only contain ASCII characters.');
}
// percent characters aren't safe in fallback.
if (str_contains($filenameFallback, '%')) {
throw new \InvalidArgumentException('The filename fallback cannot contain the "%" character.');
}
// path separators aren't allowed in either.
if (str_contains($filename, '/') || str_contains($filename, '\\') || str_contains($filenameFallback, '/') || str_contains($filenameFallback, '\\')) {
throw new \InvalidArgumentException('The filename and the fallback cannot contain the "/" and "\\" characters.');
}
$params = ['filename' => $filenameFallback];
if ($filename !== $filenameFallback) {
$params['filename*'] = "utf-8''".rawurlencode($filename);
}
return $disposition.'; '.self::toString($params, ';');
}
/**
* Like parse_str(), but preserves dots in variable names.
*/
public static function parseQuery(string $query, bool $ignoreBrackets = false, string $separator = '&'): array
{
$q = [];
foreach (explode($separator, $query) as $v) {
if (false !== $i = strpos($v, "\0")) {
$v = substr($v, 0, $i);
}
if (false === $i = strpos($v, '=')) {
$k = urldecode($v);
$v = '';
} else {
$k = urldecode(substr($v, 0, $i));
$v = substr($v, $i);
}
if (false !== $i = strpos($k, "\0")) {
$k = substr($k, 0, $i);
}
$k = ltrim($k, ' ');
if ($ignoreBrackets) {
$q[$k][] = urldecode(substr($v, 1));
continue;
}
if (false === $i = strpos($k, '[')) {
$q[] = bin2hex($k).$v;
} else {
$q[] = bin2hex(substr($k, 0, $i)).rawurlencode(substr($k, $i)).$v;
}
}
if ($ignoreBrackets) {
return $q;
}
parse_str(implode('&', $q), $q);
$query = [];
foreach ($q as $k => $v) {
if (false !== $i = strpos($k, '_')) {
$query[substr_replace($k, hex2bin(substr($k, 0, $i)).'[', 0, 1 + $i)] = $v;
} else {
$query[hex2bin($k)] = $v;
}
}
return $query;
}
private static function groupParts(array $matches, string $separators, bool $first = true): array
{
$separator = $separators[0];
$separators = substr($separators, 1) ?: '';
$i = 0;
if ('' === $separators && !$first) {
$parts = [''];
foreach ($matches as $match) {
if (!$i && isset($match['separator'])) {
$i = 1;
$parts[1] = '';
} else {
$parts[$i] .= self::unquote($match[0]);
}
}
return $parts;
}
$parts = [];
$partMatches = [];
foreach ($matches as $match) {
if (($match['separator'] ?? null) === $separator) {
++$i;
} else {
$partMatches[$i][] = $match;
}
}
foreach ($partMatches as $matches) {
if ('' === $separators && '' !== $unquoted = self::unquote($matches[0][0])) {
$parts[] = $unquoted;
} elseif ($groupedParts = self::groupParts($matches, $separators, false)) {
$parts[] = $groupedParts;
}
}
return $parts;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Request;
use KTXC\Http\File\UploadedFile;
/**
* FileBag is a container for uploaded files.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Bulat Shakirzyanov <mallluhuct@gmail.com>
*/
class RequestFileCollection extends RequestParameters
{
private const FILE_KEYS = ['error', 'full_path', 'name', 'size', 'tmp_name', 'type'];
/**
* @param array|UploadedFile[] $parameters An array of HTTP files
*/
public function __construct(array $parameters = [])
{
$this->replace($parameters);
}
public function replace(array $files = []): void
{
$this->parameters = [];
$this->add($files);
}
public function set(string $key, mixed $value): void
{
if (!\is_array($value) && !$value instanceof UploadedFile) {
throw new \InvalidArgumentException('An uploaded file must be an array or an instance of UploadedFile.');
}
parent::set($key, $this->convertFileInformation($value));
}
public function add(array $files = []): void
{
foreach ($files as $key => $file) {
$this->set($key, $file);
}
}
/**
* Converts uploaded files to UploadedFile instances.
*
* @return UploadedFile[]|UploadedFile|null
*/
protected function convertFileInformation(array|UploadedFile $file): array|UploadedFile|null
{
if ($file instanceof UploadedFile) {
return $file;
}
$file = $this->fixPhpFilesArray($file);
$keys = array_keys($file + ['full_path' => null]);
sort($keys);
if (self::FILE_KEYS === $keys) {
if (\UPLOAD_ERR_NO_FILE === $file['error']) {
$file = null;
} else {
$file = new UploadedFile($file['tmp_name'], $file['full_path'] ?? $file['name'], $file['type'], $file['error'], false);
}
} else {
$file = array_map(fn ($v) => $v instanceof UploadedFile || \is_array($v) ? $this->convertFileInformation($v) : $v, $file);
if (array_is_list($file)) {
$file = array_filter($file);
}
}
return $file;
}
/**
* Fixes a malformed PHP $_FILES array.
*
* PHP has a bug that the format of the $_FILES array differs, depending on
* whether the uploaded file fields had normal field names or array-like
* field names ("normal" vs. "parent[child]").
*
* This method fixes the array to look like the "normal" $_FILES array.
*
* It's safe to pass an already converted array, in which case this method
* just returns the original array unmodified.
*/
protected function fixPhpFilesArray(array $data): array
{
$keys = array_keys($data + ['full_path' => null]);
sort($keys);
if (self::FILE_KEYS !== $keys || !isset($data['name']) || !\is_array($data['name'])) {
return $data;
}
$files = $data;
foreach (self::FILE_KEYS as $k) {
unset($files[$k]);
}
foreach ($data['name'] as $key => $name) {
$files[$key] = $this->fixPhpFilesArray([
'error' => $data['error'][$key],
'name' => $name,
'type' => $data['type'][$key],
'tmp_name' => $data['tmp_name'][$key],
'size' => $data['size'][$key],
] + (isset($data['full_path'][$key]) ? [
'full_path' => $data['full_path'][$key],
] : []));
}
return $files;
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Request;
use KTXC\Http\HeaderUtils;
// Help opcache.preload discover always-needed symbols
class_exists(RequestHeaderAcceptItem::class);
/**
* Represents an Accept-* header.
*
* An accept header is compound with a list of items,
* sorted by descending quality.
*
* @author Jean-François Simon <contact@jfsimon.fr>
*/
class RequestHeaderAccept
{
/**
* @var RequestHeaderAcceptItem[]
*/
private array $items = [];
private bool $sorted = true;
/**
* @param RequestHeaderAcceptItem[] $items
*/
public function __construct(array $items)
{
foreach ($items as $item) {
$this->add($item);
}
}
/**
* Builds an AcceptHeader instance from a string.
*/
public static function fromString(?string $headerValue): self
{
$parts = HeaderUtils::split($headerValue ?? '', ',;=');
return new self(array_map(function ($subParts) {
static $index = 0;
$part = array_shift($subParts);
$attributes = HeaderUtils::combine($subParts);
$item = new RequestHeaderAcceptItem($part[0], $attributes);
$item->setIndex($index++);
return $item;
}, $parts));
}
/**
* Returns header value's string representation.
*/
public function __toString(): string
{
return implode(',', $this->items);
}
/**
* Tests if header has given value.
*/
public function has(string $value): bool
{
return isset($this->items[$value]);
}
/**
* Returns given value's item, if exists.
*/
public function get(string $value): ?RequestHeaderAcceptItem
{
return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null;
}
/**
* Adds an item.
*
* @return $this
*/
public function add(RequestHeaderAcceptItem $item): static
{
$this->items[$item->getValue()] = $item;
$this->sorted = false;
return $this;
}
/**
* Returns all items.
*
* @return RequestHeaderAcceptItem[]
*/
public function all(): array
{
$this->sort();
return $this->items;
}
/**
* Filters items on their value using given regex.
*/
public function filter(string $pattern): self
{
return new self(array_filter($this->items, fn (RequestHeaderAcceptItem $item) => preg_match($pattern, $item->getValue())));
}
/**
* Returns first item.
*/
public function first(): ?RequestHeaderAcceptItem
{
$this->sort();
return $this->items ? reset($this->items) : null;
}
/**
* Sorts items by descending quality.
*/
private function sort(): void
{
if (!$this->sorted) {
uasort($this->items, function (RequestHeaderAcceptItem $a, RequestHeaderAcceptItem $b) {
$qA = $a->getQuality();
$qB = $b->getQuality();
if ($qA === $qB) {
return $a->getIndex() > $b->getIndex() ? 1 : -1;
}
return $qA > $qB ? -1 : 1;
});
$this->sorted = true;
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Request;
use KTXC\Http\HeaderUtils;
/**
* Represents an Accept-* header item.
*
* @author Jean-François Simon <contact@jfsimon.fr>
*/
class RequestHeaderAcceptItem
{
private float $quality = 1.0;
private int $index = 0;
private array $attributes = [];
public function __construct(
private string $value,
array $attributes = [],
) {
foreach ($attributes as $name => $value) {
$this->setAttribute($name, $value);
}
}
/**
* Builds an AcceptHeaderInstance instance from a string.
*/
public static function fromString(?string $itemValue): self
{
$parts = HeaderUtils::split($itemValue ?? '', ';=');
$part = array_shift($parts);
$attributes = HeaderUtils::combine($parts);
return new self($part[0], $attributes);
}
/**
* Returns header value's string representation.
*/
public function __toString(): string
{
$string = $this->value.($this->quality < 1 ? ';q='.$this->quality : '');
if (\count($this->attributes) > 0) {
$string .= '; '.HeaderUtils::toString($this->attributes, ';');
}
return $string;
}
/**
* Set the item value.
*
* @return $this
*/
public function setValue(string $value): static
{
$this->value = $value;
return $this;
}
/**
* Returns the item value.
*/
public function getValue(): string
{
return $this->value;
}
/**
* Set the item quality.
*
* @return $this
*/
public function setQuality(float $quality): static
{
$this->quality = $quality;
return $this;
}
/**
* Returns the item quality.
*/
public function getQuality(): float
{
return $this->quality;
}
/**
* Set the item index.
*
* @return $this
*/
public function setIndex(int $index): static
{
$this->index = $index;
return $this;
}
/**
* Returns the item index.
*/
public function getIndex(): int
{
return $this->index;
}
/**
* Tests if an attribute exists.
*/
public function hasAttribute(string $name): bool
{
return isset($this->attributes[$name]);
}
/**
* Returns an attribute by its name.
*/
public function getAttribute(string $name, mixed $default = null): mixed
{
return $this->attributes[$name] ?? $default;
}
/**
* Returns all attributes.
*/
public function getAttributes(): array
{
return $this->attributes;
}
/**
* Set an attribute.
*
* @return $this
*/
public function setAttribute(string $name, string $value): static
{
if ('q' === $name) {
$this->quality = (float) $value;
} else {
$this->attributes[$name] = $value;
}
return $this;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Request;
use KTXC\Http\HeaderParameters;
/**
* HeaderBag is a container for HTTP headers.
*/
class RequestHeaderParameters extends HeaderParameters {}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Request;
use KTXC\Http\Exception\BadRequestException;
use KTXC\Http\Exception\UnexpectedValueException;
/**
* InputBag is a container for user input values such as $_GET, $_POST, $_REQUEST, and $_COOKIE.
*
* @author Saif Eddin Gmati <azjezz@protonmail.com>
*/
final class RequestInputParameters extends RequestParameters
{
/**
* Returns an input value by name (scalar, Stringable, or array).
*
* Arrays are now allowed. (Previously only scalar values were permitted.)
* No deep validation of array contents is performed here; callers should
* sanitize nested values as needed.
*
* @param string|int|float|bool|array|null $default The default value if the key does not exist
*
* @return string|int|float|bool|array|null
*
* @throws BadRequestException if the stored input value is of an unsupported type
* @throws \InvalidArgumentException if the provided default is of an unsupported type
*/
public function get(string $key, mixed $default = null): string|int|float|bool|array|null
{
if (null !== $default && !\is_scalar($default) && !$default instanceof \Stringable && !\is_array($default)) {
throw new \InvalidArgumentException(\sprintf('Expected a scalar or array 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 && !\is_array($value)) {
throw new BadRequestException(\sprintf('Input value "%s" contains an invalid (non-scalar, non-array, non-Stringable) value.', $key));
}
return $this === $value ? $default : $value;
}
/**
* Replaces the current input values by a new set.
*/
public function replace(array $inputs = []): void
{
$this->parameters = [];
$this->add($inputs);
}
/**
* Adds input values.
*/
public function add(array $inputs = []): void
{
foreach ($inputs as $input => $value) {
$this->set($input, $value);
}
}
/**
* Sets an input by name.
*
* @param string|int|float|bool|array|null $value
*/
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)));
}
$this->parameters[$key] = $value;
}
/**
* Returns the parameter value converted to an enum.
*
* @template T of \BackedEnum
*
* @param class-string<T> $class
* @param ?T $default
*
* @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
{
try {
return parent::getEnum($key, $class, $default);
} catch (UnexpectedValueException $e) {
throw new BadRequestException($e->getMessage(), $e->getCode(), $e);
}
}
/**
* 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
{
// Shortcuts the parent method because the validation on scalar is already done in get().
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;
// Always turn $options into an array - this allows filter_var option shortcuts.
if (!\is_array($options) && $options) {
$options = ['flags' => $options];
}
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));
}
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)));
}
$options['flags'] ??= 0;
$nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE;
$options['flags'] |= \FILTER_NULL_ON_FAILURE;
$value = filter_var($value, $filter, $options);
if (null !== $value || $nullOnFailure) {
return $value;
}
throw new BadRequestException(\sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key));
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Request;
use KTXC\Http\Exception\BadRequestException;
use KTXC\Http\Exception\UnexpectedValueException;
/**
* ParameterBag is a container for key/value pairs.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @implements \IteratorAggregate<string, mixed>
*/
class RequestParameters implements \IteratorAggregate, \Countable
{
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
{
if (null === $key) {
return $this->parameters;
}
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)));
}
return $value;
}
/**
* Returns the parameter keys.
*/
public function keys(): array
{
return array_keys($this->parameters);
}
/**
* Replaces the current parameters by a new set.
*/
public function replace(array $parameters = []): void
{
$this->parameters = $parameters;
}
/**
* Adds parameters.
*/
public function add(array $parameters = []): void
{
$this->parameters = array_replace($this->parameters, $parameters);
}
public function get(string $key, mixed $default = null): mixed
{
return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default;
}
public function set(string $key, mixed $value): void
{
$this->parameters[$key] = $value;
}
/**
* Returns true if the parameter is defined.
*/
public function has(string $key): bool
{
return \array_key_exists($key, $this->parameters);
}
/**
* Removes a parameter.
*/
public function remove(string $key): void
{
unset($this->parameters[$key]);
}
/**
* 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
{
return preg_replace('/[^[:alpha:]]/', '', $this->getString($key, $default));
}
/**
* 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
{
return preg_replace('/[^[:alnum:]]/', '', $this->getString($key, $default));
}
/**
* 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
{
return preg_replace('/[^[:digit:]]/', '', $this->getString($key, $default));
}
/**
* 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));
}
return (string) $value;
}
/**
* 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
{
return $this->filter($key, $default, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR]);
}
/**
* 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
{
return $this->filter($key, $default, \FILTER_VALIDATE_BOOL, ['flags' => \FILTER_REQUIRE_SCALAR]);
}
/**
* Returns the parameter value converted to an enum.
*
* @template T of \BackedEnum
*
* @param class-string<T> $class
* @param ?T $default
*
* @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
{
$value = $this->get($key);
if (null === $value) {
return $default;
}
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);
}
}
/**
* Filter key.
*
* @param int $filter FILTER_* constant
* @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
{
$value = $this->get($key, $default);
// Always turn $options into an array - this allows filter_var option shortcuts.
if (!\is_array($options) && $options) {
$options = ['flags' => $options];
}
// Add a convenience check for arrays.
if (\is_array($value) && !isset($options['flags'])) {
$options['flags'] = \FILTER_REQUIRE_ARRAY;
}
if (\is_object($value) && !$value instanceof \Stringable) {
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)));
}
$options['flags'] ??= 0;
$nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE;
$options['flags'] |= \FILTER_NULL_ON_FAILURE;
$value = filter_var($value, $filter, $options);
if (null !== $value || $nullOnFailure) {
return $value;
}
throw new \UnexpectedValueException(\sprintf('Parameter value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key));
}
/**
* Returns an iterator for parameters.
*
* @return \ArrayIterator<string, mixed>
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->parameters);
}
/**
* Returns the number of parameters.
*/
public function count(): int
{
return \count($this->parameters);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Request;
/**
* ServerBag is a container for HTTP headers from the $_SERVER variable.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Bulat Shakirzyanov <mallluhuct@gmail.com>
* @author Robert Kiss <kepten@gmail.com>
*/
class RequestServerParameters extends RequestParameters
{
/**
* Gets the HTTP headers.
*/
public function getHeaders(): array
{
$headers = [];
foreach ($this->parameters as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$headers[substr($key, 5)] = $value;
} elseif (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true) && '' !== $value) {
$headers[$key] = $value;
}
}
if (isset($this->parameters['PHP_AUTH_USER'])) {
$headers['PHP_AUTH_USER'] = $this->parameters['PHP_AUTH_USER'];
$headers['PHP_AUTH_PW'] = $this->parameters['PHP_AUTH_PW'] ?? '';
} else {
/*
* php-cgi under Apache does not pass HTTP Basic user/pass to PHP by default
* For this workaround to work, add these lines to your .htaccess file:
* RewriteCond %{HTTP:Authorization} .+
* RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
*
* A sample .htaccess file:
* RewriteEngine On
* RewriteCond %{HTTP:Authorization} .+
* RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
* RewriteCond %{REQUEST_FILENAME} !-f
* RewriteRule ^(.*)$ index.php [QSA,L]
*/
$authorizationHeader = null;
if (isset($this->parameters['HTTP_AUTHORIZATION'])) {
$authorizationHeader = $this->parameters['HTTP_AUTHORIZATION'];
} elseif (isset($this->parameters['REDIRECT_HTTP_AUTHORIZATION'])) {
$authorizationHeader = $this->parameters['REDIRECT_HTTP_AUTHORIZATION'];
}
if (null !== $authorizationHeader) {
if (0 === stripos($authorizationHeader, 'basic ')) {
// Decode AUTHORIZATION header into PHP_AUTH_USER and PHP_AUTH_PW when authorization header is basic
$exploded = explode(':', base64_decode(substr($authorizationHeader, 6)), 2);
if (2 == \count($exploded)) {
[$headers['PHP_AUTH_USER'], $headers['PHP_AUTH_PW']] = $exploded;
}
} elseif (empty($this->parameters['PHP_AUTH_DIGEST']) && (0 === stripos($authorizationHeader, 'digest '))) {
// In some circumstances PHP_AUTH_DIGEST needs to be set
$headers['PHP_AUTH_DIGEST'] = $authorizationHeader;
$this->parameters['PHP_AUTH_DIGEST'] = $authorizationHeader;
} elseif (0 === stripos($authorizationHeader, 'bearer ')) {
/*
* XXX: Since there is no PHP_AUTH_BEARER in PHP predefined variables,
* I'll just set $headers['AUTHORIZATION'] here.
* https://php.net/reserved.variables.server
*/
$headers['AUTHORIZATION'] = $authorizationHeader;
}
}
}
if (isset($headers['AUTHORIZATION'])) {
return $headers;
}
// PHP_AUTH_USER/PHP_AUTH_PW
if (isset($headers['PHP_AUTH_USER'])) {
$headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.($headers['PHP_AUTH_PW'] ?? ''));
} elseif (isset($headers['PHP_AUTH_DIGEST'])) {
$headers['AUTHORIZATION'] = $headers['PHP_AUTH_DIGEST'];
}
return $headers;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Response;
/**
* Simple file response that reads a file from disk and serves it.
*
* Only supports sending full file contents (no range / streaming for now).
*/
class FileResponse extends Response
{
private string $filePath;
public function __construct(string $filePath, int $status = 200, array $headers = [])
{
if (!is_file($filePath) || !is_readable($filePath)) {
throw new \InvalidArgumentException(sprintf('FileResponse: file not found or not readable: %s', $filePath));
}
$this->filePath = $filePath;
// Determine content type (very small helper; rely on common extensions)
$mime = self::guessMimeType($filePath) ?? 'application/octet-stream';
$headers['Content-Type'] = $headers['Content-Type'] ?? $mime;
$headers['Content-Length'] = (string) filesize($filePath);
$headers['Last-Modified'] = gmdate('D, d M Y H:i:s', filemtime($filePath)) . ' GMT';
$headers['Cache-Control'] = $headers['Cache-Control'] ?? 'public, max-age=60';
parent::__construct('', $status, $headers);
// Defer reading file until sendContent to avoid memory usage.
}
public function getFilePath(): string
{
return $this->filePath;
}
public function sendContent(): static
{
// Output file contents directly
readfile($this->filePath);
return $this;
}
private static function guessMimeType(string $filePath): ?string
{
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
return match ($ext) {
'html', 'htm' => 'text/html; charset=UTF-8',
'css' => 'text/css; charset=UTF-8',
'js' => 'application/javascript; charset=UTF-8',
'json' => 'application/json; charset=UTF-8',
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'txt' => 'text/plain; charset=UTF-8',
'xml' => 'application/xml; charset=UTF-8',
default => self::finfoMime($filePath),
};
}
private static function finfoMime(string $filePath): ?string
{
if (function_exists('finfo_open')) {
$f = finfo_open(FILEINFO_MIME_TYPE);
if ($f) {
$mime = finfo_file($f, $filePath) ?: null;
finfo_close($f);
return $mime;
}
}
return null;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Response;
/**
* Response represents an HTTP response in JSON format.
*
* Note that this class does not force the returned JSON content to be an
* object. It is however recommended that you do return an object as it
* protects yourself against XSSI and JSON-JavaScript Hijacking.
*
* @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside
*
* @author Igor Wiedler <igor@wiedler.ch>
*/
class JsonResponse extends Response
{
protected mixed $data;
protected ?string $callback = null;
// Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML.
// 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
public const DEFAULT_ENCODING_OPTIONS = 15;
protected int $encodingOptions = self::DEFAULT_ENCODING_OPTIONS;
/**
* @param bool $json If the data is already a JSON string
*/
public function __construct(mixed $data = null, int $status = 200, array $headers = [], bool $json = false)
{
parent::__construct('', $status, $headers);
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();
$json ? $this->setJson($data) : $this->setData($data);
}
/**
* Factory method for chainability.
*
* Example:
*
* return JsonResponse::fromJsonString('{"key": "value"}')
* ->setSharedMaxAge(300);
*
* @param string $data The JSON response string
* @param int $status The response status code (200 "OK" by default)
* @param array $headers An array of response headers
*/
public static function fromJsonString(string $data, int $status = 200, array $headers = []): static
{
return new static($data, $status, $headers, true);
}
/**
* Sets the JSONP callback.
*
* @param string|null $callback The JSONP callback or null to use none
*
* @return $this
*
* @throws \InvalidArgumentException When the callback name is not valid
*/
public function setCallback(?string $callback): static
{
if (null !== $callback) {
// partially taken from https://geekality.net/2011/08/03/valid-javascript-identifier/
// partially taken from https://github.com/willdurand/JsonpCallbackValidator
// JsonpCallbackValidator is released under the MIT License. See https://github.com/willdurand/JsonpCallbackValidator/blob/v1.1.0/LICENSE for details.
// (c) William Durand <william.durand1@gmail.com>
$pattern = '/^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\x{200C}\x{200D}]*(?:\[(?:"(?:\\\.|[^"\\\])*"|\'(?:\\\.|[^\'\\\])*\'|\d+)\])*?$/u';
$reserved = [
'break', 'do', 'instanceof', 'typeof', 'case', 'else', 'new', 'var', 'catch', 'finally', 'return', 'void', 'continue', 'for', 'switch', 'while',
'debugger', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in', 'try', 'class', 'enum', 'extends', 'super', 'const', 'export',
'import', 'implements', 'let', 'private', 'public', 'yield', 'interface', 'package', 'protected', 'static', 'null', 'true', 'false',
];
$parts = explode('.', $callback);
foreach ($parts as $part) {
if (!preg_match($pattern, $part) || \in_array($part, $reserved, true)) {
throw new \InvalidArgumentException('The callback name is not valid.');
}
}
}
$this->callback = $callback;
return $this->update();
}
/**
* Sets a raw string containing a JSON document to be sent.
*
* @return $this
*/
public function setJson(string $json): static
{
$this->data = $json;
return $this->update();
}
/**
* Sets the data to be sent as JSON.
*
* @return $this
*
* @throws \InvalidArgumentException
*/
public function setData(mixed $data = []): static
{
try {
$data = json_encode($data, $this->encodingOptions);
} catch (\Exception $e) {
if ('Exception' === $e::class && str_starts_with($e->getMessage(), 'Failed calling ')) {
throw $e->getPrevious() ?: $e;
}
throw $e;
}
if (\JSON_THROW_ON_ERROR & $this->encodingOptions) {
return $this->setJson($data);
}
if (\JSON_ERROR_NONE !== json_last_error()) {
throw new \InvalidArgumentException(json_last_error_msg());
}
return $this->setJson($data);
}
/**
* Returns options used while encoding data to JSON.
*/
public function getEncodingOptions(): int
{
return $this->encodingOptions;
}
/**
* Sets options used while encoding data to JSON.
*
* @return $this
*/
public function setEncodingOptions(int $encodingOptions): static
{
$this->encodingOptions = $encodingOptions;
return $this->setData(json_decode($this->data));
}
/**
* Updates the content and headers according to the JSON data and callback.
*
* @return $this
*/
protected function update(): static
{
if (null !== $this->callback) {
// 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));
}
// Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback)
// in order to not overwrite a custom definition.
if (!$this->headers->has('Content-Type') || 'text/javascript' === $this->headers->get('Content-Type')) {
$this->headers->set('Content-Type', 'application/json');
}
return $this->setContent($this->data);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Response;
/**
* RedirectResponse represents an HTTP response doing a redirect.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class RedirectResponse extends Response
{
protected string $targetUrl;
/**
* Creates a redirect response so that it conforms to the rules defined for a redirect status code.
*
* @param string $url The URL to redirect to. The URL should be a full URL, with schema etc.,
* but practically every browser redirects on paths only as well
* @param int $status The HTTP status code (302 "Found" by default)
* @param array $headers The headers (Location is always set to the given URL)
*
* @throws \InvalidArgumentException
*
* @see https://tools.ietf.org/html/rfc2616#section-10.3
*/
public function __construct(string $url, int $status = 302, array $headers = [])
{
parent::__construct('', $status, $headers);
$this->setTargetUrl($url);
if (!$this->isRedirect()) {
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))) {
$this->headers->remove('cache-control');
}
}
/**
* Returns the target URL.
*/
public function getTargetUrl(): string
{
return $this->targetUrl;
}
/**
* Sets the redirect target of this response.
*
* @return $this
*
* @throws \InvalidArgumentException
*/
public function setTargetUrl(string $url): static
{
if ('' === $url) {
throw new \InvalidArgumentException('Cannot redirect to an empty URL.');
}
$this->targetUrl = $url;
$this->setContent(
\sprintf('<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="refresh" content="0;url=\'%1$s\'" />
<title>Redirecting to %1$s</title>
</head>
<body>
Redirecting to <a href="%1$s">%1$s</a>.
</body>
</html>', htmlspecialchars($url, \ENT_QUOTES, 'UTF-8')));
$this->headers->set('Location', $url);
$this->headers->set('Content-Type', 'text/html; charset=utf-8');
return $this;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,275 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Response;
use KTXC\Http\Cookie;
use KTXC\Http\HeaderParameters;
use KTXC\Http\HeaderUtils;
/**
* ResponseHeaderBag is a container for Response HTTP headers.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class ResponseHeaderParameters extends HeaderParameters
{
public const COOKIES_FLAT = 'flat';
public const COOKIES_ARRAY = 'array';
public const DISPOSITION_ATTACHMENT = 'attachment';
public const DISPOSITION_INLINE = 'inline';
protected array $computedCacheControl = [];
protected array $cookies = [];
protected array $headerNames = [];
public function __construct(array $headers = [])
{
parent::__construct($headers);
if (!isset($this->headers['cache-control'])) {
$this->set('Cache-Control', '');
}
/* RFC2616 - 14.18 says all Responses need to have a Date */
if (!isset($this->headers['date'])) {
$this->initDate();
}
}
/**
* Returns the headers, with original capitalizations.
*/
public function allPreserveCase(): array
{
$headers = [];
foreach ($this->all() as $name => $value) {
$headers[$this->headerNames[$name] ?? $name] = $value;
}
return $headers;
}
public function allPreserveCaseWithoutCookies(): array
{
$headers = $this->allPreserveCase();
if (isset($this->headerNames['set-cookie'])) {
unset($headers[$this->headerNames['set-cookie']]);
}
return $headers;
}
public function replace(array $headers = []): void
{
$this->headerNames = [];
parent::replace($headers);
if (!isset($this->headers['cache-control'])) {
$this->set('Cache-Control', '');
}
if (!isset($this->headers['date'])) {
$this->initDate();
}
}
public function all(?string $key = null): array
{
$headers = parent::all();
if (null !== $key) {
$key = strtr($key, self::UPPER, self::LOWER);
return 'set-cookie' !== $key ? $headers[$key] ?? [] : array_map('strval', $this->getCookies());
}
foreach ($this->getCookies() as $cookie) {
$headers['set-cookie'][] = (string) $cookie;
}
return $headers;
}
public function set(string $key, string|array|null $values, bool $replace = true): void
{
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
if ('set-cookie' === $uniqueKey) {
if ($replace) {
$this->cookies = [];
}
foreach ((array) $values as $cookie) {
$this->setCookie(Cookie::fromString($cookie));
}
$this->headerNames[$uniqueKey] = $key;
return;
}
$this->headerNames[$uniqueKey] = $key;
parent::set($key, $values, $replace);
// ensure the cache-control header has sensible defaults
if (\in_array($uniqueKey, ['cache-control', 'etag', 'last-modified', 'expires'], true) && '' !== $computed = $this->computeCacheControlValue()) {
$this->headers['cache-control'] = [$computed];
$this->headerNames['cache-control'] = 'Cache-Control';
$this->computedCacheControl = $this->parseCacheControl($computed);
}
}
public function remove(string $key): void
{
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
unset($this->headerNames[$uniqueKey]);
if ('set-cookie' === $uniqueKey) {
$this->cookies = [];
return;
}
parent::remove($key);
if ('cache-control' === $uniqueKey) {
$this->computedCacheControl = [];
}
if ('date' === $uniqueKey) {
$this->initDate();
}
}
public function hasCacheControlDirective(string $key): bool
{
return \array_key_exists($key, $this->computedCacheControl);
}
public function getCacheControlDirective(string $key): bool|string|null
{
return $this->computedCacheControl[$key] ?? null;
}
public function setCookie(Cookie $cookie): void
{
$this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
$this->headerNames['set-cookie'] = 'Set-Cookie';
}
/**
* Removes a cookie from the array, but does not unset it in the browser.
*/
public function removeCookie(string $name, ?string $path = '/', ?string $domain = null): void
{
$path ??= '/';
unset($this->cookies[$domain][$path][$name]);
if (empty($this->cookies[$domain][$path])) {
unset($this->cookies[$domain][$path]);
if (empty($this->cookies[$domain])) {
unset($this->cookies[$domain]);
}
}
if (!$this->cookies) {
unset($this->headerNames['set-cookie']);
}
}
/**
* Returns an array with all cookies.
*
* @return Cookie[]
*
* @throws \InvalidArgumentException When the $format is invalid
*/
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])));
}
if (self::COOKIES_ARRAY === $format) {
return $this->cookies;
}
$flattenedCookies = [];
foreach ($this->cookies as $path) {
foreach ($path as $cookies) {
foreach ($cookies as $cookie) {
$flattenedCookies[] = $cookie;
}
}
}
return $flattenedCookies;
}
/**
* Clears a cookie in the browser.
*
* @param bool $partitioned
*/
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;
$this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite, $partitioned));
}
/**
* @see HeaderUtils::makeDisposition()
*/
public function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string
{
return HeaderUtils::makeDisposition($disposition, $filename, $filenameFallback);
}
/**
* Returns the calculated value of the cache-control header.
*
* This considers several other headers and calculates or modifies the
* cache-control header to a sensible, conservative value.
*/
protected function computeCacheControlValue(): string
{
if (!$this->cacheControl) {
if ($this->has('Last-Modified') || $this->has('Expires')) {
return 'private, must-revalidate'; // allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "Last-Modified"
}
// conservative by default
return 'no-cache, private';
}
$header = $this->getCacheControlHeader();
if (isset($this->cacheControl['public']) || isset($this->cacheControl['private'])) {
return $header;
}
// public if s-maxage is defined, private otherwise
if (!isset($this->cacheControl['s-maxage'])) {
return $header.', private';
}
return $header;
}
private function initDate(): void
{
$this->set('Date', gmdate('D, d M Y H:i:s').' GMT');
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Response;
/**
* StreamedJsonResponse represents a streamed HTTP response for JSON.
*
* A StreamedJsonResponse uses a structure and generics to create an
* efficient resource-saving JSON response.
*
* It is recommended to use flush() function after a specific number of items to directly stream the data.
*
* @see flush()
*
* @author Alexander Schranz <alexander@sulu.io>
*
* Example usage:
*
* function loadArticles(): \Generator
* // some streamed loading
* yield ['title' => 'Article 1'];
* yield ['title' => 'Article 2'];
* yield ['title' => 'Article 3'];
* // recommended to use flush() after every specific number of items
* }),
*
* $response = new StreamedJsonResponse(
* // json structure with generators in which will be streamed
* [
* '_embedded' => [
* 'articles' => loadArticles(), // any generator which you want to stream as list of data
* ],
* ],
* );
*/
class StreamedJsonResponse extends StreamedResponse
{
private const PLACEHOLDER = '__symfony_json__';
/**
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator
* @param int $status The HTTP status code (200 "OK" by default)
* @param array<string, string|string[]> $headers An array of HTTP headers
* @param int $encodingOptions Flags for the json_encode() function
*/
public function __construct(
private readonly iterable $data,
int $status = 200,
array $headers = [],
private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
) {
parent::__construct($this->stream(...), $status, $headers);
if (!$this->headers->get('Content-Type')) {
$this->headers->set('Content-Type', 'application/json');
}
}
private function stream(): void
{
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
$this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions);
}
private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
{
if (\is_array($data)) {
$this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions);
return;
}
if (is_iterable($data) && !$data instanceof \JsonSerializable) {
$this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions);
return;
}
echo json_encode($data, $jsonEncodingOptions);
}
private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
{
$generators = [];
array_walk_recursive($data, 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
$generators[] = $key;
}
// generators should be used but for better DX all kind of Traversable and objects are supported
if (\is_object($item)) {
$generators[] = $item;
$item = self::PLACEHOLDER;
} elseif (self::PLACEHOLDER === $item) {
// if the placeholder is already in the structure it should be replaced with a new one that explode
// works like expected for the structure
$generators[] = $item;
}
});
$jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions));
foreach ($generators as $index => $generator) {
// send first and between parts of the structure
echo $jsonParts[$index];
$this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions);
}
// send last part of the structure
echo $jsonParts[array_key_last($jsonParts)];
}
private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void
{
$isFirstItem = true;
$startTag = '[';
foreach ($iterable as $key => $item) {
if ($isFirstItem) {
$isFirstItem = false;
// depending on the first elements key the generator is detected as a list or map
// we can not check for a whole list or map because that would hurt the performance
// of the streamed response which is the main goal of this response class
if (0 !== $key) {
$startTag = '{';
}
echo $startTag;
} else {
// if not first element of the generic, a separator is required between the elements
echo ',';
}
if ('{' === $startTag) {
echo json_encode((string) $key, $keyEncodingOptions).':';
}
$this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions);
}
if ($isFirstItem) { // indicates that the generator was empty
echo '[';
}
echo '[' === $startTag ? ']' : '}';
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types = 1);
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace KTXC\Http\Response;
/**
* StreamedResponse represents a streamed HTTP response.
*
* 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
* can also be used if needed.
*
* @see flush()
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class StreamedResponse extends Response
{
protected ?\Closure $callback = null;
protected bool $streamed = false;
private bool $headersSent = false;
/**
* @param callable|iterable<string>|null $callbackOrChunks
* @param int $status The HTTP status code (200 "OK" by default)
*/
public function __construct(callable|iterable|null $callbackOrChunks = null, int $status = 200, array $headers = [])
{
parent::__construct(null, $status, $headers);
if (\is_callable($callbackOrChunks)) {
$this->setCallback($callbackOrChunks);
} elseif ($callbackOrChunks) {
$this->setChunks($callbackOrChunks);
}
$this->streamed = false;
$this->headersSent = false;
}
/**
* @param iterable<string> $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.
*
* @return $this
*/
public function setCallback(callable $callback): static
{
$this->callback = $callback(...);
return $this;
}
public function getCallback(): ?\Closure
{
if (!isset($this->callback)) {
return null;
}
return ($this->callback)(...);
}
/**
* This method only sends the headers once.
*
* @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null
*
* @return $this
*/
public function sendHeaders(?int $statusCode = null): static
{
if ($this->headersSent) {
return $this;
}
if ($statusCode < 100 || $statusCode >= 200) {
$this->headersSent = true;
}
return parent::sendHeaders($statusCode);
}
/**
* This method only sends the content once.
*
* @return $this
*/
public function sendContent(): static
{
if ($this->streamed) {
return $this;
}
$this->streamed = true;
if (!isset($this->callback)) {
throw new \LogicException('The Response callback must be set.');
}
($this->callback)();
return $this;
}
/**
* @return $this
*
* @throws \LogicException when the content is not null
*/
public function setContent(?string $content): static
{
if (null !== $content) {
throw new \LogicException('The content cannot be set on a StreamedResponse instance.');
}
$this->streamed = true;
return $this;
}
public function getContent(): string|false
{
return false;
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Session;
/**
* Interface for session storage.
*/
interface SessionInterface
{
/**
* Starts the session storage.
*
* @return bool True if session started
*
* @throws \RuntimeException if session fails to start
*/
public function start(): bool;
/**
* Returns the session ID.
*
* @return string The session ID
*/
public function getId(): string;
/**
* Sets the session ID.
*
* @param string $id The session ID
*/
public function setId(string $id): void;
/**
* Returns the session name.
*
* @return string The session name
*/
public function getName(): string;
/**
* Sets the session name.
*
* @param string $name The session name
*/
public function setName(string $name): void;
/**
* Invalidates the current session.
*
* Clears all session attributes and flashes and regenerates the
* session and deletes the old session from persistence.
*
* @param int|null $lifetime Sets the cookie lifetime for the session cookie.
* A null value will leave the system settings unchanged,
* 0 sets the cookie to expire with browser session.
* Time is in seconds, and is not a Unix timestamp.
*
* @return bool True if session invalidated, false if error
*/
public function invalidate(?int $lifetime = null): bool;
/**
* Migrates the current session to a new session id while maintaining all
* session attributes.
*
* @param bool $destroy Whether to delete the old session or leave it to garbage collection
* @param int|null $lifetime Sets the cookie lifetime for the session cookie.
* A null value will leave the system settings unchanged,
* 0 sets the cookie to expire with browser session.
* Time is in seconds, and is not a Unix timestamp.
*
* @return bool True if session migrated, false if error
*/
public function migrate(bool $destroy = false, ?int $lifetime = null): bool;
/**
* Force the session to be saved and closed.
*
* This method is generally not required for real sessions as
* the session will be automatically saved at the end of
* code execution.
*/
public function save(): void;
/**
* Checks if an attribute is defined.
*
* @param string $name The attribute name
*
* @return bool True if the attribute is defined, false otherwise
*/
public function has(string $name): bool;
/**
* Returns an attribute.
*
* @param string $name The attribute name
* @param mixed $default The default value if not found
*
* @return mixed
*/
public function get(string $name, mixed $default = null): mixed;
/**
* Sets an attribute.
*
* @param string $name The attribute name
* @param mixed $value The attribute value
*/
public function set(string $name, mixed $value): void;
/**
* Returns attributes.
*
* @return array<string, mixed> Attributes
*/
public function all(): array;
/**
* Sets attributes.
*
* @param array<string, mixed> $attributes Attributes
*/
public function replace(array $attributes): void;
/**
* Removes an attribute.
*
* @param string $name The attribute name
*
* @return mixed The removed value or null when it does not exist
*/
public function remove(string $name): mixed;
/**
* Clears all attributes.
*/
public function clear(): void;
/**
* Checks if the session was started.
*
* @return bool True if started, false otherwise
*/
public function isStarted(): bool;
}