Initial Version
This commit is contained in:
78
core/lib/Http/Response/FileResponse.php
Normal file
78
core/lib/Http/Response/FileResponse.php
Normal 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;
|
||||
}
|
||||
}
|
||||
189
core/lib/Http/Response/JsonResponse.php
Normal file
189
core/lib/Http/Response/JsonResponse.php
Normal 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);
|
||||
}
|
||||
}
|
||||
94
core/lib/Http/Response/RedirectResponse.php
Normal file
94
core/lib/Http/Response/RedirectResponse.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1336
core/lib/Http/Response/Response.php
Normal file
1336
core/lib/Http/Response/Response.php
Normal file
File diff suppressed because it is too large
Load Diff
275
core/lib/Http/Response/ResponseHeaderParameters.php
Normal file
275
core/lib/Http/Response/ResponseHeaderParameters.php
Normal 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');
|
||||
}
|
||||
}
|
||||
164
core/lib/Http/Response/StreamedJsonResponse.php
Normal file
164
core/lib/Http/Response/StreamedJsonResponse.php
Normal 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 ? ']' : '}';
|
||||
}
|
||||
}
|
||||
152
core/lib/Http/Response/StreamedResponse.php
Normal file
152
core/lib/Http/Response/StreamedResponse.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user