mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-06 09:22:04 -04:00
view_video.php took the end of the range straight from the request and never checked it against the file: if (!empty($matches[2])) $end = intval($matches[2]); $length = $end - $begin + 1; so on a 1000 byte file: bytes=0-99999 206, Content-Length 100000, body 1000 bytes bytes=5000-6000 206, Content-Range naming bytes that do not exist bytes=500-100 206, Content-Length -399 bytes=1000- 206, Content-Length 0 bytes=-100 200 with the whole file, not the last 100 bytes A client that is told to expect 100000 bytes and gets 1000 does not see a bad request, it sees a truncated file, and reports the video as broken. The suffix form was not recognised at all because the pattern required a digit before the dash. This is reached once per fragment by the byte-range HLS manifest VideoStore writes, every fragment being a Range against the one mp4, so a player that asks for anything the file cannot supply gets a body that does not match its own Content-Length rather than an answer it can act on. Parse the header properly: clamp a range that runs past the end, because a client may ask for more than is there and is entitled to what is there; answer 416 with "Content-Range: bytes */size" when the range cannot be satisfied at all, so the client learns the real length; and read "-N" as the last N bytes. Length is now derived from the range being served rather than the one requested, and the send loop counts down by the bytes it actually read, so Content-Length and the body cannot disagree. Only the first range of a multi-range request is served, as before. A multipart/byteranges body is not worth building for this, and falling back to sending the whole representation is not an option when these are event videos of hundreds of megabytes; Content-Range names exactly what was sent. The parsing is its own dependency-free include so it can be tested without a database, and tests/php/test_http_range.php covers each case above plus a sweep asserting that every range it ever returns lies inside the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
3.7 KiB
PHP
85 lines
3.7 KiB
PHP
<?php
|
|
//
|
|
// ZoneMinder HTTP Range request parsing
|
|
// Copyright (C) 2026 ZoneMinder LLC
|
|
//
|
|
// This program is free software; you can redistribute it and/or
|
|
// modify it under the terms of the GNU General Public License
|
|
// as published by the Free Software Foundation; either version 2
|
|
// of the License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program; if not, write to the Free Software
|
|
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
//
|
|
// Deliberately free of dependencies: nothing here needs the config, the
|
|
// database or the session, so it can be reasoned about and tested on its own.
|
|
//
|
|
|
|
// Work out which bytes a Range request asks for, given a representation of
|
|
// $size bytes.
|
|
//
|
|
// Returns one of three things, which the caller has to tell apart with ===:
|
|
//
|
|
// false no range to honour. Send the whole representation, 200.
|
|
// null a range was asked for and cannot be satisfied. Send 416 with
|
|
// "Content-Range: bytes */$size" so the client learns the real
|
|
// length instead of waiting for bytes that will never come.
|
|
// array(b, e) the inclusive range to send, 206.
|
|
//
|
|
// The caller must take the length from the returned range rather than from
|
|
// the request: a range may legitimately run past the end of the file, and it
|
|
// is satisfied by whatever is there. Answering with the length that was asked
|
|
// for promises more than the body can deliver, and a player that reads the
|
|
// short body treats it as a broken file.
|
|
function parseHttpRange($header, $size) {
|
|
if (!is_string($header) or ($header === '')) return false;
|
|
$size = intval($size);
|
|
|
|
// "bytes" is the only range unit HTTP defines. Anything else is not ours to
|
|
// interpret, and a server may always ignore a range it does not understand.
|
|
if (!preg_match('/^\s*bytes\s*=\s*(.*)$/is', $header, $matches)) return false;
|
|
$spec = trim($matches[1]);
|
|
if ($spec === '') return false;
|
|
|
|
// A request for several ranges should strictly be answered with a
|
|
// multipart/byteranges body. Serve the first one instead: Content-Range
|
|
// names exactly which bytes these are, so the client can ask for the rest,
|
|
// and falling back to the whole representation is not an option when these
|
|
// are event videos of hundreds of megabytes. Players, and the byte-range
|
|
// HLS manifest VideoStore writes, only ever ask for one range anyway.
|
|
$parts = explode(',', $spec);
|
|
$first = trim($parts[0]);
|
|
|
|
if (!preg_match('/^(\d*)\s*-\s*(\d*)$/', $first, $range)) return false;
|
|
$from = $range[1];
|
|
$to = $range[2];
|
|
// "bytes=-" names neither end and is malformed rather than unsatisfiable.
|
|
if (($from === '') and ($to === '')) return false;
|
|
|
|
if ($from === '') {
|
|
// "-N" is the LAST n bytes, not a range starting at zero. Reading it as
|
|
// the latter would quietly serve the wrong part of the file.
|
|
$suffix = intval($to);
|
|
// A suffix of zero asks for no bytes at all, which cannot be satisfied.
|
|
if (($suffix <= 0) or ($size <= 0)) return null;
|
|
$begin = ($suffix >= $size) ? 0 : $size - $suffix;
|
|
$end = $size - 1;
|
|
} else {
|
|
$begin = intval($from);
|
|
$end = ($to === '') ? ($size - 1) : intval($to);
|
|
// Clamp rather than reject: a client is entitled to ask for more than is
|
|
// there, and gets what is there.
|
|
if ($end > ($size - 1)) $end = $size - 1;
|
|
}
|
|
|
|
if (($size <= 0) or ($begin < 0) or ($begin >= $size) or ($begin > $end)) return null;
|
|
|
|
return array($begin, $end);
|
|
}
|