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