Merge "config: Add new ConfigRepository"
[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 if ( self::$disableForPostSend ) {
155 $cookie = $options['prefix'] . $name;
156 wfDebugLog( 'cookie', 'ignored post-send cookie {cookie}', 'all', [
157 'cookie' => $cookie,
158 'data' => [
159 'name' => (string)$cookie,
160 'value' => (string)$value,
161 'expire' => (int)$expire,
162 'path' => (string)$options['path'],
163 'domain' => (string)$options['domain'],
164 'secure' => (bool)$options['secure'],
165 'httpOnly' => (bool)$options['httpOnly'],
166 ],
167 'exception' => new RuntimeException( 'Ignored post-send cookie' ),
168 ] );
169 return;
170 }
171
172 $func = $options['raw'] ? 'setrawcookie' : 'setcookie';
173
174 if ( Hooks::run( 'WebResponseSetCookie', [ &$name, &$value, &$expire, &$options ] ) ) {
175 // Note: Don't try to move this earlier to reuse it for self::$disableForPostSend,
176 // we need to use the altered values from the hook here. (T198525)
177 $cookie = $options['prefix'] . $name;
178 $data = [
179 'name' => (string)$cookie,
180 'value' => (string)$value,
181 'expire' => (int)$expire,
182 'path' => (string)$options['path'],
183 'domain' => (string)$options['domain'],
184 'secure' => (bool)$options['secure'],
185 'httpOnly' => (bool)$options['httpOnly'],
186 ];
187
188 // Per RFC 6265, key is name + domain + path
189 $key = "{$data['name']}\n{$data['domain']}\n{$data['path']}";
190
191 // If this cookie name was in the request, fake an entry in
192 // self::$setCookies for it so the deleting check works right.
193 if ( isset( $_COOKIE[$cookie] ) && !array_key_exists( $key, self::$setCookies ) ) {
194 self::$setCookies[$key] = [];
195 }
196
197 // PHP deletes if value is the empty string; also, a past expiry is deleting
198 $deleting = ( $data['value'] === '' || $data['expire'] > 0 && $data['expire'] <= time() );
199
200 if ( $deleting && !isset( self::$setCookies[$key] ) ) { // isset( null ) is false
201 wfDebugLog( 'cookie', 'already deleted ' . $func . ': "' . implode( '", "', $data ) . '"' );
202 } elseif ( !$deleting && isset( self::$setCookies[$key] ) &&
203 self::$setCookies[$key] === [ $func, $data ]
204 ) {
205 wfDebugLog( 'cookie', 'already set ' . $func . ': "' . implode( '", "', $data ) . '"' );
206 } else {
207 wfDebugLog( 'cookie', $func . ': "' . implode( '", "', $data ) . '"' );
208 if ( call_user_func_array( $func, array_values( $data ) ) ) {
209 self::$setCookies[$key] = $deleting ? null : [ $func, $data ];
210 }
211 }
212 }
213 }
214
215 /**
216 * Unset a browser cookie.
217 * This sets the cookie with an empty value and an expiry set to a time in the past,
218 * which will cause the browser to remove any cookie with the given name, domain and
219 * path from its cookie store. Options other than these (and prefix) have no effect.
220 * @param string $name Cookie name
221 * @param array $options Cookie options, see {@link setCookie()}
222 * @since 1.27
223 */
224 public function clearCookie( $name, $options = [] ) {
225 $this->setCookie( $name, '', time() - 31536000 /* 1 year */, $options );
226 }
227
228 /**
229 * Checks whether this request is performing cookie operations
230 *
231 * @return bool
232 * @since 1.27
233 */
234 public function hasCookies() {
235 return (bool)self::$setCookies;
236 }
237 }
238
239 /**
240 * @ingroup HTTP
241 */
242 class FauxResponse extends WebResponse {
243 private $headers;
244 private $cookies = [];
245 private $code;
246
247 /**
248 * Stores a HTTP header
249 * @param string $string Header to output
250 * @param bool $replace Replace current similar header
251 * @param null|int $http_response_code Forces the HTTP response code to the specified value.
252 */
253 public function header( $string, $replace = true, $http_response_code = null ) {
254 if ( substr( $string, 0, 5 ) == 'HTTP/' ) {
255 $parts = explode( ' ', $string, 3 );
256 $this->code = intval( $parts[1] );
257 } else {
258 list( $key, $val ) = array_map( 'trim', explode( ":", $string, 2 ) );
259
260 $key = strtoupper( $key );
261
262 if ( $replace || !isset( $this->headers[$key] ) ) {
263 $this->headers[$key] = $val;
264 }
265 }
266
267 if ( $http_response_code !== null ) {
268 $this->code = intval( $http_response_code );
269 }
270 }
271
272 /**
273 * @since 1.26
274 * @param int $code Status code
275 */
276 public function statusHeader( $code ) {
277 $this->code = intval( $code );
278 }
279
280 public function headersSent() {
281 return false;
282 }
283
284 /**
285 * @param string $key The name of the header to get (case insensitive).
286 * @return string|null The header value (if set); null otherwise.
287 */
288 public function getHeader( $key ) {
289 $key = strtoupper( $key );
290
291 return $this->headers[$key] ?? null;
292 }
293
294 /**
295 * Get the HTTP response code, null if not set
296 *
297 * @return int|null
298 */
299 public function getStatusCode() {
300 return $this->code;
301 }
302
303 /**
304 * @param string $name The name of the cookie.
305 * @param string $value The value to be stored in the cookie.
306 * @param int|null $expire Ignored in this faux subclass.
307 * @param array $options Ignored in this faux subclass.
308 */
309 public function setCookie( $name, $value, $expire = 0, $options = [] ) {
310 global $wgCookiePath, $wgCookiePrefix, $wgCookieDomain;
311 global $wgCookieSecure, $wgCookieExpiration, $wgCookieHttpOnly;
312
313 $options = array_filter( $options, function ( $a ) {
314 return $a !== null;
315 } ) + [
316 'prefix' => $wgCookiePrefix,
317 'domain' => $wgCookieDomain,
318 'path' => $wgCookiePath,
319 'secure' => $wgCookieSecure,
320 'httpOnly' => $wgCookieHttpOnly,
321 'raw' => false,
322 ];
323
324 if ( $expire === null ) {
325 $expire = 0; // Session cookie
326 } elseif ( $expire == 0 && $wgCookieExpiration != 0 ) {
327 $expire = time() + $wgCookieExpiration;
328 }
329
330 $this->cookies[$options['prefix'] . $name] = [
331 'value' => (string)$value,
332 'expire' => (int)$expire,
333 'path' => (string)$options['path'],
334 'domain' => (string)$options['domain'],
335 'secure' => (bool)$options['secure'],
336 'httpOnly' => (bool)$options['httpOnly'],
337 'raw' => (bool)$options['raw'],
338 ];
339 }
340
341 /**
342 * @param string $name
343 * @return string|null
344 */
345 public function getCookie( $name ) {
346 if ( isset( $this->cookies[$name] ) ) {
347 return $this->cookies[$name]['value'];
348 }
349 return null;
350 }
351
352 /**
353 * @param string $name
354 * @return array|null
355 */
356 public function getCookieData( $name ) {
357 return $this->cookies[$name] ?? null;
358 }
359
360 /**
361 * @return array
362 */
363 public function getCookies() {
364 return $this->cookies;
365 }
366 }