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 23use Wikimedia\AtEase\AtEase; 24use Wikimedia\Timestamp\ConvertibleTimestamp; 25 26/** 27 * Functions related to the output of file content 28 * 29 * @since 1.28 30 */ 31class 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 unless requested by caller (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 ? function ( $header ) { 99 // no-op 100 } 101 : 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 header( $header ); // always use header(); specifically requested 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 list( , $start, $end ) = $m; 231 if ( $start === '' && $end === '' ) { 232 $absRange = [ 0, $size - 1 ]; 233 } elseif ( $start === '' ) { 234 $absRange = [ $size - $end, $size - 1 ]; 235 } elseif ( $end === '' ) { 236 $absRange = [ $start, $size - 1 ]; 237 } else { 238 $absRange = [ $start, $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} 288