mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
refactor(webui): clarify websocket routing
This commit is contained in:
parent
9ed5643d93
commit
8e421eb976
@ -730,23 +730,65 @@ class WebSocketChannel(BaseChannel):
|
||||
"""Route an inbound HTTP request to a handler or to the WS upgrade path."""
|
||||
got, query = _parse_request_path(request.path)
|
||||
|
||||
# 1. Token issue endpoint (legacy, optional, gated by configured secret).
|
||||
if self.config.token_issue_path:
|
||||
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
||||
if got == issue_expected:
|
||||
return self._handle_token_issue_http(connection, request)
|
||||
|
||||
# 2. Bootstrap (`/webui/bootstrap`): mint WS/API tokens + shared session metadata.
|
||||
if got == "/webui/bootstrap":
|
||||
return self._handle_bootstrap(connection, request)
|
||||
|
||||
# 3. REST handlers co-located with this channel (sessions, settings, …).
|
||||
api_response = await self._dispatch_api_route(connection, request, got)
|
||||
if api_response is not None:
|
||||
return api_response
|
||||
|
||||
ws_matched, ws_response = self._dispatch_websocket_upgrade(
|
||||
connection, request, got, query
|
||||
)
|
||||
if ws_matched:
|
||||
return ws_response
|
||||
|
||||
# API clients should never receive the SPA shell for an unknown route.
|
||||
# Returning HTML here makes the WebUI fail with "Unexpected token <"
|
||||
# when a dev server is pointed at an older gateway.
|
||||
if got.startswith("/api/"):
|
||||
return _http_error(404, "API route not found")
|
||||
|
||||
if self._static_dist_path is not None:
|
||||
response = self._serve_static(got)
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
return connection.respond(404, "Not Found")
|
||||
|
||||
async def _dispatch_api_route(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Any | None:
|
||||
"""Route REST-ish WebUI requests served beside the WebSocket endpoint."""
|
||||
response = await self._dispatch_settings_api_route(request, got)
|
||||
if response is not None:
|
||||
return response
|
||||
response = self._dispatch_session_api_route(request, got)
|
||||
if response is not None:
|
||||
return response
|
||||
response = self._dispatch_media_api_route(request, got)
|
||||
if response is not None:
|
||||
return response
|
||||
return self._dispatch_misc_api_route(connection, request, got)
|
||||
|
||||
def _dispatch_misc_api_route(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Response | None:
|
||||
"""Route small API endpoints that do not belong to a larger route group."""
|
||||
if got == "/api/sessions":
|
||||
return self._handle_sessions_list(request)
|
||||
|
||||
if got == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
|
||||
if got == "/api/commands":
|
||||
return self._handle_commands(request)
|
||||
|
||||
@ -759,6 +801,16 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/webui/sidebar-state/update":
|
||||
return self._handle_webui_sidebar_state_update(request)
|
||||
|
||||
return None
|
||||
|
||||
async def _dispatch_settings_api_route(
|
||||
self,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Response | None:
|
||||
if got == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
|
||||
if got == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
|
||||
@ -808,6 +860,13 @@ class WebSocketChannel(BaseChannel):
|
||||
if mcp_action is not None:
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
|
||||
return None
|
||||
|
||||
def _dispatch_session_api_route(
|
||||
self,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Response | None:
|
||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||
if m:
|
||||
return self._handle_session_messages(request, m.group(1))
|
||||
@ -822,40 +881,36 @@ class WebSocketChannel(BaseChannel):
|
||||
if m:
|
||||
return self._handle_session_delete(request, m.group(1))
|
||||
|
||||
# Signed media fetch: ``<sig>`` is an HMAC over ``<payload>``; the
|
||||
# payload decodes to a path inside :func:`get_media_dir`. See
|
||||
# :meth:`_sign_media_path` for the inverse direction used to build
|
||||
# these URLs when replaying a session.
|
||||
return None
|
||||
|
||||
def _dispatch_media_api_route(
|
||||
self,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Response | None:
|
||||
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), request)
|
||||
|
||||
# 4. WebSocket upgrade (the channel's primary purpose). Only run the
|
||||
# handshake gate on requests that actually ask to upgrade; otherwise
|
||||
# a bare ``GET /`` from the browser would be rejected as an
|
||||
# unauthorized WS handshake instead of serving the SPA's index.html.
|
||||
return None
|
||||
|
||||
def _dispatch_websocket_upgrade(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
query: dict[str, list[str]],
|
||||
) -> tuple[bool, Any | None]:
|
||||
"""Authorize only real WS upgrade requests for the configured path."""
|
||||
expected_ws = self._expected_path()
|
||||
if got == expected_ws and _is_websocket_upgrade(request):
|
||||
client_id = _query_first(query, "client_id") or ""
|
||||
if len(client_id) > 128:
|
||||
client_id = client_id[:128]
|
||||
if not self.is_allowed(client_id):
|
||||
return connection.respond(403, "Forbidden")
|
||||
return self._authorize_websocket_handshake(connection, query)
|
||||
|
||||
# API clients should never receive the SPA shell for an unknown route.
|
||||
# Returning HTML here makes the WebUI fail with "Unexpected token <"
|
||||
# when a dev server is pointed at an older gateway.
|
||||
if got.startswith("/api/"):
|
||||
return _http_error(404, "API route not found")
|
||||
|
||||
# 5. Static SPA serving (only if a build directory was wired in).
|
||||
if self._static_dist_path is not None:
|
||||
response = self._serve_static(got)
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
return connection.respond(404, "Not Found")
|
||||
if got != expected_ws or not _is_websocket_upgrade(request):
|
||||
return False, None
|
||||
client_id = _query_first(query, "client_id") or ""
|
||||
if len(client_id) > 128:
|
||||
client_id = client_id[:128]
|
||||
if not self.is_allowed(client_id):
|
||||
return True, connection.respond(403, "Forbidden")
|
||||
return True, self._authorize_websocket_handshake(connection, query)
|
||||
|
||||
# -- HTTP route handlers ------------------------------------------------
|
||||
|
||||
@ -1164,6 +1219,8 @@ class WebSocketChannel(BaseChannel):
|
||||
self._with_settings_restart_state(payload, section="runtime")
|
||||
)
|
||||
|
||||
# -- Session replay, transcript, and signed media ----------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
||||
@ -1376,6 +1433,8 @@ class WebSocketChannel(BaseChannel):
|
||||
delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(deleted)})
|
||||
|
||||
# -- Static files and WebSocket handshake ------------------------------
|
||||
|
||||
def _serve_static(self, request_path: str) -> Response | None:
|
||||
"""Resolve *request_path* against the built SPA directory; SPA fallback to index.html."""
|
||||
assert self._static_dist_path is not None
|
||||
@ -1440,6 +1499,8 @@ class WebSocketChannel(BaseChannel):
|
||||
self._take_issued_token_if_valid(supplied)
|
||||
return None
|
||||
|
||||
# -- Server lifecycle and connection ingress ---------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||
|
||||
@ -1583,6 +1644,8 @@ class WebSocketChannel(BaseChannel):
|
||||
finally:
|
||||
self._cleanup_connection(connection)
|
||||
|
||||
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||
|
||||
def _save_envelope_media(
|
||||
self,
|
||||
media: list[Any],
|
||||
@ -1812,6 +1875,8 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return None
|
||||
|
||||
# -- Outbound WebSocket events -----------------------------------------
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user