From 9233d5cc2ba3f5217a1e9e4154c9bbc6cdcc309a Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 1 May 2026 16:00:59 -0400 Subject: [PATCH] fix: parse HTTP Range header correctly in output_file() fixes #4777 The previous implementation used `str_replace($range, '-', $range)` which is a no-op (wrong arg order, return value discarded), then cast the raw Range value (e.g. "12345-67890" or "-500") to int. The function then ignored the requested END entirely and always streamed from the parsed start to EOF. For a suffix range like `Range: bytes=-500` -- which Chrome's media stack sends to locate the moov atom in many HEVC mp4s -- (int)"-500" is -500, producing Content-Length = filesize + 500. fseek with SEEK_SET fails for negative offsets, so the body delivered was filesize bytes against an inflated Content-Length, triggering ERR_CONTENT_LENGTH_MISMATCH in the browser and blocking HEVC playback in the files view. Parse `bytes=start-end`, `bytes=start-`, and `bytes=-suffix` per RFC 7233, clamp the end to file size, return 416 for unsatisfiable ranges, set Content-Length to the actual byte count served, and stop reading once that many bytes have been emitted. Guard ob_flush() with ob_get_level() so it does not warn when no buffer is active. Verified on pseudo by loading an HEVC mp4 in Chrome -- the ERR_CONTENT_LENGTH_MISMATCH is gone, the browser parses metadata (duration, dimensions) and buffers playback data normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/includes/functions.php | 51 +++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/web/includes/functions.php b/web/includes/functions.php index 9dae2966b..4fa640bcc 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2367,22 +2367,38 @@ function output_file($path, $chunkSize=1024) { header("Content-Disposition: $contentDisposition;filename=\"$file\""); header('Accept-Ranges: bytes'); - $range = 0; $size = filesize($path); + $start = 0; + $end = $size - 1; if (isset($_SERVER['HTTP_RANGE'])) { - list($a, $range) = explode('=', $_SERVER['HTTP_RANGE']); - str_replace($range, '-', $range); - $range = (int)$range; #fseek etc require integers not strings - $size2 = $size - 1; - $new_length = $size - $range; + # RFC 7233: bytes=start-end | bytes=start- | bytes=-suffix + if (preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m) + and ($m[1] !== '' or $m[2] !== '')) { + if ($m[1] === '') { + # Suffix range: last N bytes + $suffix = (int)$m[2]; + if ($suffix > $size) $suffix = $size; + $start = $size - $suffix; + } else { + $start = (int)$m[1]; + if ($m[2] !== '') $end = (int)$m[2]; + } + if ($end > $size - 1) $end = $size - 1; + } + if ($start > $end or $start >= $size) { + header('HTTP/1.1 416 Range Not Satisfiable'); + header("Content-Range: bytes */$size"); + return false; + } + $length = $end - $start + 1; header('HTTP/1.1 206 Partial Content'); - header("Content-Length: $new_length"); - header("Content-Range: bytes $range-$size2/$size"); + header("Content-Length: $length"); + header("Content-Range: bytes $start-$end/$size"); } else { - $size2 = $size - 1; - header("Content-Range: bytes 0-$size2/$size"); - header('Content-Length: ' . $size); + $length = $size; + header("Content-Range: bytes 0-$end/$size"); + header("Content-Length: $size"); } if ($size == 0) { @@ -2391,13 +2407,18 @@ function output_file($path, $chunkSize=1024) { @ini_set('magic_quotes_runtime', 0); $fp = fopen($path, 'rb'); - fseek($fp, $range); + fseek($fp, $start); - while (!feof($fp) and (connection_status() == 0)) { + $remaining = $length; + $buffer = 1024 * $chunkSize; + while ($remaining > 0 and !feof($fp) and (connection_status() == 0)) { set_time_limit(0); - print(@fread($fp, 1024*$chunkSize)); + $data = @fread($fp, min($buffer, $remaining)); + if ($data === false or $data === '') break; + print($data); flush(); - ob_flush(); + if (ob_get_level() > 0) ob_flush(); + $remaining -= strlen($data); } fclose($fp);