| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* This file is part of the Nette Framework (https://nette.org) |
| 5 |
* Copyright (c) 2004 David Grudl (https://davidgrudl.com) |
| 6 |
*/ |
| 7 |
declare (strict_types=1); |
| 8 |
namespace Packetery\Nette\Http; |
| 9 |
|
| 10 |
use Packetery\Nette; |
| 11 |
use Packetery\Nette\Utils\DateTime; |
| 12 |
/** |
| 13 |
* HttpResponse class. |
| 14 |
* |
| 15 |
* @property-read array $headers |
| 16 |
*/ |
| 17 |
final class Response implements IResponse |
| 18 |
{ |
| 19 |
use \Packetery\Nette\SmartObject; |
| 20 |
/** @var string The domain in which the cookie will be available */ |
| 21 |
public $cookieDomain = ''; |
| 22 |
/** @var string The path in which the cookie will be available */ |
| 23 |
public $cookiePath = '/'; |
| 24 |
/** @var bool Whether the cookie is available only through HTTPS */ |
| 25 |
public $cookieSecure = \false; |
| 26 |
/** @deprecated */ |
| 27 |
public $cookieHttpOnly; |
| 28 |
/** @var bool Whether warn on possible problem with data in output buffer */ |
| 29 |
public $warnOnBuffer = \true; |
| 30 |
/** @deprecated */ |
| 31 |
private static $fixIE = \true; |
| 32 |
/** @var int HTTP response code */ |
| 33 |
private $code = self::S200_OK; |
| 34 |
public function __construct() |
| 35 |
{ |
| 36 |
if (\is_int($code = \http_response_code())) { |
| 37 |
$this->code = $code; |
| 38 |
} |
| 39 |
} |
| 40 |
/** |
| 41 |
* Sets HTTP response code. |
| 42 |
* @return static |
| 43 |
* @throws \Packetery\Nette\InvalidArgumentException if code is invalid |
| 44 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 45 |
*/ |
| 46 |
public function setCode(int $code, ?string $reason = null) |
| 47 |
{ |
| 48 |
if ($code < 100 || $code > 599) { |
| 49 |
throw new \Packetery\Nette\InvalidArgumentException("Bad HTTP response '{$code}'."); |
| 50 |
} |
| 51 |
self::checkHeaders(); |
| 52 |
$this->code = $code; |
| 53 |
$protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'; |
| 54 |
$reason = $reason ?? self::ReasonPhrases[$code] ?? 'Unknown status'; |
| 55 |
\header("{$protocol} {$code} {$reason}"); |
| 56 |
return $this; |
| 57 |
} |
| 58 |
/** |
| 59 |
* Returns HTTP response code. |
| 60 |
*/ |
| 61 |
public function getCode() : int |
| 62 |
{ |
| 63 |
return $this->code; |
| 64 |
} |
| 65 |
/** |
| 66 |
* Sends an HTTP header and overwrites previously sent header of the same name. |
| 67 |
* @return static |
| 68 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 69 |
*/ |
| 70 |
public function setHeader(string $name, ?string $value) |
| 71 |
{ |
| 72 |
self::checkHeaders(); |
| 73 |
if ($value === null) { |
| 74 |
\header_remove($name); |
| 75 |
} elseif (\strcasecmp($name, 'Content-Length') === 0 && \ini_get('zlib.output_compression')) { |
| 76 |
// ignore, PHP bug #44164 |
| 77 |
} else { |
| 78 |
\header($name . ': ' . $value); |
| 79 |
} |
| 80 |
return $this; |
| 81 |
} |
| 82 |
/** |
| 83 |
* Sends an HTTP header and doesn't overwrite previously sent header of the same name. |
| 84 |
* @return static |
| 85 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 86 |
*/ |
| 87 |
public function addHeader(string $name, string $value) |
| 88 |
{ |
| 89 |
self::checkHeaders(); |
| 90 |
\header($name . ': ' . $value, \false); |
| 91 |
return $this; |
| 92 |
} |
| 93 |
/** |
| 94 |
* Deletes a previously sent HTTP header. |
| 95 |
* @return static |
| 96 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 97 |
*/ |
| 98 |
public function deleteHeader(string $name) |
| 99 |
{ |
| 100 |
self::checkHeaders(); |
| 101 |
\header_remove($name); |
| 102 |
return $this; |
| 103 |
} |
| 104 |
/** |
| 105 |
* Sends a Content-type HTTP header. |
| 106 |
* @return static |
| 107 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 108 |
*/ |
| 109 |
public function setContentType(string $type, ?string $charset = null) |
| 110 |
{ |
| 111 |
$this->setHeader('Content-Type', $type . ($charset ? '; charset=' . $charset : '')); |
| 112 |
return $this; |
| 113 |
} |
| 114 |
/** |
| 115 |
* Response should be downloaded with 'Save as' dialog. |
| 116 |
* @return static |
| 117 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 118 |
*/ |
| 119 |
public function sendAsFile(string $fileName) |
| 120 |
{ |
| 121 |
$this->setHeader('Content-Disposition', 'attachment; filename="' . \str_replace('"', '', $fileName) . '"; ' . "filename*=utf-8''" . \rawurlencode($fileName)); |
| 122 |
return $this; |
| 123 |
} |
| 124 |
/** |
| 125 |
* Redirects to another URL. Don't forget to quit the script then. |
| 126 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 127 |
*/ |
| 128 |
public function redirect(string $url, int $code = self::S302_Found) : void |
| 129 |
{ |
| 130 |
$this->setCode($code); |
| 131 |
$this->setHeader('Location', $url); |
| 132 |
if (\preg_match('#^https?:|^\\s*+[a-z0-9+.-]*+[^:]#i', $url)) { |
| 133 |
$escapedUrl = \htmlspecialchars($url, \ENT_IGNORE | \ENT_QUOTES, 'UTF-8'); |
| 134 |
echo "<h1>Redirect</h1>\n\n<p><a href=\"{$escapedUrl}\">Please click here to continue</a>.</p>"; |
| 135 |
} |
| 136 |
} |
| 137 |
/** |
| 138 |
* Sets the expiration of the HTTP document using the `Cache-Control` and `Expires` headers. |
| 139 |
* The parameter is either a time interval (as text) or `null`, which disables caching. |
| 140 |
* @return static |
| 141 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 142 |
*/ |
| 143 |
public function setExpiration(?string $expire) |
| 144 |
{ |
| 145 |
$this->setHeader('Pragma', null); |
| 146 |
if (!$expire) { |
| 147 |
// no cache |
| 148 |
$this->setHeader('Cache-Control', 's-maxage=0, max-age=0, must-revalidate'); |
| 149 |
$this->setHeader('Expires', 'Mon, 23 Jan 1978 10:00:00 GMT'); |
| 150 |
return $this; |
| 151 |
} |
| 152 |
$expire = DateTime::from($expire); |
| 153 |
$this->setHeader('Cache-Control', 'max-age=' . ($expire->format('U') - \time())); |
| 154 |
$this->setHeader('Expires', Helpers::formatDate($expire)); |
| 155 |
return $this; |
| 156 |
} |
| 157 |
/** |
| 158 |
* Returns whether headers have already been sent from the server to the browser, |
| 159 |
* so it is no longer possible to send headers or change the response code. |
| 160 |
*/ |
| 161 |
public function isSent() : bool |
| 162 |
{ |
| 163 |
return \headers_sent(); |
| 164 |
} |
| 165 |
/** |
| 166 |
* Returns the sent HTTP header, or `null` if it does not exist. The parameter is case-insensitive. |
| 167 |
*/ |
| 168 |
public function getHeader(string $header) : ?string |
| 169 |
{ |
| 170 |
if (\func_num_args() > 1) { |
| 171 |
\trigger_error(__METHOD__ . '() parameter $default is deprecated, use operator ??', \E_USER_DEPRECATED); |
| 172 |
} |
| 173 |
$header .= ':'; |
| 174 |
$len = \strlen($header); |
| 175 |
foreach (\headers_list() as $item) { |
| 176 |
if (\strncasecmp($item, $header, $len) === 0) { |
| 177 |
return \ltrim(\substr($item, $len)); |
| 178 |
} |
| 179 |
} |
| 180 |
return null; |
| 181 |
} |
| 182 |
/** |
| 183 |
* Returns all sent HTTP headers as associative array. |
| 184 |
*/ |
| 185 |
public function getHeaders() : array |
| 186 |
{ |
| 187 |
$headers = []; |
| 188 |
foreach (\headers_list() as $header) { |
| 189 |
$a = \strpos($header, ':'); |
| 190 |
$headers[\substr($header, 0, $a)] = (string) \substr($header, $a + 2); |
| 191 |
} |
| 192 |
return $headers; |
| 193 |
} |
| 194 |
/** |
| 195 |
* Sends a cookie. |
| 196 |
* @param string|int|\DateTimeInterface $expire expiration time, value null means "until the browser session ends" |
| 197 |
* @return static |
| 198 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 199 |
*/ |
| 200 |
public function setCookie(string $name, string $value, $expire, ?string $path = null, ?string $domain = null, ?bool $secure = null, ?bool $httpOnly = null, ?string $sameSite = null) |
| 201 |
{ |
| 202 |
self::checkHeaders(); |
| 203 |
$options = ['expires' => $expire ? (int) DateTime::from($expire)->format('U') : 0, 'path' => $path ?? ($domain ? '/' : $this->cookiePath), 'domain' => $domain ?? ($path ? '' : $this->cookieDomain), 'secure' => $secure ?? $this->cookieSecure, 'httponly' => $httpOnly ?? \true, 'samesite' => $sameSite = $sameSite ?? self::SameSiteLax]; |
| 204 |
if (\PHP_VERSION_ID >= 70300) { |
| 205 |
\setcookie($name, $value, $options); |
| 206 |
} else { |
| 207 |
\setcookie($name, $value, $options['expires'], $options['path'] . ($sameSite ? "; SameSite={$sameSite}" : ''), $options['domain'], $options['secure'], $options['httponly']); |
| 208 |
} |
| 209 |
return $this; |
| 210 |
} |
| 211 |
/** |
| 212 |
* Deletes a cookie. |
| 213 |
* @throws \Packetery\Nette\InvalidStateException if HTTP headers have been sent |
| 214 |
*/ |
| 215 |
public function deleteCookie(string $name, ?string $path = null, ?string $domain = null, ?bool $secure = null) : void |
| 216 |
{ |
| 217 |
$this->setCookie($name, '', 0, $path, $domain, $secure); |
| 218 |
} |
| 219 |
private function checkHeaders() : void |
| 220 |
{ |
| 221 |
if (\PHP_SAPI === 'cli') { |
| 222 |
} elseif (\headers_sent($file, $line)) { |
| 223 |
throw new \Packetery\Nette\InvalidStateException('Cannot send header after HTTP headers have been sent' . ($file ? " (output started at {$file}:{$line})." : '.')); |
| 224 |
} elseif ($this->warnOnBuffer && \ob_get_length() && !\array_filter(\ob_get_status(\true), function (array $i) : bool { |
| 225 |
return !$i['chunk_size']; |
| 226 |
})) { |
| 227 |
\trigger_error('Possible problem: you are sending a HTTP header while already having some data in output buffer. Try Tracy\\OutputDebugger or send cookies/start session earlier.'); |
| 228 |
} |
| 229 |
} |
| 230 |
} |
| 231 |
|