Merge "Expose sort orders from search engine in ApiQuerySearch"
[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 return $this->headers[$key] ?? null;
281 }
282
283 /**
284 * Get the HTTP response code, null if not set
285 *
286 * @return int|null
287 */
288 public function getStatusCode() {
289 return $this->code;
290 }
291
292 /**
293 * @param string $name The name of the cookie.
294 * @param string $value The value to be stored in the cookie.
295 * @param int|null $expire Ignored in this faux subclass.
296 * @param array $options Ignored in this faux subclass.
297 */
298 public function setCookie( $name, $value, $expire = 0, $options = [] ) {
299 global $wgCookiePath, $wgCookiePrefix, $wgCookieDomain;
300 global $wgCookieSecure, $wgCookieExpiration, $wgCookieHttpOnly;
301
302 $options = array_filter( $options, function ( $a ) {
303 return $a !== null;
304 } ) + [
305 'prefix' => $wgCookiePrefix,
306 'domain' => $wgCookieDomain,
307 'path' => $wgCookiePath,
308 'secure' => $wgCookieSecure,
309 'httpOnly' => $wgCookieHttpOnly,
310 'raw' => false,
311 ];
312
313 if ( $expire === null ) {
314 $expire = 0; // Session cookie
315 } elseif ( $expire == 0 && $wgCookieExpiration != 0 ) {
316 $expire = time() + $wgCookieExpiration;
317 }
318
319 $this->cookies[$options['prefix'] . $name] = [
320 'value' => (string)$value,
321 'expire' => (int)$expire,
322 'path' => (string)$options['path'],
323 'domain' => (string)$options['domain'],
324 'secure' => (bool)$options['secure'],
325 'httpOnly' => (bool)$options['httpOnly'],
326 'raw' => (bool)$options['raw'],
327 ];
328 }
329
330 /**
331 * @param string $name
332 * @return string|null
333 */
334 public function getCookie( $name ) {
335 if ( isset( $this->cookies[$name] ) ) {
336 return $this->cookies[$name]['value'];
337 }
338 return null;
339 }
340
341 /**
342 * @param string $name
343 * @return array|null
344 */
345 public function getCookieData( $name ) {
346 return $this->cookies[$name] ?? null;
347 }
348
349 /**
350 * @return array
351 */
352 public function getCookies() {
353 return $this->cookies;
354 }
355 }