@@ -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