Skip to content

Commit 0b5f0b0

Browse files
committed
Response divided into storage which doesn't emit data to the browser and ResponseEmitter (BC break!)
1 parent 7def8f2 commit 0b5f0b0

19 files changed

+403
-260
lines changed

src/Http/IResponse.php

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,6 @@ function redirect(string $url, int $code = self::S302_FOUND): void;
181181
*/
182182
function setExpiration(?string $expire);
183183

184-
/**
185-
* Checks if headers have been sent.
186-
*/
187-
function isSent(): bool;
188-
189184
/**
190185
* Returns value of an HTTP header.
191186
*/

src/Http/Response.php

Lines changed: 104 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -34,40 +34,54 @@ final class Response implements IResponse
3434
/** @var bool Whether the cookie is hidden from client-side */
3535
public $cookieHttpOnly = true;
3636

37-
/** @var bool Whether warn on possible problem with data in output buffer */
38-
public $warnOnBuffer = true;
39-
40-
/** @var bool Send invisible garbage for IE 6? */
41-
private static $fixIE = true;
42-
4337
/** @var int HTTP response code */
4438
private $code = self::S200_OK;
4539

40+
/** @var string */
41+
private $reason = self::REASON_PHRASES[self::S200_OK];
42+
43+
/** @var string */
44+
private $version = '1.1';
45+
46+
/** @var array of [name, values] */
47+
private $headers = [];
48+
49+
/** @var string|\Closure */
50+
private $body = '';
4651

47-
public function __construct()
52+
53+
/**
54+
* Sets HTTP protocol version.
55+
* @return static
56+
*/
57+
public function setProtocolVersion(string $version)
4858
{
49-
if (is_int($code = http_response_code())) {
50-
$this->code = $code;
51-
}
59+
$this->version = $version;
60+
return $this;
61+
}
62+
63+
64+
/**
65+
* Returns HTTP protocol version.
66+
*/
67+
public function getProtocolVersion(): string
68+
{
69+
return $this->version;
5270
}
5371

5472

5573
/**
5674
* Sets HTTP response code.
5775
* @return static
5876
* @throws Nette\InvalidArgumentException if code is invalid
59-
* @throws Nette\InvalidStateException if HTTP headers have been sent
6077
*/
6178
public function setCode(int $code, string $reason = null)
6279
{
6380
if ($code < 100 || $code > 599) {
6481
throw new Nette\InvalidArgumentException("Bad HTTP response '$code'.");
6582
}
66-
self::checkHeaders();
6783
$this->code = $code;
68-
$protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1';
69-
$reason = $reason ?? self::REASON_PHRASES[$code] ?? 'Unknown status';
70-
header("$protocol $code $reason");
84+
$this->reason = $reason ?? self::REASON_PHRASES[$code] ?? 'Unknown status';
7185
return $this;
7286
}
7387

@@ -81,20 +95,24 @@ public function getCode(): int
8195
}
8296

8397

98+
/**
99+
* Returns HTTP reason phrase.
100+
*/
101+
public function getReasonPhrase(): string
102+
{
103+
return $this->reason;
104+
}
105+
106+
84107
/**
85108
* Sends a HTTP header and replaces a previous one.
86109
* @return static
87-
* @throws Nette\InvalidStateException if HTTP headers have been sent
88110
*/
89111
public function setHeader(string $name, ?string $value)
90112
{
91-
self::checkHeaders();
92-
if ($value === null) {
93-
header_remove($name);
94-
} elseif (strcasecmp($name, 'Content-Length') === 0 && ini_get('zlib.output_compression')) {
95-
// ignore, PHP bug #44164
96-
} else {
97-
header($name . ': ' . $value, true, $this->code);
113+
unset($this->headers[strtolower($name)]);
114+
if ($value !== null) { // supports null for back compatibility
115+
$this->addHeader($name, $value);
98116
}
99117
return $this;
100118
}
@@ -103,32 +121,52 @@ public function setHeader(string $name, ?string $value)
103121
/**
104122
* Adds HTTP header.
105123
* @return static
106-
* @throws Nette\InvalidStateException if HTTP headers have been sent
107124
*/
108125
public function addHeader(string $name, string $value)
109126
{
110-
self::checkHeaders();
111-
header($name . ': ' . $value, false, $this->code);
127+
$lname = strtolower($name);
128+
$this->headers[$lname][0] = $name;
129+
$this->headers[$lname][1][] = trim(preg_replace('#[^\x20-\x7E\x80-\xFE]#', '', $value));
112130
return $this;
113131
}
114132

115133

116134
/**
117135
* @return static
118-
* @throws Nette\InvalidStateException if HTTP headers have been sent
119136
*/
120137
public function deleteHeader(string $name)
121138
{
122-
self::checkHeaders();
123-
header_remove($name);
139+
unset($this->headers[strtolower($name)]);
124140
return $this;
125141
}
126142

127143

144+
/**
145+
* Returns value of an HTTP header.
146+
*/
147+
public function getHeader(string $name): ?string
148+
{
149+
return $this->headers[strtolower($name)][1][0] ?? null;
150+
}
151+
152+
153+
/**
154+
* Returns a associative array of headers to sent.
155+
* @return string[][]
156+
*/
157+
public function getHeaders(): array
158+
{
159+
$res = [];
160+
foreach ($this->headers as $info) {
161+
$res[$info[0]] = $info[1];
162+
}
163+
return $res;
164+
}
165+
166+
128167
/**
129168
* Sends a Content-type HTTP header.
130169
* @return static
131-
* @throws Nette\InvalidStateException if HTTP headers have been sent
132170
*/
133171
public function setContentType(string $type, string $charset = null)
134172
{
@@ -139,23 +177,23 @@ public function setContentType(string $type, string $charset = null)
139177

140178
/**
141179
* Redirects to a new URL. Note: call exit() after it.
142-
* @throws Nette\InvalidStateException if HTTP headers have been sent
143180
*/
144181
public function redirect(string $url, int $code = self::S302_FOUND): void
145182
{
146183
$this->setCode($code);
147184
$this->setHeader('Location', $url);
148185
if (preg_match('#^https?:|^\s*+[a-z0-9+.-]*+[^:]#i', $url)) {
149186
$escapedUrl = htmlspecialchars($url, ENT_IGNORE | ENT_QUOTES, 'UTF-8');
150-
echo "<h1>Redirect</h1>\n\n<p><a href=\"$escapedUrl\">Please click here to continue</a>.</p>";
187+
$this->setBody("<h1>Redirect</h1>\n\n<p><a href=\"$escapedUrl\">Please click here to continue</a>.</p>");
188+
} else {
189+
$this->setBody('');
151190
}
152191
}
153192

154193

155194
/**
156195
* Sets the time (like '20 minutes') before a page cached on a browser expires, null means "must-revalidate".
157196
* @return static
158-
* @throws Nette\InvalidStateException if HTTP headers have been sent
159197
*/
160198
public function setExpiration(?string $time)
161199
{
@@ -174,115 +212,63 @@ public function setExpiration(?string $time)
174212

175213

176214
/**
177-
* Checks if headers have been sent.
215+
* Sends a cookie.
216+
* @param string|int|\DateTimeInterface $time expiration time, value 0 means "until the browser is closed"
217+
* @return static
178218
*/
179-
public function isSent(): bool
219+
public function setCookie(string $name, string $value, $expire, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null, string $sameSite = null)
180220
{
181-
return headers_sent();
182-
}
183-
221+
$path = $path === null ? $this->cookiePath : $path;
222+
$domain = $domain === null ? $this->cookieDomain : $domain;
223+
$secure = $secure === null ? $this->cookieSecure : $secure;
224+
$httpOnly = $httpOnly === null ? $this->cookieHttpOnly : $httpOnly;
184225

185-
/**
186-
* Returns value of an HTTP header.
187-
*/
188-
public function getHeader(string $header): ?string
189-
{
190-
$header .= ':';
191-
$len = strlen($header);
192-
foreach (headers_list() as $item) {
193-
if (strncasecmp($item, $header, $len) === 0) {
194-
return ltrim(substr($item, $len));
195-
}
226+
if (strpbrk($name . $path . $domain . $sameSite, "=,; \t\r\n\013\014") !== false) {
227+
throw new Nette\InvalidArgumentException('Cookie cannot contain any of the following \'=,; \t\r\n\013\014\'');
196228
}
197-
return null;
198-
}
199229

230+
$value = $name . '=' . rawurlencode($value)
231+
. ($expire ? '; expires=' . Helpers::formatDate($expire) : '')
232+
. ($expire ? '; Max-Age=' . (DateTime::from($expire)->format('U') - time()) : '')
233+
. ($domain ? '; domain=' . $domain : '')
234+
. ($path ? '; path=' . $path : '')
235+
. ($secure ? '; secure' : '')
236+
. ($httpOnly ? '; HttpOnly' : '')
237+
. ($sameSite ? '; SameSite=' . $sameSite : '');
200238

201-
/**
202-
* Returns a associative array of headers to sent.
203-
* @return string[][]
204-
*/
205-
public function getHeaders(): array
206-
{
207-
$headers = [];
208-
foreach (headers_list() as $header) {
209-
$pair = explode(': ', $header);
210-
$headers[$pair[0]][] = $pair[1];
211-
}
212-
return $headers;
239+
$this->addHeader('Set-Cookie', $value);
240+
return $this;
213241
}
214242

215243

216-
public function __destruct()
244+
/**
245+
* Deletes a cookie.
246+
*/
247+
public function deleteCookie(string $name, string $path = null, string $domain = null, bool $secure = null): void
217248
{
218-
if (
219-
self::$fixIE
220-
&& strpos($_SERVER['HTTP_USER_AGENT'] ?? '', 'MSIE ') !== false
221-
&& in_array($this->code, [400, 403, 404, 405, 406, 408, 409, 410, 500, 501, 505], true)
222-
&& preg_match('#^text/html(?:;|$)#', (string) $this->getHeader('Content-Type'))
223-
) {
224-
echo Nette\Utils\Random::generate(2000, " \t\r\n"); // sends invisible garbage for IE
225-
self::$fixIE = false;
226-
}
249+
$this->setCookie($name, '', 0, $path, $domain, $secure);
227250
}
228251

229252

230253
/**
231-
* Sends a cookie.
232-
* @param string|int|\DateTimeInterface $time expiration time, value 0 means "until the browser is closed"
254+
* @param string|\Closure $body
233255
* @return static
234-
* @throws Nette\InvalidStateException if HTTP headers have been sent
235256
*/
236-
public function setCookie(string $name, string $value, $time, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null, string $sameSite = null)
257+
public function setBody($body)
237258
{
238-
self::checkHeaders();
239-
$options = [
240-
'expires' => $time ? (int) DateTime::from($time)->format('U') : 0,
241-
'path' => $path === null ? $this->cookiePath : $path,
242-
'domain' => $domain === null ? $this->cookieDomain : $domain,
243-
'secure' => $secure === null ? $this->cookieSecure : $secure,
244-
'httponly' => $httpOnly === null ? $this->cookieHttpOnly : $httpOnly,
245-
'samesite' => $sameSite,
246-
];
247-
if (PHP_VERSION_ID >= 70300) {
248-
setcookie($name, $value, $options);
249-
} else {
250-
setcookie(
251-
$name,
252-
$value,
253-
$options['expires'],
254-
$options['path'] . ($sameSite ? "; SameSite=$sameSite" : ''),
255-
$options['domain'],
256-
$options['secure'],
257-
$options['httponly']
258-
);
259+
if (!is_string($body) && !$body instanceof \Closure) {
260+
throw new Nette\InvalidArgumentException('Body must be string or Closure.');
259261
}
262+
$this->body = $body;
260263
return $this;
261264
}
262265

263266

264267
/**
265-
* Deletes a cookie.
266-
* @throws Nette\InvalidStateException if HTTP headers have been sent
268+
* @return string|\Closure
267269
*/
268-
public function deleteCookie(string $name, string $path = null, string $domain = null, bool $secure = null): void
270+
public function getBody()
269271
{
270-
$this->setCookie($name, '', 0, $path, $domain, $secure);
271-
}
272-
273-
274-
private function checkHeaders(): void
275-
{
276-
if (PHP_SAPI === 'cli') {
277-
} elseif (headers_sent($file, $line)) {
278-
throw new Nette\InvalidStateException('Cannot send header after HTTP headers have been sent' . ($file ? " (output started at $file:$line)." : '.'));
279-
280-
} elseif (
281-
$this->warnOnBuffer &&
282-
ob_get_length() &&
283-
!array_filter(ob_get_status(true), function (array $i): bool { return !$i['chunk_size']; })
284-
) {
285-
trigger_error('Possible problem: you are sending a HTTP header while already having some data in output buffer. Try Tracy\OutputDebugger or start session earlier.');
286-
}
272+
return $this->body;
287273
}
288274
}

0 commit comments

Comments
 (0)