mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
fix(webui): support video byte ranges
This commit is contained in:
parent
a71e6a0ae8
commit
4a0035ef8f
@ -538,6 +538,35 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
||||
"video/webm",
|
||||
"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:
|
||||
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||
if not configured_secret:
|
||||
@ -852,7 +881,7 @@ class WebSocketChannel(BaseChannel):
|
||||
# these URLs when replaying a session.
|
||||
m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
|
||||
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
|
||||
# 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,
|
||||
)
|
||||
|
||||
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
|
||||
:meth:`_sign_media_path`. Validates the signature, decodes the
|
||||
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")
|
||||
if not candidate.is_file():
|
||||
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:
|
||||
body = candidate.read_bytes()
|
||||
except OSError:
|
||||
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(
|
||||
body,
|
||||
content_type=mime,
|
||||
extra_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"),
|
||||
],
|
||||
extra_headers=common_headers,
|
||||
)
|
||||
|
||||
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
|
||||
|
||||
@ -227,10 +227,103 @@ async def test_media_route_serves_signed_file(
|
||||
assert resp.headers["content-type"].startswith("image/png")
|
||||
# Immutable cache header lets the browser skip round-trips on replay.
|
||||
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.
|
||||
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
|
||||
async def test_media_route_rejects_bad_signature(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user