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/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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user