Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.22% covered (success)
93.22%
110 / 118
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
HTTPFileStreamer
94.02% covered (success)
94.02%
110 / 117
62.50% covered (warning)
62.50%
5 / 8
43.40
0.00% covered (danger)
0.00%
0 / 1
 preprocessHeaders
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 stream
95.08% covered (success)
95.08%
58 / 61
0.00% covered (danger)
0.00%
0 / 1
20
 send404Message
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 parseRange
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
11.02
 resetOutputBuffers
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 contentTypeFromPath
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 header
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Functions related to the output of file content.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace Wikimedia\FileBackend;
10
11use Wikimedia\Http\HttpStatus;
12use Wikimedia\Timestamp\ConvertibleTimestamp;
13use Wikimedia\Timestamp\TimestampFormat as TS;
14
15/**
16 * Functions related to the output of file content
17 *
18 * @since 1.28
19 */
20class HTTPFileStreamer {
21    /** @var string */
22    protected $path;
23    /** @var callable */
24    protected $obResetFunc;
25    /** @var callable */
26    protected $streamMimeFunc;
27    /** @var callable */
28    protected $headerFunc;
29
30    // Do not send any HTTP headers (i.e. body only)
31    public const STREAM_HEADLESS = 1;
32    // Do not try to tear down any PHP output buffers
33    public const STREAM_ALLOW_OB = 2;
34
35    /**
36     * Takes HTTP headers in a name => value format and converts them to the weird format
37     * expected by stream().
38     * @param string[] $headers
39     * @return array[] [ $headers, $optHeaders ]
40     * @since 1.34
41     */
42    public static function preprocessHeaders( $headers ) {
43        $rawHeaders = [];
44        $optHeaders = [];
45        foreach ( $headers as $name => $header ) {
46            $nameLower = strtolower( $name );
47            if ( in_array( $nameLower, [ 'range', 'if-modified-since' ], true ) ) {
48                $optHeaders[$nameLower] = $header;
49            } else {
50                $rawHeaders[] = "$name$header";
51            }
52        }
53        return [ $rawHeaders, $optHeaders ];
54    }
55
56    /**
57     * @param string $path Local filesystem path to a file
58     * @param array $params Options map, which includes:
59     *   - obResetFunc : alternative callback to clear the output buffer
60     *   - streamMimeFunc : alternative method to determine the content type from the path
61     *   - headerFunc : alternative method for sending response headers
62     */
63    public function __construct( $path, array $params = [] ) {
64        $this->path = $path;
65        $this->obResetFunc = $params['obResetFunc'] ?? self::resetOutputBuffers( ... );
66        $this->streamMimeFunc = $params['streamMimeFunc'] ?? self::contentTypeFromPath( ... );
67        $this->headerFunc = $params['headerFunc'] ?? header( ... );
68    }
69
70    /**
71     * Stream a file to the browser, adding all the headings and fun stuff.
72     * Headers sent include: Content-type, Content-Length, Last-Modified,
73     * and Content-Disposition.
74     *
75     * @param array $headers Any additional headers to send if the file exists
76     * @param bool $sendErrors Send error messages if errors occur (like 404)
77     * @param array $optHeaders HTTP request header map (e.g. "range") (use lowercase keys)
78     * @param int $flags Bitfield of STREAM_* constants
79     * @return bool Success
80     */
81    public function stream(
82        $headers = [], $sendErrors = true, $optHeaders = [], $flags = 0
83    ) {
84        $headless = ( $flags & self::STREAM_HEADLESS );
85
86        // Don't stream it out as text/html if there was a PHP error
87        if ( $headers && headers_sent() ) {
88            echo "Headers already sent, terminating.\n";
89            return false;
90        }
91
92        $headerFunc = $headless
93            ? static function ( $header ) {
94                // no-op
95            }
96            : $this->header( ... );
97
98        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
99        $info = @stat( $this->path );
100
101        if ( !is_array( $info ) ) {
102            if ( $sendErrors ) {
103                self::send404Message( $this->path, $flags );
104            }
105            return false;
106        }
107
108        // Send Last-Modified HTTP header for client-side caching
109        $mtimeCT = new ConvertibleTimestamp( $info['mtime'] );
110        $headerFunc( 'Last-Modified: ' . $mtimeCT->getTimestamp( TS::RFC2822 ) );
111
112        if ( ( $flags & self::STREAM_ALLOW_OB ) == 0 ) {
113            ( $this->obResetFunc )();
114        }
115
116        $type = ( $this->streamMimeFunc )( $this->path );
117        if ( $type && $type != 'unknown/unknown' ) {
118            $headerFunc( "Content-type: $type" );
119        } else {
120            // Send a content type which is not known to Internet Explorer, to
121            // avoid triggering IE's content type detection. Sending a standard
122            // unknown content type here essentially gives IE license to apply
123            // whatever content type it likes.
124            $headerFunc( 'Content-type: application/x-wiki' );
125        }
126
127        // Don't send if client has up to date cache
128        if ( isset( $optHeaders['if-modified-since'] ) ) {
129            $modsince = preg_replace( '/;.*$/', '', $optHeaders['if-modified-since'] );
130            if ( $mtimeCT->getTimestamp( TS::UNIX ) <= strtotime( $modsince ) ) {
131                ini_set( 'zlib.output_compression', 0 );
132                $headerFunc( 304 );
133                return true; // ok
134            }
135        }
136
137        // Send additional headers
138        foreach ( $headers as $header ) {
139            $headerFunc( $header );
140        }
141
142        if ( isset( $optHeaders['range'] ) ) {
143            $range = self::parseRange( $optHeaders['range'], $info['size'] );
144            if ( is_array( $range ) ) {
145                $headerFunc( 206 );
146                $headerFunc( 'Content-Length: ' . $range[2] );
147                $headerFunc( "Content-Range: bytes {$range[0]}-{$range[1]}/{$info['size']}" );
148            } elseif ( $range === 'invalid' ) {
149                if ( $sendErrors ) {
150                    $headerFunc( 416 );
151                    $headerFunc( 'Cache-Control: no-cache' );
152                    $headerFunc( 'Content-Type: text/html; charset=utf-8' );
153                    $headerFunc( 'Content-Range: bytes */' . $info['size'] );
154                }
155                return false;
156            } else { // unsupported Range request (e.g. multiple ranges)
157                $range = null;
158                $headerFunc( 'Content-Length: ' . $info['size'] );
159            }
160        } else {
161            $range = null;
162            $headerFunc( 'Content-Length: ' . $info['size'] );
163        }
164
165        if ( is_array( $range ) ) {
166            $handle = fopen( $this->path, 'rb' );
167            if ( $handle ) {
168                $ok = true;
169                fseek( $handle, $range[0] );
170                $remaining = $range[2];
171                while ( $remaining > 0 && $ok ) {
172                    $bytes = min( $remaining, 8 * 1024 );
173                    $data = fread( $handle, $bytes );
174                    $remaining -= $bytes;
175                    $ok = ( $data !== false );
176                    print $data;
177                }
178            } else {
179                return false;
180            }
181        } else {
182            return readfile( $this->path ) !== false; // faster
183        }
184
185        return true;
186    }
187
188    /**
189     * Send out a standard 404 message for a file
190     *
191     * @param string $fname Full name and path of the file to stream
192     * @param int $flags Bitfield of STREAM_* constants
193     * @since 1.24
194     */
195    public static function send404Message( $fname, $flags = 0 ) {
196        if ( ( $flags & self::STREAM_HEADLESS ) == 0 ) {
197            HttpStatus::header( 404 );
198            header( 'Cache-Control: no-cache' );
199            header( 'Content-Type: text/html; charset=utf-8' );
200        }
201        $encFile = htmlspecialchars( $fname );
202        $encScript = htmlspecialchars( $_SERVER['SCRIPT_NAME'] );
203        echo "<!DOCTYPE html><html><body>
204            <h1>File not found</h1>
205            <p>Although this PHP script ($encScript) exists, the file requested for output
206            ($encFile) does not.</p>
207            </body></html>
208            ";
209    }
210
211    /**
212     * Convert a Range header value to an absolute (start, end) range tuple
213     *
214     * @param string $range Range header value
215     * @param int $size File size
216     * @return array|string Returns error string on failure (start, end, length)
217     * @since 1.24
218     */
219    public static function parseRange( $range, $size ) {
220        $m = [];
221        if ( preg_match( '#^bytes=(\d*)-(\d*)$#', $range, $m ) ) {
222            [ , $start, $end ] = $m;
223            if ( $start === '' && $end === '' ) {
224                $absRange = [ 0, $size - 1 ];
225            } elseif ( $start === '' ) {
226                $absRange = [ $size - (int)$end, $size - 1 ];
227            } elseif ( $end === '' ) {
228                $absRange = [ (int)$start, $size - 1 ];
229            } else {
230                $absRange = [ (int)$start, (int)$end ];
231            }
232            if ( $absRange[0] >= 0 && $absRange[1] >= $absRange[0] ) {
233                if ( $absRange[0] < $size ) {
234                    $absRange[1] = min( $absRange[1], $size - 1 ); // stop at EOF
235                    $absRange[2] = $absRange[1] - $absRange[0] + 1;
236                    return $absRange;
237                } elseif ( $absRange[0] == 0 && $size == 0 ) {
238                    return 'unrecognized'; // the whole file should just be sent
239                }
240            }
241            return 'invalid';
242        }
243        return 'unrecognized';
244    }
245
246    protected static function resetOutputBuffers() {
247        while ( ob_get_status() ) {
248            if ( !ob_end_clean() ) {
249                // Could not remove output buffer handler; abort now
250                // to avoid getting in some kind of infinite loop.
251                break;
252            }
253        }
254    }
255
256    /**
257     * Determine the file type of a file based on the path
258     *
259     * @param string $filename Storage path or file system path
260     */
261    protected static function contentTypeFromPath( string $filename ): string {
262        $ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
263        return match ( $ext ) {
264            'gif' => 'image/gif',
265            'png' => 'image/png',
266            'jpg',
267            'jpeg' => 'image/jpeg',
268            'webp' => 'image/webp',
269            default => 'unknown/unknown',
270        };
271    }
272
273    /**
274     * @param string|int $header
275     */
276    private function header( $header ) {
277        if ( is_int( $header ) ) {
278            $header = HttpStatus::getHeader( $header );
279        }
280
281        ( $this->headerFunc )( $header );
282    }
283}
284
285/** @deprecated class alias since 1.43 */
286class_alias( HTTPFileStreamer::class, 'HTTPFileStreamer' );