Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
OutputHandler
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 4
812
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
110
 findUriExtension
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 handleGzip
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
110
 emitContentLength
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Functions to be used with PHP's output buffer.
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
23namespace MediaWiki\Output;
24
25use MediaWiki\Logger\LoggerFactory;
26use MediaWiki\MainConfigNames;
27use MediaWiki\MediaWikiServices;
28
29/**
30 * @since 1.31
31 */
32class OutputHandler {
33    /**
34     * Standard output handler for use with ob_start.
35     *
36     * Output buffers using this method should only be started from MW_SETUP_CALLBACK,
37     * and only if there are no parent output buffers.
38     *
39     * @param string $s Web response output
40     * @param int $phase Flags indicating the reason for the call
41     * @return string
42     */
43    public static function handle( $s, $phase ) {
44        $config = MediaWikiServices::getInstance()->getMainConfig();
45        $disableOutputCompression = $config->get( MainConfigNames::DisableOutputCompression );
46        // Don't send headers if output is being discarded (T278579)
47        if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) === PHP_OUTPUT_HANDLER_CLEAN ) {
48            $logger = LoggerFactory::getInstance( 'output' );
49            $logger->debug( __METHOD__ . " entrypoint={entry}; size={size}; phase=$phase", [
50                'entry' => MW_ENTRY_POINT,
51                'size' => strlen( $s ),
52            ] );
53
54            return $s;
55        }
56
57        // Check if a compression output buffer is already enabled via php.ini. Such
58        // buffers exists at the start of the request and are reflected by ob_get_level().
59        $phpHandlesCompression = (
60            ini_get( 'output_handler' ) === 'ob_gzhandler' ||
61            ini_get( 'zlib.output_handler' ) === 'ob_gzhandler' ||
62            !in_array(
63                strtolower( ini_get( 'zlib.output_compression' ) ),
64                [ '', 'off', '0' ]
65            )
66        );
67
68        if (
69            // Compression is not already handled by an internal PHP buffer
70            !$phpHandlesCompression &&
71            // Compression is not disabled by the application entry point
72            !defined( 'MW_NO_OUTPUT_COMPRESSION' ) &&
73            // Compression is not disabled by site configuration
74            !$disableOutputCompression
75        ) {
76            $s = self::handleGzip( $s );
77        }
78
79        if (
80            // Response body length does not depend on internal PHP compression buffer
81            !$phpHandlesCompression &&
82            // Response body length does not depend on mangling by a custom buffer
83            !ini_get( 'output_handler' ) &&
84            !ini_get( 'zlib.output_handler' )
85        ) {
86            self::emitContentLength( strlen( $s ) );
87        }
88
89        return $s;
90    }
91
92    /**
93     * Get the "file extension" that some client apps will estimate from
94     * the currently-requested URL.
95     *
96     * This isn't a WebRequest method, because we need it before the class loads.
97     * @todo As of 2018, this actually runs after autoloader in Setup.php, so
98     * WebRequest seems like a good place for this.
99     *
100     * @return string
101     */
102    private static function findUriExtension() {
103        // @todo FIXME: this sort of dupes some code in WebRequest::getRequestUrl()
104        if ( isset( $_SERVER['REQUEST_URI'] ) ) {
105            // Strip the query string...
106            $path = explode( '?', $_SERVER['REQUEST_URI'], 2 )[0];
107        } elseif ( isset( $_SERVER['SCRIPT_NAME'] ) ) {
108            // Probably IIS. QUERY_STRING appears separately.
109            $path = $_SERVER['SCRIPT_NAME'];
110        } else {
111            // Can't get the path from the server? :(
112            return '';
113        }
114
115        $period = strrpos( $path, '.' );
116        if ( $period !== false ) {
117            return strtolower( substr( $path, $period ) );
118        }
119        return '';
120    }
121
122    /**
123     * Handler that compresses data with gzip if allowed by the Accept header.
124     *
125     * Unlike ob_gzhandler, it works for HEAD requests too. This assumes that the application
126     * processes them as normal GET request and that the webserver is tasked with stripping out
127     * the response body before sending the response the client.
128     *
129     * @param string $s Web response output
130     * @return string
131     */
132    private static function handleGzip( $s ) {
133        if ( !function_exists( 'gzencode' ) ) {
134            wfDebug( __METHOD__ . "() skipping compression (gzencode unavailable)" );
135            return $s;
136        }
137        if ( headers_sent() ) {
138            wfDebug( __METHOD__ . "() skipping compression (headers already sent)" );
139            return $s;
140        }
141
142        $ext = self::findUriExtension();
143        if ( $ext == '.gz' || $ext == '.tgz' ) {
144            // Don't do gzip compression if the URL path ends in .gz or .tgz
145            // This confuses Safari and triggers a download of the page,
146            // even though it's pretty clearly labeled as viewable HTML.
147            // Bad Safari! Bad!
148            return $s;
149        }
150
151        if ( $s === '' ) {
152            // Do not gzip empty HTTP responses since that would not only bloat the body
153            // length, but it would result in invalid HTTP responses when the HTTP status code
154            // is one that must not be accompanied by a body (e.g. "204 No Content").
155            return $s;
156        }
157
158        if ( wfClientAcceptsGzip() ) {
159            wfDebug( __METHOD__ . "() is compressing output" );
160            header( 'Content-Encoding: gzip' );
161            $s = gzencode( $s, 6 );
162        }
163
164        // Set vary header if it hasn't been set already
165        $headers = headers_list();
166        $foundVary = false;
167        foreach ( $headers as $header ) {
168            $headerName = strtolower( substr( $header, 0, 5 ) );
169            if ( $headerName == 'vary:' ) {
170                $foundVary = true;
171                break;
172            }
173        }
174        if ( !$foundVary ) {
175            header( 'Vary: Accept-Encoding' );
176        }
177        return $s;
178    }
179
180    /**
181     * Set the Content-Length header if possible
182     *
183     * This sets Content-Length for the following cases:
184     *  - When the response body is meaningful (HTTP 200/404).
185     *  - On any HTTP 1.0 request response. This improves cooperation with certain CDNs.
186     *
187     * This assumes that HEAD requests are processed as GET requests by MediaWiki and that
188     * the webserver is tasked with stripping out the body.
189     *
190     * Setting Content-Length can prevent clients from getting stuck waiting on PHP to finish
191     * while deferred updates are running.
192     *
193     * @param int $length
194     */
195    private static function emitContentLength( $length ) {
196        if ( headers_sent() ) {
197            wfDebug( __METHOD__ . "() headers already sent" );
198            return;
199        }
200
201        if (
202            in_array( http_response_code(), [ 200, 404 ], true ) ||
203            ( $_SERVER['SERVER_PROTOCOL'] ?? null ) === 'HTTP/1.0'
204        ) {
205            header( "Content-Length: $length" );
206        }
207    }
208}
209
210/** @deprecated class alias since 1.41 */
211class_alias( OutputHandler::class, 'MediaWiki\\OutputHandler' );