julienmonnerie/kirby/src/Http/Response.php
2025-01-12 18:56:44 +01:00

323 lines
6.9 KiB
PHP

<?php
namespace Kirby\Http;
use Closure;
use Exception;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Throwable;
/**
* Representation of an Http response,
* to simplify sending correct headers
* and Http status codes.
*
* @package Kirby Http
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Response
{
/**
* Store for all registered headers,
* which will be sent with the response
*/
protected array $headers = [];
/**
* The response body
*/
protected string $body;
/**
* The HTTP response code
*/
protected int $code;
/**
* The content type for the response
*/
protected string $type;
/**
* The content type charset
*/
protected string $charset = 'UTF-8';
/**
* Creates a new response object
*/
public function __construct(
string|array $body = '',
string|null $type = null,
int|null $code = null,
array|null $headers = null,
string|null $charset = null
) {
// array construction
if (is_array($body) === true) {
$params = $body;
$body = $params['body'] ?? '';
$type = $params['type'] ?? $type;
$code = $params['code'] ?? $code;
$headers = $params['headers'] ?? $headers;
$charset = $params['charset'] ?? $charset;
}
// regular construction
$this->body = $body;
$this->type = $type ?? 'text/html';
$this->code = $code ?? 200;
$this->headers = $headers ?? [];
$this->charset = $charset ?? 'UTF-8';
// automatic mime type detection
if (strpos($this->type, '/') === false) {
$this->type = F::extensionToMime($this->type) ?? 'text/html';
}
}
/**
* Improved `var_dump` output
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Makes it possible to convert the
* entire response object to a string
* to send the headers and print the body
*/
public function __toString(): string
{
try {
return $this->send();
} catch (Throwable) {
return '';
}
}
/**
* Getter for the body
*/
public function body(): string
{
return $this->body;
}
/**
* Getter for the content type charset
*/
public function charset(): string
{
return $this->charset;
}
/**
* Getter for the HTTP status code
*/
public function code(): int
{
return $this->code;
}
/**
* Creates a response that triggers
* a file download for the given file
*
* @param array $props Custom overrides for response props (e.g. headers)
*/
public static function download(
string $file,
string|null $filename = null,
array $props = []
): static {
if (file_exists($file) === false) {
throw new Exception('The file could not be found');
}
$filename ??= basename($file);
$modified = filemtime($file);
$body = file_get_contents($file);
$size = strlen($body);
$props = array_replace_recursive([
'body' => $body,
'type' => F::mime($file),
'headers' => [
'Pragma' => 'public',
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Last-Modified' => gmdate('D, d M Y H:i:s', $modified) . ' GMT',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Transfer-Encoding' => 'binary',
'Content-Length' => $size,
'Connection' => 'close'
]
], $props);
return new static($props);
}
/**
* Creates a response for a file and
* sends the file content to the browser
*
* @param array $props Custom overrides for response props (e.g. headers)
*/
public static function file(string $file, array $props = []): static
{
$props = array_merge([
'body' => F::read($file),
'type' => F::extensionToMime(F::extension($file))
], $props);
// if we couldn't serve a correct MIME type, force
// the browser to display the file as plain text to
// harden against attacks from malicious file uploads
if ($props['type'] === null) {
if (isset($props['headers']) !== true) {
$props['headers'] = [];
}
$props['type'] = 'text/plain';
$props['headers']['X-Content-Type-Options'] = 'nosniff';
}
return new static($props);
}
/**
* Redirects to the given Urls
* Urls can be relative or absolute.
* @since 3.7.0
*
* @codeCoverageIgnore
*/
public static function go(string $url = '/', int $code = 302): never
{
die(static::redirect($url, $code));
}
/**
* Ensures that the callback does not produce the first body output
* (used to show when loading a file creates side effects)
*/
public static function guardAgainstOutput(Closure $callback, ...$args): mixed
{
$before = headers_sent();
$result = $callback(...$args);
$after = headers_sent($file, $line);
if ($before === false && $after === true) {
throw new LogicException("Disallowed output from file $file:$line, possible accidental whitespace?");
}
return $result;
}
/**
* Getter for single headers
*
* @param string $key Name of the header
*/
public function header(string $key): string|null
{
return $this->headers[$key] ?? null;
}
/**
* Getter for all headers
*/
public function headers(): array
{
return $this->headers;
}
/**
* Creates a json response with appropriate
* header and automatic conversion of arrays.
*/
public static function json(
string|array $body = '',
int|null $code = null,
bool|null $pretty = null,
array $headers = []
): static {
if (is_array($body) === true) {
$body = json_encode($body, $pretty === true ? JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES : 0);
}
return new static([
'body' => $body,
'code' => $code,
'type' => 'application/json',
'headers' => $headers
]);
}
/**
* Creates a redirect response,
* which will send the visitor to the
* given location.
*/
public static function redirect(string $location = '/', int $code = 302): static
{
return new static([
'code' => $code,
'headers' => [
'Location' => Url::unIdn($location)
]
]);
}
/**
* Sends all registered headers and
* returns the response body
*/
public function send(): string
{
// send the status response code
http_response_code($this->code());
// send all custom headers
foreach ($this->headers() as $key => $value) {
header($key . ': ' . $value);
}
// send the content type header
header('Content-Type:' . $this->type() . '; charset=' . $this->charset());
// print the response body
return $this->body();
}
/**
* Converts all relevant response attributes
* to an associative array for debugging,
* testing or whatever.
*/
public function toArray(): array
{
return [
'type' => $this->type(),
'charset' => $this->charset(),
'code' => $this->code(),
'headers' => $this->headers(),
'body' => $this->body()
];
}
/**
* Getter for the content type
*/
public function type(): string
{
return $this->type;
}
}