fix(webui): support video byte ranges

This commit is contained in:
Xubin Ren 2026-05-29 16:17:20 +08:00
parent a71e6a0ae8
commit 4a0035ef8f
2 changed files with 175 additions and 11 deletions

View File

@ -538,6 +538,35 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
"video/webm", "video/webm",
"video/quicktime", "video/quicktime",
}) })
_BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
def _parse_single_byte_range(range_header: str, size: int) -> tuple[int, int]:
"""Parse a single HTTP byte range for signed media responses."""
if size <= 0 or "," in range_header:
raise ValueError("invalid byte range")
m = _BYTE_RANGE_RE.fullmatch(range_header.strip())
if m is None:
raise ValueError("invalid byte range")
start_text, end_text = m.groups()
if not start_text and not end_text:
raise ValueError("invalid byte range")
if not start_text:
suffix_length = int(end_text)
if suffix_length <= 0:
raise ValueError("invalid byte range")
start = max(size - suffix_length, 0)
end = size - 1
else:
start = int(start_text)
end = int(end_text) if end_text else size - 1
if start >= size or start > end:
raise ValueError("invalid byte range")
end = min(end, size - 1)
return start, end
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool: def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``.""" """Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
if not configured_secret: if not configured_secret:
@ -852,7 +881,7 @@ class WebSocketChannel(BaseChannel):
# these URLs when replaying a session. # these URLs when replaying a session.
m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got) m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
if m: if m:
return self._handle_media_fetch(m.group(1), m.group(2)) return self._handle_media_fetch(m.group(1), m.group(2), request)
# 4. WebSocket upgrade (the channel's primary purpose). Only run the # 4. WebSocket upgrade (the channel's primary purpose). Only run the
# handshake gate on requests that actually ask to upgrade; otherwise # handshake gate on requests that actually ask to upgrade; otherwise
@ -1384,7 +1413,9 @@ class WebSocketChannel(BaseChannel):
sign_path=self._sign_or_stage_media_path, sign_path=self._sign_or_stage_media_path,
) )
def _handle_media_fetch(self, sig: str, payload: str) -> Response: def _handle_media_fetch(
self, sig: str, payload: str, request: WsRequest | None = None
) -> Response:
"""Serve a single media file previously signed via """Serve a single media file previously signed via
:meth:`_sign_media_path`. Validates the signature, decodes the :meth:`_sign_media_path`. Validates the signature, decodes the
payload to a relative path, and streams the file bytes with a payload to a relative path, and streams the file bytes with a
@ -1414,22 +1445,62 @@ class WebSocketChannel(BaseChannel):
return _http_error(404, "not found") return _http_error(404, "not found")
if not candidate.is_file(): if not candidate.is_file():
return _http_error(404, "not found") return _http_error(404, "not found")
mime, _ = mimetypes.guess_type(candidate.name)
if mime not in _MEDIA_ALLOWED_MIMES:
mime = "application/octet-stream"
common_headers = [
("Accept-Ranges", "bytes"),
("Cache-Control", "private, max-age=31536000, immutable"),
# Paired with the MIME whitelist above: prevents browsers from
# MIME-sniffing an octet-stream fallback into executable HTML.
("X-Content-Type-Options", "nosniff"),
]
try:
size = candidate.stat().st_size
except OSError:
return _http_error(500, "read error")
range_header = (
_case_insensitive_header(request.headers, "Range") if request else ""
)
if range_header:
try:
start, end = _parse_single_byte_range(range_header, size)
except ValueError:
return _http_response(
b"range not satisfiable",
status=416,
extra_headers=[
("Accept-Ranges", "bytes"),
("Content-Range", f"bytes */{size}"),
("X-Content-Type-Options", "nosniff"),
],
)
try:
length = end - start + 1
with candidate.open("rb") as fh:
fh.seek(start)
body = fh.read(length)
except OSError:
return _http_error(500, "read error")
return _http_response(
body,
status=206,
content_type=mime,
extra_headers=[
*common_headers,
("Content-Range", f"bytes {start}-{end}/{size}"),
],
)
try: try:
body = candidate.read_bytes() body = candidate.read_bytes()
except OSError: except OSError:
return _http_error(500, "read error") return _http_error(500, "read error")
mime, _ = mimetypes.guess_type(candidate.name)
if mime not in _MEDIA_ALLOWED_MIMES:
mime = "application/octet-stream"
return _http_response( return _http_response(
body, body,
content_type=mime, content_type=mime,
extra_headers=[ extra_headers=common_headers,
("Cache-Control", "private, max-age=31536000, immutable"),
# Paired with the MIME whitelist above: prevents browsers from
# MIME-sniffing an octet-stream fallback into executable HTML.
("X-Content-Type-Options", "nosniff"),
],
) )
def _handle_session_delete(self, request: WsRequest, key: str) -> Response: def _handle_session_delete(self, request: WsRequest, key: str) -> Response:

View File

@ -227,10 +227,103 @@ async def test_media_route_serves_signed_file(
assert resp.headers["content-type"].startswith("image/png") assert resp.headers["content-type"].startswith("image/png")
# Immutable cache header lets the browser skip round-trips on replay. # Immutable cache header lets the browser skip round-trips on replay.
assert "immutable" in resp.headers.get("cache-control", "") assert "immutable" in resp.headers.get("cache-control", "")
# Video players rely on byte ranges; images get the header for consistency.
assert resp.headers.get("accept-ranges") == "bytes"
# nosniff keeps the browser from second-guessing our Content-Type. # nosniff keeps the browser from second-guessing our Content-Type.
assert resp.headers.get("x-content-type-options") == "nosniff" assert resp.headers.get("x-content-type-options") == "nosniff"
@pytest.mark.asyncio
async def test_media_route_serves_video_byte_ranges(
bus: MagicMock, tmp_path: Path
) -> None:
"""MP4 playback needs HTTP Range support for mid-stream reads and seeking."""
media = tmp_path / "media"
media.mkdir()
target = media / "clip.mp4"
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29927)
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(
f"http://127.0.0.1:29927{url_path}",
headers={"Range": "bytes=2-5"},
)
finally:
await channel.stop()
await server_task
assert resp.status_code == 206
assert resp.content == b"2345"
assert resp.headers["content-type"].startswith("video/mp4")
assert resp.headers.get("accept-ranges") == "bytes"
assert resp.headers.get("content-range") == "bytes 2-5/10"
assert resp.headers.get("content-length") == "4"
@pytest.mark.asyncio
async def test_media_route_serves_suffix_video_byte_ranges(
bus: MagicMock, tmp_path: Path
) -> None:
media = tmp_path / "media"
media.mkdir()
target = media / "clip.mp4"
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29928)
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(
f"http://127.0.0.1:29928{url_path}",
headers={"Range": "bytes=-3"},
)
finally:
await channel.stop()
await server_task
assert resp.status_code == 206
assert resp.content == b"789"
assert resp.headers.get("content-range") == "bytes 7-9/10"
@pytest.mark.asyncio
async def test_media_route_rejects_unsatisfiable_byte_range(
bus: MagicMock, tmp_path: Path
) -> None:
media = tmp_path / "media"
media.mkdir()
target = media / "clip.mp4"
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29929)
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(
f"http://127.0.0.1:29929{url_path}",
headers={"Range": "bytes=100-200"},
)
finally:
await channel.stop()
await server_task
assert resp.status_code == 416
assert resp.headers.get("accept-ranges") == "bytes"
assert resp.headers.get("content-range") == "bytes */10"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_media_route_rejects_bad_signature( async def test_media_route_rejects_bad_signature(
bus: MagicMock, tmp_path: Path bus: MagicMock, tmp_path: Path