| | |
| | set_time_limit( 0 ); |
| | + ignore_user_abort( true ); |
| | |
| | - while( ob_get_level() > 0 ) { |
| | - ob_end_flush(); |
| | + $level = ob_get_level(); |
| | + $cleared = false; |
| | + |
| | + while( $level > 0 && !$cleared ) { |
| | + ob_end_clean(); |
| | + |
| | + $newLevel = ob_get_level(); |
| | + |
| | + if( $newLevel === $level ) { |
| | + $cleared = true; |
| | + } |
| | + else { |
| | + $level = $newLevel; |
| | + } |
| | } |
| | |
| | if( session_id() === "" ) { |
| | session_start(); |
| | } |
| | |
| | - ignore_user_abort( true ); |
| | + class DownloadManager { |
| | + private $filename; |
| | + private $expiry; |
| | |
| | - $filename = get_sanitized_filename(); |
| | - $valid = !empty( $filename ); |
| | - $expiry = 24 * 60 * 60; |
| | + public function __construct( $filename, $expiry ) { |
| | + $this->filename = $filename; |
| | + $this->expiry = $expiry; |
| | + } |
| | |
| | - if( $valid && download( $filename ) && token_expired( $expiry ) ) { |
| | - increment_count( "$filename-count.txt" ); |
| | - } |
| | + public function process() { |
| | + $result = false; |
| | |
| | - /** |
| | - * Retrieve the file name being downloaded from the HTTP GET request. |
| | - * |
| | - * @return string The sanitized file name (without path information). |
| | - */ |
| | - function get_sanitized_filename() { |
| | - $filepath = isset( $_GET[ 'filename' ] ) ? $_GET[ 'filename' ] : ''; |
| | - $fileinfo = pathinfo( $filepath ); |
| | - $basename = $fileinfo[ 'basename' ]; |
| | + if( $this->filename !== '' ) { |
| | + if( $this->tokenExpired() ) { |
| | + $this->incrementCount(); |
| | + } |
| | |
| | - if( isset( $_SERVER[ 'HTTP_USER_AGENT' ] ) ) { |
| | - $periods = substr_count( $basename, '.' ); |
| | + $result = $this->download(); |
| | + } |
| | |
| | - $basename = strstr( $_SERVER[ 'HTTP_USER_AGENT' ], 'MSIE' ) |
| | - ? mb_ereg_replace( '/\./', '%2e', $basename, $periods - 1 ) |
| | - : $basename; |
| | + return $result; |
| | } |
| | - |
| | - $basename = mb_ereg_replace( '/\s+/', '', $basename ); |
| | - $basename = mb_ereg_replace( '([^\w\d\-_~,;\[\]\(\).])', '', $basename ); |
| | |
| | - return $basename; |
| | - } |
| | + /** |
| | + * Downloads a file, allowing for resuming partial downloads. |
| | + * |
| | + * @return bool True if the file was transferred. |
| | + */ |
| | + private function download() { |
| | + $result = false; |
| | + $method = isset( $_SERVER[ 'REQUEST_METHOD' ] ) |
| | + ? $_SERVER[ 'REQUEST_METHOD' ] |
| | + : 'GET'; |
| | + $isHead = $method === 'HEAD'; |
| | + $contentType = mime_content_type( $this->filename ); |
| | |
| | - /** |
| | - * Answers whether the user's download token has expired. |
| | - * |
| | - * @param int $lifetime Number of seconds before expiring the token. |
| | - * |
| | - * @return bool True indicates the token has expired (or was not set). |
| | - */ |
| | - function token_expired( $lifetime ) { |
| | - $TOKEN_NAME = 'LAST_DOWNLOAD'; |
| | - $now = time(); |
| | - $expired = !isset( $_SESSION[ $TOKEN_NAME ] ); |
| | + clearstatcache(); |
| | |
| | - if( !$expired && ( $now - $_SESSION[ $TOKEN_NAME ] > $lifetime ) ) { |
| | - $expired = true; |
| | - $_SESSION = array(); |
| | + $fileSize = @filesize( $this->filename ); |
| | + $size = $fileSize === false || empty( $fileSize ) ? 0 : $fileSize; |
| | |
| | - session_destroy(); |
| | - } |
| | + if( !$isHead ) { |
| | + $rangeData = $this->parseRange( $size ); |
| | + $start = $rangeData[ 0 ]; |
| | + $length = $rangeData[ 1 ]; |
| | |
| | - $_SESSION[ $TOKEN_NAME ] = $now; |
| | + if( $length > 0 ) { |
| | + header_remove( 'x-powered-by' ); |
| | + header( 'Expires: 0' ); |
| | + header( |
| | + 'Cache-Control: public, must-revalidate, post-check=0, pre-check=0' |
| | + ); |
| | + header( 'Cache-Control: private', false ); |
| | + header( |
| | + 'Content-Disposition: attachment; filename="' . |
| | + $this->filename . '"' |
| | + ); |
| | + header( 'Accept-Ranges: bytes' ); |
| | + header( "Content-Length: $length" ); |
| | + header( "Content-Type: $contentType" ); |
| | |
| | - $TOKEN_CREATE = 'CREATED'; |
| | + $result = $this->transmit( $start, $size ); |
| | + } |
| | + } |
| | |
| | - if( !isset( $_SESSION[ $TOKEN_CREATE ] ) ) { |
| | - $_SESSION[ $TOKEN_CREATE ] = $now; |
| | - } |
| | - else if( $now - $_SESSION[ $TOKEN_CREATE ] > $lifetime ) { |
| | - session_regenerate_id( true ); |
| | - $_SESSION[ $TOKEN_CREATE ] = $now; |
| | + return $result; |
| | } |
| | - |
| | - return $expired; |
| | - } |
| | - |
| | - /** |
| | - * Downloads a file, allowing for resuming partial downloads. |
| | - * |
| | - * @param string $filename File to download, must be in script directory. |
| | - * |
| | - * @return bool True if the file was transferred. |
| | - */ |
| | - function download( $filename ) { |
| | - clearstatcache(); |
| | |
| | - $size = @filesize( $filename ); |
| | - $size = $size === false || empty( $size ) ? 0 : $size; |
| | - $content_type = mime_content_type( $filename ); |
| | - list( $seek_start, $content_length ) = parse_range( $size ); |
| | + /** |
| | + * Parses the HTTP range request header, provided one was sent by the |
| | + * client. This provides download resume functionality. |
| | + * |
| | + * @param int $size The total file size (as stored on disk). |
| | + * |
| | + * @return array The starting offset for resuming the download, or 0 to |
| | + * download the entire file (i.e., no offset could be parsed); also the |
| | + * number of bytes to be transferred. |
| | + */ |
| | + private function parseRange( $size ) { |
| | + $start = 0; |
| | + $length = $size; |
| | |
| | - header_remove( 'x-powered-by' ); |
| | - header( 'Expires: 0' ); |
| | - header( 'Cache-Control: public, must-revalidate, post-check=0, pre-check=0' ); |
| | - header( 'Cache-Control: private', false ); |
| | - header( "Content-Disposition: attachment; filename=\"$filename\"" ); |
| | - header( 'Accept-Ranges: bytes' ); |
| | - header( "Content-Length: $content_length" ); |
| | - header( "Content-Type: $content_type" ); |
| | + if( isset( $_SERVER[ 'HTTP_RANGE' ] ) ) { |
| | + $format = '/^bytes=(\d*)-(\d*)(?:,\d*-\d*)*$/'; |
| | + $range = $_SERVER[ 'HTTP_RANGE' ]; |
| | + $match = preg_match( $format, $range, $matches ); |
| | |
| | - $method = isset( $_SERVER[ 'REQUEST_METHOD' ] ) |
| | - ? $_SERVER[ 'REQUEST_METHOD' ] |
| | - : 'GET'; |
| | + if( $match ) { |
| | + $start = isset( $matches[ 1 ] ) ? (int)$matches[ 1 ] : 0; |
| | + $end = !empty( $matches[ 2 ] ) ? (int)$matches[ 2 ] : $size - 1; |
| | + $length = $end - $start + 1; |
| | |
| | - return $method === 'HEAD' |
| | - ? false |
| | - : transmit( $filename, $seek_start, $size ); |
| | - } |
| | + header( 'HTTP/1.1 206 Partial Content' ); |
| | + header( "Content-Range: bytes $start-$end/$size" ); |
| | + } |
| | + else { |
| | + header( 'HTTP/1.1 416 Requested Range Not Satisfiable' ); |
| | + header( "Content-Range: bytes */$size" ); |
| | |
| | - /** |
| | - * Parses the HTTP range request header, provided one was sent by the |
| | - * client. This provides download resume functionality. |
| | - * |
| | - * @param int $size The total file size (as stored on disk). |
| | - * |
| | - * @return array The starting offset for resuming the download, or 0 to |
| | - * download the entire file (i.e., no offset could be parsed); also the |
| | - * number of bytes to be transferred. |
| | - */ |
| | - function parse_range( $size ) { |
| | - $seek_start = 0; |
| | - $content_length = $size; |
| | + $length = 0; |
| | + } |
| | + } |
| | |
| | - if( isset( $_SERVER[ 'HTTP_RANGE' ] ) ) { |
| | - $range_format = '/^bytes=(\d*)-(\d*)(?:,\d*-\d*)*$/'; |
| | - $request_range = $_SERVER[ 'HTTP_RANGE' ]; |
| | + return array( |
| | + $start, |
| | + $length |
| | + ); |
| | + } |
| | |
| | - if( !preg_match( $range_format, $request_range, $matches ) ) { |
| | - header( 'HTTP/1.1 416 Requested Range Not Satisfiable' ); |
| | - header( "Content-Range: bytes */$size" ); |
| | + /** |
| | + * Transmits a file from the server to the client. |
| | + * |
| | + * @param int $seekStart Offset into file to start downloading. |
| | + * @param int $size Total size of the file. |
| | + * |
| | + * @return bool True if the file was transferred. |
| | + */ |
| | + private function transmit( $seekStart, $size ) { |
| | + $result = false; |
| | + $bytesSent = -1; |
| | + $fp = @fopen( $this->filename, 'rb' ); |
| | |
| | - exit; |
| | - } |
| | + if( $fp !== false ) { |
| | + $aborted = false; |
| | + $bytesSent = $seekStart; |
| | + $chunkSize = 1024 * 16; |
| | |
| | - $seek_start = isset( $matches[ 1 ] ) ? $matches[ 1 ] + 0 : 0; |
| | - $seek_end = !empty( $matches[ 2 ] ) ? $matches[ 2 ] + 0 : $size - 1; |
| | - $range_bytes = $seek_start . '-' . $seek_end . '/' . $size; |
| | - $content_length = $seek_end - $seek_start + 1; |
| | + @fseek( $fp, $seekStart ); |
| | |
| | - header( 'HTTP/1.1 206 Partial Content' ); |
| | - header( "Content-Range: bytes $range_bytes" ); |
| | - } |
| | + while( !feof( $fp ) && !$aborted ) { |
| | + print( @fread( $fp, $chunkSize ) ); |
| | |
| | - return array( $seek_start, $content_length ); |
| | - } |
| | + $bytesSent += $chunkSize; |
| | + $aborted = connection_aborted() || connection_status() !== 0; |
| | |
| | - /** |
| | - * Transmits a file from the server to the client. |
| | - * |
| | - * @param string $filename File to download, must be this script directory. |
| | - * @param int $seek_start Offset into file to start downloading. |
| | - * @param int $size Total size of the file. |
| | - * |
| | - * @return bool True if the file was transferred. |
| | - */ |
| | - function transmit( $filename, $seek_start, $size ) { |
| | - if( ob_get_level() == 0 ) { |
| | - ob_start(); |
| | - } |
| | + flush(); |
| | + } |
| | |
| | - $bytes_sent = -1; |
| | - $fp = @fopen( $filename, 'rb' ); |
| | + fclose( $fp ); |
| | |
| | - if( $fp !== false ) { |
| | - @fseek( $fp, $seek_start ); |
| | + $result = $bytesSent >= $size; |
| | + } |
| | |
| | - $aborted = false; |
| | - $bytes_sent = $seek_start; |
| | - $chunk_size = 1024 * 16; |
| | + return $result; |
| | + } |
| | |
| | - while( !feof( $fp ) && !$aborted ) { |
| | - print( @fread( $fp, $chunk_size ) ); |
| | + /** |
| | + * Answers whether the user's download token has expired. |
| | + * |
| | + * @return bool True indicates the token has expired (or was not set). |
| | + */ |
| | + private function tokenExpired() { |
| | + $tokenName = 'LAST_DOWNLOAD'; |
| | + $tokenCreate = 'CREATED'; |
| | + $now = time(); |
| | + $expired = !isset( $_SESSION[ $tokenName ] ); |
| | |
| | - $bytes_sent += $chunk_size; |
| | + if( !$expired && $now - $_SESSION[ $tokenName ] > $this->expiry ) { |
| | + $expired = true; |
| | + $_SESSION = array(); |
| | |
| | - if( ob_get_level() > 0 ) { |
| | - ob_flush(); |
| | - } |
| | + session_destroy(); |
| | + } |
| | |
| | - flush(); |
| | + $_SESSION[ $tokenName ] = $now; |
| | |
| | - $aborted = connection_aborted() || connection_status() != 0; |
| | + if( !isset( $_SESSION[ $tokenCreate ] ) ) { |
| | + $_SESSION[ $tokenCreate ] = $now; |
| | } |
| | + else { |
| | + $elapsed = $now - $_SESSION[ $tokenCreate ]; |
| | |
| | - if( ob_get_level() > 0 ) { |
| | - ob_end_flush(); |
| | + if( $elapsed > $this->expiry ) { |
| | + session_regenerate_id( true ); |
| | + |
| | + $_SESSION[ $tokenCreate ] = $now; |
| | + } |
| | } |
| | |
| | - fclose( $fp ); |
| | + return $expired; |
| | } |
| | |
| | - return $bytes_sent >= $size; |
| | - } |
| | + /** |
| | + * Increments the number in a file using an exclusive lock. The file |
| | + * is set to an initial value set to 0 if it doesn't exist. |
| | + */ |
| | + private function incrementCount() { |
| | + $result = false; |
| | + $filename = $this->filename . '-count.txt'; |
| | + $lockDir = $filename . '.lock'; |
| | + $locked = $this->lockOpen( $lockDir ); |
| | |
| | - /** |
| | - * Increments the number in a file using an exclusive lock. The file |
| | - * is set to an initial value set to 0 if it doesn't exist. |
| | - * |
| | - * @param string $filename The file containing a number to increment. |
| | - */ |
| | - function increment_count( $filename ) { |
| | - try { |
| | - lock_open( $filename ); |
| | + if( $locked ) { |
| | + $count = (int)@file_get_contents( $filename ) + 1; |
| | |
| | - $count = @file_get_contents( $filename ) + 1; |
| | + @file_put_contents( $filename, $count ); |
| | + @rmdir( $lockDir ); |
| | |
| | - file_put_contents( $filename, $count ); |
| | - } |
| | - finally { |
| | - lock_close( $filename ); |
| | + $result = true; |
| | + } |
| | + |
| | + return $result; |
| | } |
| | - } |
| | |
| | - /** |
| | - * Acquires a lock for a particular file. Callers would be prudent to |
| | - * call this function from within a try/finally block and close the lock |
| | - * in the finally section. The amount of time between opening and closing |
| | - * the lock must be minimal because parallel processes will be waiting on |
| | - * the lock's release. |
| | - * |
| | - * @param string $filename The name of file to lock. |
| | - * |
| | - * @return bool True if the lock was obtained, false upon excessive attempts. |
| | - */ |
| | - function lock_open( $filename ) { |
| | - $lockdir = create_lock_filename( $filename ); |
| | - $iterations = 0; |
| | + /** |
| | + * Acquires a lock for a particular file. Callers would be prudent to |
| | + * call this function from within a try/finally block and close the lock |
| | + * in the finally section. The amount of time between opening and closing |
| | + * the lock must be minimal because parallel processes will be waiting on |
| | + * the lock's release. |
| | + * |
| | + * @param string $lockDir The name of directory to lock. |
| | + * |
| | + * @return bool True if the lock was obtained, false upon excessive |
| | + * attempts. |
| | + */ |
| | + private function lockOpen( $lockDir ) { |
| | + $result = false; |
| | + $iterations = 0; |
| | |
| | - do { |
| | - if( @mkdir( $lockdir, 0777 ) ) { |
| | - $iterations = 0; |
| | - } |
| | - else { |
| | - $iterations++; |
| | - $lifetime = time() - filemtime( $lockdir ); |
| | + while( $iterations < 10 && !$result ) { |
| | + $result = @mkdir( $lockDir, 0777 ); |
| | |
| | - if( $lifetime > 10 ) { |
| | - @rmdir( $lockdir ); |
| | - } |
| | - else { |
| | - usleep( rand( 1000, 10000 ) ); |
| | + if( !$result ) { |
| | + $iterations++; |
| | + $lifetime = time() - (int)@filemtime( $lockDir ); |
| | + |
| | + if( $lifetime > 10 ) { |
| | + @rmdir( $lockDir ); |
| | + } |
| | + else { |
| | + usleep( rand( 1000, 10000 ) ); |
| | + } |
| | } |
| | } |
| | - } |
| | - while( $iterations > 0 && $iterations < 10 ); |
| | |
| | - return $iterations == 0; |
| | + return $result; |
| | + } |
| | } |
| | |
| | /** |
| | - * Releases the lock on a particular file. |
| | + * Retrieve the file name being downloaded from the HTTP GET request. |
| | * |
| | - * @param string $filename The name of file that was locked. |
| | + * @return string The sanitized file name (without path information). |
| | */ |
| | - function lock_close( $filename ) { |
| | - @rmdir( create_lock_filename( $filename ) ); |
| | - } |
| | + function getSanitizedFilename() { |
| | + $filepath = isset( $_GET[ 'filename' ] ) ? $_GET[ 'filename' ] : ''; |
| | + $fileinfo = pathinfo( $filepath ); |
| | + $basename = $fileinfo[ 'basename' ]; |
| | + $noSpaces = preg_replace( '/\s+/', '', $basename ); |
| | |
| | - /** |
| | - * Creates a uniquely named lock directory name. |
| | - * |
| | - * @param string $filename The name of the file under contention. |
| | - * |
| | - * @return string A unique lock file reference for the given file name. |
| | - */ |
| | - function create_lock_filename( $filename ) { |
| | - return $filename . '.lock'; |
| | + return preg_replace( '/[^\w\d\-_~,;\[\]\(\).]/', '', $noSpaces ); |
| | } |
| | + |
| | + $filename = getSanitizedFilename(); |
| | + $expiry = 24 * 60 * 60; |
| | + $manager = new DownloadManager( $filename, $expiry ); |
| | + |
| | + $manager->process(); |
| | ?> |
| | |