refactor: extract GatewayHTTPHandler from WebSocketChannel

Extract all HTTP route handling (bootstrap, sessions, settings, media,
commands, sidebar state, static serving, token management) into a new
GatewayHTTPHandler class in nanobot/channels/ws_http.py.

WebSocketChannel is reduced from 1907 to 1372 lines (-28%), retaining
only WebSocket connection management and message dispatch.

No behavior change. 3730 tests pass, 0 failures.

Shared HTTP utility functions (path parsing, response builders, auth
helpers) now live in ws_http.py with websocket.py importing from there,
avoiding circular dependencies.

Backwards-compat property aliases on WebSocketChannel ensure existing
tests continue to work without modification.
This commit is contained in:
chengyongru
2026-06-02 17:14:38 +08:00
committed by Xubin Ren
parent 92fe40a690
commit 1a585288b2
5 changed files with 859 additions and 628 deletions
+102 -605
View File
@@ -7,11 +7,8 @@ import email.utils
import hmac
import http
import json
import mimetypes
import re
import secrets
import ssl
import time
import uuid
from collections.abc import Callable
from contextlib import suppress
@@ -31,7 +28,7 @@ from websockets.http11 import Response
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import builtin_command_palette
from nanobot.channels.ws_http import GatewayHTTPHandler
from nanobot.config.paths import get_media_dir, get_workspace_path
from nanobot.config.schema import Base
from nanobot.security.workspace_access import (
@@ -44,32 +41,9 @@ from nanobot.utils.media_decode import (
FileSizeExceeded,
save_base64_data_url,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
from nanobot.webui.media_api import (
attach_signed_media_urls,
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
signed_media_attachments,
)
from nanobot.webui.settings_api import runtime_capabilities
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
)
from nanobot.webui.thread_disk import delete_webui_thread
from nanobot.webui.transcript import (
append_transcript_object,
build_webui_thread_response,
rewrite_local_markdown_images,
)
from nanobot.webui.websocket_logging import websockets_server_logger
from nanobot.webui.workspaces import (
WebUIWorkspaceController,
)
from nanobot.webui.transcript import append_transcript_object
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -500,51 +474,36 @@ class WebSocketChannel(BaseChannel):
self._conn_chats: dict[Any, set[str]] = {}
# connection -> default chat_id for legacy frames that omit routing.
self._conn_default: dict[Any, str] = {}
# Single-use tokens consumed at WebSocket handshake.
self._issued_tokens: dict[str, float] = {}
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
self._api_tokens: dict[str, float] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
self._session_manager = session_manager
self._static_dist_path: Path | None = (
static_dist_path.resolve() if static_dist_path is not None else None
)
self._workspace_path = (
_resolved_workspace = (
Path(workspace_path).expanduser()
if workspace_path is not None
else get_workspace_path()
).resolve(strict=False)
self._default_restrict_to_workspace = restrict_to_workspace
self._webui_workspaces = WebUIWorkspaceController(
session_manager=self._session_manager,
default_workspace=self._workspace_path,
default_restrict_to_workspace=self._default_restrict_to_workspace,
)
self._runtime_model_name = runtime_model_name
self._runtime_surface = (
"native" if runtime_surface in {"native", "desktop"} else "browser"
)
self._runtime_capabilities = runtime_capabilities(
self._runtime_surface,
runtime_capabilities_overrides,
)
self._settings_routes = WebUISettingsRouter(
bus=self.bus,
logger=self.logger,
check_api_token=self._check_api_token,
parse_query=_parse_query,
json_response=_http_json_response,
error_response=_http_error,
# HTTP API handler — owns tokens, sessions, media, settings, static serving
self._http = GatewayHTTPHandler(
config=self.config,
session_manager=session_manager,
static_dist_path=(
static_dist_path.resolve() if static_dist_path is not None else None
),
workspace_path=_resolved_workspace,
runtime_model_name=runtime_model_name,
runtime_surface=self._runtime_surface,
runtime_capabilities=self._runtime_capabilities,
runtime_capabilities_overrides=runtime_capabilities_overrides,
bus=self.bus,
log=self.logger,
)
# Backwards-compat aliases for workspace controller used in envelope dispatch
self._webui_workspaces = self._http.workspaces
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is
# the capability — anyone who holds a valid URL can fetch that one
# file, nothing else. The secret regenerates on restart so links
# become self-expiring (callers just refresh the session list).
self._media_secret: bytes = secrets.token_bytes(32)
# -- Subscription bookkeeping -------------------------------------------
@@ -572,9 +531,9 @@ class WebSocketChannel(BaseChannel):
connected clients normally see it via ``goal_state`` / ``turn_end`` frames.
Pushing here makes refresh + reconnect restore the strip without a new model turn.
"""
if self._session_manager is None:
if self._http.session_manager is None:
return
row = self._session_manager.read_session_file(f"websocket:{chat_id}")
row = self._http.session_manager.read_session_file(f"websocket:{chat_id}")
meta = row.get("metadata", {}) if isinstance(row, dict) else {}
if not isinstance(meta, dict):
meta = {}
@@ -614,6 +573,59 @@ class WebSocketChannel(BaseChannel):
def _expected_path(self) -> str:
return _normalize_config_path(self.config.path)
# -- Backwards-compat property aliases (used by tests) ------------------
@property
def _session_manager(self):
return self._http.session_manager
@_session_manager.setter
def _session_manager(self, value):
self._http.session_manager = value
@property
def _media_secret(self):
return self._http.media_secret
@property
def _issued_tokens(self):
return self._http.issued_tokens
@_issued_tokens.setter
def _issued_tokens(self, value):
self._http.issued_tokens = value
@property
def _api_tokens(self):
return self._http.api_tokens
def _check_api_token(self, request):
return self._http.check_api_token(request)
def _sign_media_path(self, path):
return self._http.sign_media_path(path)
def _sign_or_stage_media_path(self, path):
return self._http.sign_or_stage_media_path(path)
def _rewrite_local_markdown_images(self, text):
return self._http.rewrite_local_markdown_images(text)
def _handle_bootstrap(self, connection, request):
return self._http._handle_bootstrap(connection, request)
_MAX_ISSUED_TOKENS = GatewayHTTPHandler._MAX_ISSUED_TOKENS
def _handle_sessions_list(self, request):
return self._http._handle_sessions_list(request)
def _handle_webui_thread_get(self, request, key):
return self._http._handle_webui_thread_get(request, key)
@property
def _workspace_path(self):
return self._http.workspace_path
def _build_ssl_context(self) -> ssl.SSLContext | None:
cert = self.config.ssl_certfile.strip()
key = self.config.ssl_keyfile.strip()
@@ -628,548 +640,24 @@ class WebSocketChannel(BaseChannel):
ctx.load_cert_chain(certfile=cert, keyfile=key)
return ctx
_MAX_ISSUED_TOKENS = 10_000
def _purge_expired_issued_tokens(self) -> None:
now = time.monotonic()
for token_key, expiry in list(self._issued_tokens.items()):
if now > expiry:
self._issued_tokens.pop(token_key, None)
def _take_issued_token_if_valid(self, token_value: str | None) -> bool:
"""Validate and consume one issued token (single use per connection attempt).
Uses single-step pop to minimize the window between lookup and removal;
safe under asyncio's single-threaded cooperative model.
"""
if not token_value:
return False
self._purge_expired_issued_tokens()
expiry = self._issued_tokens.pop(token_value, None)
if expiry is None:
return False
if time.monotonic() > expiry:
return False
return True
def _handle_token_issue_http(self, connection: Any, request: Any) -> Any:
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return connection.respond(401, "Unauthorized")
else:
self.logger.warning(
"token_issue_path is set but no token_issue_secret or static token is configured; "
"any client can obtain connection tokens — set a secret for production."
)
self._purge_expired_issued_tokens()
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
self.logger.error(
"too many outstanding issued tokens ({}), rejecting issuance",
len(self._issued_tokens),
)
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
token_value = f"nbwt_{secrets.token_urlsafe(32)}"
self._issued_tokens[token_value] = time.monotonic() + float(self.config.token_ttl_s)
return _http_json_response(
{"token": token_value, "expires_in": self.config.token_ttl_s}
)
# -- HTTP dispatch ------------------------------------------------------
async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any:
"""Route an inbound HTTP request to a handler or to the WS upgrade path."""
"""Route an inbound HTTP request to the HTTP handler or WS upgrade."""
got, query = _parse_request_path(request.path)
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)
if got == "/webui/bootstrap":
return self._handle_bootstrap(connection, request)
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/commands":
return self._handle_commands(request)
if got == "/api/workspaces":
return self._handle_workspaces(connection, request)
if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request)
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:
return await self._settings_routes.dispatch(request, got)
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))
m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got)
if m:
return self._handle_webui_thread_get(request, m.group(1))
# NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a
# true ``DELETE`` verb. The action is folded into the path instead.
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
if m:
return self._handle_session_delete(request, m.group(1))
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)
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."""
# WebSocket upgrade — channel handles this itself
expected_ws = self._expected_path()
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)
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)
# -- HTTP route handlers ------------------------------------------------
def _check_api_token(self, request: WsRequest) -> bool:
"""Validate a request against the API token pool (multi-use, TTL-bound)."""
self._purge_expired_api_tokens()
token = _bearer_token(request.headers) or _query_first(
_parse_query(request.path), "token"
)
if not token:
return False
expiry = self._api_tokens.get(token)
if expiry is None or time.monotonic() > expiry:
self._api_tokens.pop(token, None)
return False
return True
def _purge_expired_api_tokens(self) -> None:
now = time.monotonic()
for token_key, expiry in list(self._api_tokens.items()):
if now > expiry:
self._api_tokens.pop(token_key, None)
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
# When a secret is configured (token_issue_secret or static token),
# validate it regardless of source IP. This secures deployments
# behind a reverse proxy where all connections appear as localhost.
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return _http_error(401, "Unauthorized")
elif not _is_localhost(connection):
# No secret configured: only allow localhost (local dev mode).
return _http_error(403, "bootstrap is localhost-only")
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
self._purge_expired_issued_tokens()
self._purge_expired_api_tokens()
if (
len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS
or len(self._api_tokens) >= self._MAX_ISSUED_TOKENS
):
return _http_response(
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
status=429,
content_type="application/json; charset=utf-8",
)
token = f"nbwt_{secrets.token_urlsafe(32)}"
expiry = time.monotonic() + float(self.config.token_ttl_s)
# Same string registered in both pools: the WS handshake consumes one copy
# while the REST surface keeps validating the other until TTL expiry.
self._issued_tokens[token] = expiry
self._api_tokens[token] = expiry
ws_url = self._bootstrap_ws_url(request)
return _http_json_response(
{
"token": token,
"ws_path": self._expected_path(),
"ws_url": ws_url,
"expires_in": self.config.token_ttl_s,
"model_name": _resolve_bootstrap_model_name(self._runtime_model_name),
"runtime_surface": self._runtime_surface,
"runtime_capabilities": self._runtime_capabilities,
}
)
def _bootstrap_ws_url(self, request: Any) -> str:
"""Absolute WS URL clients should prefer over a dev-server proxy."""
headers = getattr(request, "headers", {}) or {}
host = _safe_host_header(_case_insensitive_header(headers, "Host"))
if not host:
host = _host_for_url(self.config.host, self.config.port)
proto = _case_insensitive_header(headers, "X-Forwarded-Proto")
proto = proto.split(",", 1)[0].strip().lower()
secure = proto in {"https", "wss"} or bool(self.config.ssl_certfile.strip())
scheme = "wss" if secure else "ws"
return f"{scheme}://{host}{self._expected_path()}"
def _handle_sessions_list(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
if self._session_manager is None:
return _http_error(503, "session manager unavailable")
sessions = self._session_manager.list_sessions()
# Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc.
# keys are not intended for resume over this HTTP surface.
cleaned = []
for s in sessions:
key = s.get("key")
if not (isinstance(key, str) and key.startswith("websocket:")):
continue
row = {k: v for k, v in s.items() if k != "path"}
chat_id = key.split(":", 1)[1]
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
row["run_started_at"] = started_at
scope = self._webui_workspaces.scope_for_session_key(key)
row["workspace_scope"] = scope.payload()
cleaned.append(row)
return _http_json_response({"sessions": cleaned})
def _handle_workspaces(self, connection: Any, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self._webui_workspaces.payload(controls_available=_is_localhost(connection))
)
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response({"commands": builtin_command_palette()})
def _handle_webui_sidebar_state(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(read_webui_sidebar_state())
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
raw_state = _query_first(query, "state")
if raw_state is None:
return _http_error(400, "missing state")
try:
decoded = json.loads(raw_state)
except json.JSONDecodeError:
return _http_error(400, "state must be JSON")
if not isinstance(decoded, dict):
return _http_error(400, "state must be an object")
try:
state = write_webui_sidebar_state(decoded)
except ValueError as e:
return _http_error(400, str(e))
except OSError:
self.logger.exception("failed to write webui sidebar state")
return _http_error(500, "failed to write sidebar state")
return _http_json_response(state)
# -- 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."""
return key.startswith("websocket:")
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
if self._session_manager is None:
return _http_error(503, "session manager unavailable")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
# Only ``websocket:…`` sessions are listed/served here — same boundary as
# ``/api/sessions``. Block handcrafted URLs from probing CLI / Slack / etc.
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
data = self._session_manager.read_session_file(decoded_key)
if data is None:
return _http_error(404, "session not found")
messages = data.get("messages")
if isinstance(messages, list):
scrub_subagent_messages_for_channel(messages)
# Decorate persisted user messages with signed media URLs so the
# client can render previews. The raw on-disk ``media`` paths are
# stripped on the way out — they leak server filesystem layout and
# the client never needs them once it has the signed fetch URL.
attach_signed_media_urls(data, sign_path=self._sign_media_path)
return _http_json_response(data)
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
scope = self._webui_workspaces.scope_for_session_key(decoded_key)
augment_media = partial(
signed_media_attachments,
sign_path=self._sign_or_stage_media_path,
)
data = build_webui_thread_response(
decoded_key,
augment_user_media=augment_media,
augment_assistant_media=augment_media,
augment_assistant_text=lambda text: rewrite_local_markdown_images(
text,
workspace_path=scope.project_path,
sign_path=self._sign_or_stage_media_path,
),
)
if data is None:
return _http_error(404, "webui thread not found")
data["workspace_scope"] = scope.payload()
return _http_json_response(data)
def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None:
sk = f"websocket:{chat_id}"
try:
dup = json.loads(json.dumps(wire, ensure_ascii=False))
append_transcript_object(sk, dup)
except (ValueError, TypeError) as e:
self.logger.warning("webui transcript append failed: {}", e)
async def _handle_message(
self,
sender_id: str,
chat_id: str,
content: str,
media: list[str] | None = None,
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
is_dm: bool = False,
) -> None:
meta = metadata or {}
if meta.get("webui"):
user_obj: dict[str, Any] = {
"event": "user",
"chat_id": chat_id,
"text": content,
}
if media:
user_obj["media_paths"] = list(media)
cli_apps = meta.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
user_obj["cli_apps"] = cli_apps
mcp_presets = meta.get("mcp_presets")
if isinstance(mcp_presets, list) and mcp_presets:
user_obj["mcp_presets"] = mcp_presets
self._try_append_webui_transcript(chat_id, user_obj)
await super()._handle_message(
sender_id,
chat_id,
content,
media,
metadata,
session_key,
is_dm,
)
def _sign_media_path(self, abs_path: Path) -> str | None:
"""Return a ``/api/media/<sig>/<payload>`` URL for *abs_path*, or
``None`` when the path does not resolve inside the media root.
The URL is self-authenticating: the signature binds the payload to
this process's ``_media_secret``, so only paths we chose to sign can
be fetched. The returned path is relative to the server origin; the
client joins it against this server's HTTP origin (same host as WS).
"""
return sign_media_path(
abs_path,
secret=self._media_secret,
media_dir=lambda channel=None: get_media_dir(channel),
)
def _sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
"""Return a signed media URL payload for *path*.
Persisted inbound media already lives under ``get_media_dir`` and can
be signed directly. Outbound bot-generated files may live anywhere on
disk; copy those into the websocket media bucket first so the browser
can fetch them through the existing signed media route without
exposing arbitrary filesystem paths.
"""
return sign_or_stage_media_path(
path,
secret=self._media_secret,
media_dir=lambda channel=None: get_media_dir(channel),
logger=self.logger,
)
def _rewrite_local_markdown_images(self, text: str) -> str:
return rewrite_local_markdown_images(
text,
workspace_path=self._workspace_path,
sign_path=self._sign_or_stage_media_path,
)
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
long-lived immutable cache header (the URL already encodes the
file identity, so caches can be aggressive)."""
return serve_signed_media(
sig,
payload,
secret=self._media_secret,
request=request,
media_dir=lambda channel=None: get_media_dir(channel),
)
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
if self._session_manager is None:
return _http_error(503, "session manager unavailable")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
# Same boundary as ``_handle_session_messages``: mutations apply only to
# websocket-channel sessions; deletion unlinks local JSONL — keep scope narrow.
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
deleted = self._session_manager.delete_session(decoded_key)
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
rel = request_path.lstrip("/")
if not rel:
rel = "index.html"
# Reject path-traversal attempts and absolute targets.
if ".." in rel.split("/") or rel.startswith("/"):
return _http_error(403, "Forbidden")
candidate = (self._static_dist_path / rel).resolve()
try:
candidate.relative_to(self._static_dist_path)
except ValueError:
return _http_error(403, "Forbidden")
if not candidate.is_file():
# SPA history-mode fallback: unknown routes serve index.html so the
# client-side router can render them.
index = self._static_dist_path / "index.html"
if index.is_file():
candidate = index
else:
return None
try:
body = candidate.read_bytes()
except OSError as e:
self.logger.warning("static: failed to read {}: {}", candidate, e)
return _http_error(500, "Internal Server Error")
ctype, _ = mimetypes.guess_type(candidate.name)
if ctype is None:
ctype = "application/octet-stream"
if ctype.startswith("text/") or ctype in {"application/javascript", "application/json"}:
ctype = f"{ctype}; charset=utf-8"
# Hash-named build assets are cache-friendly; index.html must stay fresh.
if candidate.name == "index.html":
cache = "no-cache"
else:
cache = "public, max-age=31536000, immutable"
return _http_response(
body,
status=200,
content_type=ctype,
extra_headers=[("Cache-Control", cache)],
)
<< # Everything else goes to the HTTP handler
return await self._http.dispatch(connection, request)
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
supplied = _query_first(query, "token")
@@ -1178,20 +666,21 @@ class WebSocketChannel(BaseChannel):
if static_token:
if supplied and hmac.compare_digest(supplied, static_token):
return None
if supplied and self._take_issued_token_if_valid(supplied):
if supplied and self._http.take_issued_token_if_valid(supplied):
return None
return connection.respond(401, "Unauthorized")
if self.config.websocket_requires_token:
if supplied and self._take_issued_token_if_valid(supplied):
if supplied and self._http.take_issued_token_if_valid(supplied):
return None
return connection.respond(401, "Unauthorized")
if supplied:
self._take_issued_token_if_valid(supplied)
self._http.take_issued_token_if_valid(supplied)
return None
# -- Server lifecycle and connection ingress ---------------------------
# -- Server lifecycle and connection ingress ---------------------------
async def start(self) -> None:
from nanobot.utils.logging_bridge import redirect_lib_logging
@@ -1587,8 +1076,8 @@ class WebSocketChannel(BaseChannel):
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._issued_tokens.clear()
self._api_tokens.clear()
self._http.issued_tokens.clear()
self._http.api_tokens.clear()
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
@@ -1601,6 +1090,14 @@ class WebSocketChannel(BaseChannel):
self.logger.exception("send failed{}", label)
raise
def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None:
sk = f"websocket:{chat_id}"
try:
dup = json.loads(json.dumps(wire, ensure_ascii=False))
append_transcript_object(sk, dup)
except (ValueError, TypeError) as e:
self.logger.warning("webui transcript append failed: {}", e)
async def send(self, msg: OutboundMessage) -> None:
if msg.metadata.get("_runtime_model_updated"):
await self.send_runtime_model_updated(
@@ -1662,7 +1159,7 @@ class WebSocketChannel(BaseChannel):
)
return
text = msg.content
wire_text = self._rewrite_local_markdown_images(text)
wire_text = self._http.rewrite_local_markdown_images(text)
payload: dict[str, Any] = {
"event": "message",
"chat_id": msg.chat_id,
@@ -1672,7 +1169,7 @@ class WebSocketChannel(BaseChannel):
payload["media"] = msg.media
urls: list[dict[str, str]] = []
for entry in msg.media:
signed = self._sign_or_stage_media_path(Path(entry))
signed = self._http.sign_or_stage_media_path(Path(entry))
if signed is not None:
urls.append(signed)
if urls:
@@ -1787,7 +1284,7 @@ class WebSocketChannel(BaseChannel):
if delta:
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._rewrite_local_markdown_images(full_text)
rewritten = self._http.rewrite_local_markdown_images(full_text)
if rewritten != full_text:
body["text"] = rewritten
else: