Disable WebResponse setters for post-send processing
[lhc/web/wiklou.git] / includes / WebResponse.php
1 <?php
2 /**
3 * Classes used to send headers and cookies back to the user
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 /**
24 * Allow programs to request this object from WebRequest::response()
25 * and handle all outputting (or lack of outputting) via it.
26 * @ingroup HTTP
27 */
28 class WebResponse {
29
30 /** @var array Used to record set cookies, because PHP's setcookie() will
31 * happily send an identical Set-Cookie to the client.
32 */
33 protected static $setCookies = [];
34
35 /** @var bool Used to disable setters before running jobs post-request (T191537) */
36 protected static $disableForPostSend = false;
37
38 /**
39 * Disable setters for post-send processing
40 *
41 * After this call, self::setCookie(), self::header(), and
42 * self::statusHeader() will log a warning and return without
43 * setting cookies or headers.
44 *
45 * @since 1.32
46 */
47 public static function disableForPostSend() {
48 self::$disableForPostSend = true;
49 }
50
51 /**
52 * Output an HTTP header, wrapper for PHP's header()
53 * @param string $string Header to output
54 * @param bool $replace Replace current similar header
55 * @param null|int $http_response_code Forces the HTTP response code to the specified value.
56 */
57 public function header( $string, $replace = true, $http_response_code = null ) {
58 if ( self::$disableForPostSend ) {
59 wfDebugLog( 'header', 'ignored post-send header {header}', 'all', [
60 'header' => $string,
61 'replace' => $replace,
62 'http_response_code' => $http_response_code,
63 'exception' => new RuntimeException( 'Ignored post-send header' ),
64 ] );
65 return;
66 }
67
68 \MediaWiki\HeaderCallback::warnIfHeadersSent();
69 if ( $http_response_code ) {
70 header( $string, $replace, $http_response_code );
71 } else {
72 header( $string, $replace );
73 }
74 }
75
76 /**
77 * Get a response header
78 * @param string $key The name of the header to get (case insensitive).
79 * @return string|null The header value (if set); null otherwise.
80 * @since 1.25
81 */
82 public function getHeader( $key ) {
83 foreach ( headers_list() as $header ) {
84 list( $name, $val ) = explode( ':', $header, 2 );
85 if ( !strcasecmp( $name, $key ) ) {
86 return trim( $val );
87 }
88 }
89 return null;
90 }
91
92 /**
93 * Output an HTTP status code header
94 * @since 1.26
95 * @param int $code Status code
96 */
97 public function statusHeader( $code ) {
98 if ( self::$disableForPostSend ) {
99 wfDebugLog( 'header', 'ignored post-send status header {code}', 'all', [
100 'code' => $code,
101 'exception' => new RuntimeException( 'Ignored post-send status header' ),
102 ] );
103 return;
104 }
105
106 HttpStatus::header( $code );
107 }
108
109 /**
110 * Test if headers have been sent
111 * @since 1.27
112 * @return bool
113 */
114 public function headersSent() {
115 return headers_sent();
116 }
117
118 /**
119 * Set the browser cookie
120 * @param string $name The name of the cookie.
121 * @param string $value The value to be stored in the cookie.
122 * @param int|null $expire Unix timestamp (in seconds) when the cookie should expire.
123 * 0 (the default) causes it to expire $wgCookieExpiration seconds from now.
124 * null causes it to be a session cookie.
125 * @param array $options Assoc of additional cookie options:
126 * prefix: string, name prefix ($wgCookiePrefix)
127 * domain: string, cookie domain ($wgCookieDomain)
128 * path: string, cookie path ($wgCookiePath)
129 * secure: bool, secure attribute ($wgCookieSecure)
130 * httpOnly: bool, httpOnly attribute ($wgCookieHttpOnly)
131 * @since 1.22 Replaced $prefix, $domain, and $forceSecure with $options
132 */
133 public function setCookie( $name, $value, $expire = 0, $options = [] ) {
134 global $wgCookiePath, $wgCookiePrefix, $wgCookieDomain;
135 global $wgCookieSecure, $wgCookieExpiration, $wgCookieHttpOnly;
136
137 $options = array_filter( $options, function ( $a ) {
138 return $a !== null;
139 } ) + [
140 'prefix' => $wgCookiePrefix,
141 'domain' => $wgCookieDomain,
142 'path' => $wgCookiePath,
143 'secure' => $wgCookieSecure,
144 'httpOnly' => $wgCookieHttpOnly,
145 'raw' => false,
146 ];
147
148 if ( $expire === null ) {
149 $expire = 0; // Session cookie
150 } elseif ( $expire == 0 && $wgCookieExpiration != 0 ) {
151 $expire = time() + $wgCookieExpiration;
152 }
153
154 $cookie = $options['prefix'] . $name;
155 $data = [
156 'name' => (string)$cookie,
157 'value' => (string)$value,
158 'expire' => (int)$expire,
159 'path' => (string)$options['path'],
160 'domain' => (string)$options['domain'],
161 'secure' => (bool)$options['secure'],
162 'httpOnly' => (bool)$options['httpOnly'],
163 ];
164
165 if ( self::$disableForPostSend ) {
166 wfDebugLog( 'cookie', 'ignored post-send cookie {cookie}', 'all', [
167 'cookie' => $cookie,
168 'data' => $data,
169 'exception' => new RuntimeException( 'Ignored post-send cookie' ),
170 ] );
171 return;
172 }
173
174 $func = $options['raw'] ? 'setrawcookie' : 'setcookie';
175
176 if ( Hooks::run( 'WebResponseSetCookie', [ &$name, &$value, &$expire, &$options ] ) ) {
177 // Per RFC 6265, key is name + domain + path
178 $key = "{$data['name']}\n{$data['domain']}\n{$data['path']}";
179
180 // If this cookie name was in the request, fake an entry in
181 // self::$setCookies for it so the deleting check works right.
182 if ( isset( $_COOKIE[$cookie] ) && !array_key_exists( $key, self::$setCookies ) ) {
183 self::$setCookies[$key] = [];
184 }
185
186 // PHP deletes if value is the empty string; also, a past expiry is deleting
187 $deleting = ( $data['value'] === '' || $data['expire'] > 0 && $data['expire'] <= time() );
188
189 if ( $deleting && !isset( self::$setCookies[$key] ) ) { // isset( null ) is false
190 wfDebugLog( 'cookie', 'already deleted ' . $func . ': "' . implode( '", "', $data ) . '"' );
191 } elseif ( !$deleting && isset( self::$setCookies[$key] ) &&
192 self::$setCookies[$key] === [ $func, $data ]
193 ) {
194 wfDebugLog( 'cookie', 'already set ' . $func . ': "' . implode( '", "', $data ) . '"' );
195 } else {
196 wfDebugLog( 'cookie', $func . ': "' . implode( '", "', $data ) . '"' );
197 if ( call_user_func_array( $func, array_values( $data ) ) ) {
198 self::$setCookies[$key] = $deleting ? null : [ $func, $data ];
199 }
200 }
201 }
202 }
203
204 /**
205 * Unset a browser cookie.
206 * This sets the cookie with an empty value and an expiry set to a time in the past,
207 * which will cause the browser to remove any cookie with the given name, domain and
208 * path from its cookie store. Options other than these (and prefix) have no effect.
209 * @param string $name Cookie name
210 * @param array $options Cookie options, see {@link setCookie()}
211 * @since 1.27
212 */
213 public function clearCookie( $name, $options = [] ) {
214 $this->setCookie( $name, '', time() - 31536000 /* 1 year */, $options );
215 }
216
217 /**
218 * Checks whether this request is performing cookie operations
219 *
220 * @return bool
221 * @since 1.27
222 */
223 public function hasCookies() {
224 return (bool)self::$setCookies;
225 }
226 }
227
228 /**
229 * @ingroup HTTP
230 */
231 class FauxResponse extends WebResponse {
232 private $headers;
233 private $cookies = [];
234 private $code;
235
236 /**
237 * Stores a HTTP header
238 * @param string $string Header to output
239 * @param bool $replace Replace current similar header
240 * @param null|int $http_response_code Forces the HTTP response code to the specified value.
241 */
242 public function header( $string, $replace = true, $http_response_code = null ) {
243 if ( substr( $string, 0, 5 ) == 'HTTP/' ) {
244 $parts = explode( ' ', $string, 3 );
245 $this->code = intval( $parts[1] );
246 } else {
247 list( $key, $val ) = array_map( 'trim', explode( ":", $string, 2 ) );
248
249 $key = strtoupper( $key );
250
251 if ( $replace || !isset( $this->headers[$key] ) ) {
252 $this->headers[$key] = $val;
253 }
254 }
255
256 if ( $http_response_code !== null ) {
257 $this->code = intval( $http_response_code );
258 }
259 }
260
261 /**
262 * @since 1.26
263 * @param int $code Status code
264 */
265 public function statusHeader( $code ) {
266 $this->code = intval( $code );
267 }
268
269 public function headersSent() {
270 return false;
271 }
272
273 /**
274 * @param string $key The name of the header to get (case insensitive).
275 * @return string|null The header value (if set); null otherwise.
276 */
277 public function getHeader( $key ) {
278 $key = strtoupper( $key );
279
280 if ( isset( $this->headers[$key] ) ) {
281 return $this->headers[$key];
282 }
283 return null;
284 }
285
286 /**
287 * Get the HTTP response code, null if not set
288 *
289 * @return int|null
290 */
291 public function getStatusCode() {
292 return $this->code;
293 }
294
295 /**
296 * @param string $name The name of the cookie.
297 * @param string $value The value to be stored in the cookie.
298 * @param int|null $expire Ignored in this faux subclass.
299 * @param array $options Ignored in this faux subclass.
300 */
301 public function setCookie( $name, $value, $expire = 0, $options = [] ) {
302 global $wgCookiePath, $wgCookiePrefix, $wgCookieDomain;
303 global $wgCookieSecure, $wgCookieExpiration, $wgCookieHttpOnly;
304
305 $options = array_filter( $options, function ( $a ) {
306 return $a !== null;
307 } ) + [
308 'prefix' => $wgCookiePrefix,
309 'domain' => $wgCookieDomain,
310 'path' => $wgCookiePath,
311 'secure' => $wgCookieSecure,
312 'httpOnly' => $wgCookieHttpOnly,
313 'raw' => false,
314 ];
315
316 if ( $expire === null ) {
317 $expire = 0; // Session cookie
318 } elseif ( $expire == 0 && $wgCookieExpiration != 0 ) {
319 $expire = time() + $wgCookieExpiration;
320 }
321
322 $this->cookies[$options['prefix'] . $name] = [
323 'value' => (string)$value,
324 'expire' => (int)$expire,
325 'path' => (string)$options['path'],
326 'domain' => (string)$options['domain'],
327 'secure' => (bool)$options['secure'],
328 'httpOnly' => (bool)$options['httpOnly'],
329 'raw' => (bool)$options['raw'],
330 ];
331 }
332
333 /**
334 * @param string $name
335 * @return string|null
336 */
337 public function getCookie( $name ) {
338 if ( isset( $this->cookies[$name] ) ) {
339 return $this->cookies[$name]['value'];
340 }
341 return null;
342 }
343
344 /**
345 * @param string $name
346 * @return array|null
347 */
348 public function getCookieData( $name ) {
349 if ( isset( $this->cookies[$name] ) ) {
350 return $this->cookies[$name];
351 }
352 return null;
353 }
354
355 /**
356 * @return array
357 */
358 public function getCookies() {
359 return $this->cookies;
360 }
361 }