refactor: move ws_http.py from channels/ to webui/

This commit is contained in:
chengyongru
2026-06-02 17:14:38 +08:00
committed by Xubin Ren
parent 1a585288b2
commit ca139c7031
+731
View File
@@ -0,0 +1,731 @@
"""HTTP API handler extracted from WebSocketChannel.
Handles all non-WebSocket HTTP routes: bootstrap, sessions, settings,
media, commands, sidebar state, static file serving, and token management.
Also houses shared HTTP utility functions used by both this module and
``websocket.py`` to avoid circular imports.
"""
from __future__ import annotations
import email.utils
import hmac
import http
import json
import mimetypes
import re
import secrets
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, urlparse
from loguru import logger
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.media_api import (
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
)
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 (
build_webui_thread_response,
rewrite_local_markdown_images,
)
if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import SessionManager
# ---------------------------------------------------------------------------
# Shared HTTP utility functions (imported by websocket.py)
# ---------------------------------------------------------------------------
def _strip_trailing_slash(path: str) -> str:
if len(path) > 1 and path.endswith("/"):
return path.rstrip("/")
return path or "/"
def _normalize_config_path(path: str) -> str:
return _strip_trailing_slash(path)
def _case_insensitive_header(headers: Any, key: str) -> str:
"""Read a header from websockets/http test stubs without assuming casing."""
try:
value = headers.get(key)
except Exception:
value = None
if value is None:
try:
value = headers.get(key.lower())
except Exception:
value = None
return str(value or "").strip()
def _safe_host_header(value: str) -> str:
"""Return a safe Host header value, or empty when it should not be echoed."""
value = value.strip()
if not value:
return ""
if re.fullmatch(r"\[[0-9A-Fa-f:.]+\](?::\d{1,5})?", value):
return value
if re.fullmatch(r"[A-Za-z0-9.-]+(?::\d{1,5})?", value):
return value
return ""
def _host_for_url(host: str, port: int) -> str:
host = host.strip()
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{host}:{port}"
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers = Headers(
[
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", "application/json; charset=utf-8"),
]
)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, headers, body)
def _http_response(
body: bytes,
*,
status: int = 200,
content_type: str = "text/plain; charset=utf-8",
extra_headers: list[tuple[str, str]] | None = None,
) -> Response:
headers = [
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", content_type),
]
if extra_headers:
headers.extend(extra_headers)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body)
def _http_error(status: int, message: str | None = None) -> Response:
body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
return _http_response(body, status=status)
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
"""Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query)
path = _strip_trailing_slash(parsed.path or "/")
return path, parse_qs(parsed.query, keep_blank_values=True)
def _normalize_http_path(path_with_query: str) -> str:
return _parse_request_path(path_with_query)[0]
def _parse_query(path_with_query: str) -> dict[str, list[str]]:
return _parse_request_path(path_with_query)[1]
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _is_localhost(connection: Any) -> bool:
addr = getattr(connection, "remote_address", None)
if not addr:
return False
host = addr[0] if isinstance(addr, tuple) else addr
if not isinstance(host, str):
return False
if host.startswith("::ffff:"):
host = host[7:]
return host in {"127.0.0.1", "::1", "localhost"}
def _bearer_token(headers: Any) -> str | None:
auth = headers.get("Authorization") or headers.get("authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
if not configured_secret:
return True
authorization = headers.get("Authorization") or headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:].strip()
return hmac.compare_digest(supplied, configured_secret)
header_token = headers.get("X-Nanobot-Auth") or headers.get("x-nanobot-auth")
if not header_token:
return False
return hmac.compare_digest(header_token.strip(), configured_secret)
def _decode_api_key(raw_key: str) -> str | None:
from urllib.parse import unquote
key = unquote(raw_key)
_api_key_re = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
if _api_key_re.match(key) is None:
return None
return key
def _default_model_name_from_config() -> str | None:
try:
from nanobot.config.loader import load_config
model = load_config().resolve_preset().model.strip()
return model or None
except Exception as e:
logger.debug("bootstrap model_name could not load from config: {}", e)
return None
def _resolve_bootstrap_model_name(
runtime_name: Callable[[], str | None] | None,
) -> str | None:
if runtime_name is not None:
try:
raw = runtime_name()
except Exception as e:
logger.debug("bootstrap runtime model resolver failed: {}", e)
else:
if isinstance(raw, str):
stripped = raw.strip()
if stripped:
return stripped
return _default_model_name_from_config()
# ---------------------------------------------------------------------------
# GatewayHTTPHandler
# ---------------------------------------------------------------------------
class GatewayHTTPHandler:
"""Handles all HTTP routes served alongside the WebSocket endpoint.
Owns token management, session API, media API, static file serving,
and delegates settings routes to ``WebUISettingsRouter``.
"""
_MAX_ISSUED_TOKENS = 10_000
def __init__(
self,
*,
config: Any, # WebSocketConfig
session_manager: SessionManager | None,
static_dist_path: Path | None,
workspace_path: Path,
runtime_model_name: Callable[[], str | None] | None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
bus: MessageBus,
log: Any = logger,
) -> None:
self.config = config
self.session_manager = session_manager
self.static_dist_path = static_dist_path
self.workspace_path = workspace_path
self.runtime_model_name = runtime_model_name
self.bus = bus
self._log = log
self._runtime_surface = runtime_surface
self.issued_tokens: dict[str, float] = {}
self.api_tokens: dict[str, float] = {}
self.media_secret: bytes = secrets.token_bytes(32)
# Workspace controller
from nanobot.webui.workspaces import WebUIWorkspaceController
self.workspaces = WebUIWorkspaceController(
session_manager=session_manager,
default_workspace=workspace_path,
default_restrict_to_workspace=None,
)
# Settings router
from nanobot.webui.settings_api import runtime_capabilities as _rc
from nanobot.webui.settings_routes import WebUISettingsRouter
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
self.settings_routes = WebUISettingsRouter(
bus=bus,
logger=self._log,
check_api_token=self.check_api_token,
parse_query=_parse_query,
json_response=_http_json_response,
error_response=_http_error,
runtime_surface=runtime_surface,
runtime_capabilities=self._capabilities,
)
# -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool:
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 _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:
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
# -- Main dispatch ------------------------------------------------------
async def dispatch(self, connection: Any, request: WsRequest) -> Any | None:
"""Route an HTTP request. Returns Response or None."""
got, query = _parse_request_path(request.path)
# Token issue endpoint
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(connection, request)
# Bootstrap
if got == "/webui/bootstrap":
return self._handle_bootstrap(connection, request)
# Settings routes (delegated)
response = await self.settings_routes.dispatch(request, got)
if response is not None:
return response
# Session routes
response = self._dispatch_session_routes(request, got)
if response is not None:
return response
# Media routes
response = self._dispatch_media_routes(request, got)
if response is not None:
return response
# Misc routes
response = self._dispatch_misc_routes(connection, request, got)
if response is not None:
return response
# API 404 (never serve SPA for /api/ routes)
if got.startswith("/api/"):
return _http_error(404, "API route not found")
# Static SPA serving
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")
# -- Token issue --------------------------------------------------------
def _handle_token_issue(self, connection: Any, request: Any) -> Any:
secret = self.config.token_issue_secret.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return connection.respond(401, "Unauthorized")
else:
self._log.warning(
"token_issue_path is set but token_issue_secret is empty; "
"any client can obtain connection tokens — set token_issue_secret for production."
)
self._purge_expired_issued_tokens()
if len(self.issued_tokens) >= self._MAX_ISSUED_TOKENS:
self._log.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}
)
# -- Bootstrap ----------------------------------------------------------
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
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):
return _http_error(403, "bootstrap is localhost-only")
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)
self.issued_tokens[token] = expiry
self.api_tokens[token] = expiry
ws_url = self._bootstrap_ws_url(request)
expected_path = _normalize_config_path(self.config.path)
return _http_json_response(
{
"token": token,
"ws_path": 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._capabilities,
}
)
def _bootstrap_ws_url(self, request: Any) -> str:
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"
expected_path = _normalize_config_path(self.config.path)
return f"{scheme}://{host}{expected_path}"
# -- Session routes -----------------------------------------------------
def _dispatch_session_routes(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))
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
if m:
return self._handle_session_delete(request, m.group(1))
return None
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()
from nanobot.session.webui_turns import websocket_turn_wall_started_at
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.workspaces.scope_for_session_key(key)
row["workspace_scope"] = scope.payload()
cleaned.append(row)
return _http_json_response({"sessions": cleaned})
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")
if not _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)
self._augment_media_urls(data)
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 _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
scope = self.workspaces.scope_for_session_key(decoded_key)
data = build_webui_thread_response(
decoded_key,
augment_user_media=self._augment_transcript_user_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 _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")
if not _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)})
# -- Media routes -------------------------------------------------------
def _dispatch_media_routes(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 _handle_media_fetch(
self, sig: str, payload: str, request: WsRequest | None = None
) -> Response:
return serve_signed_media(
sig,
payload,
secret=self.media_secret,
request=request,
media_dir=lambda channel=None: get_media_dir(channel),
)
def sign_media_path(self, abs_path: Path) -> str | None:
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 sign_or_stage_media_path(
path,
secret=self.media_secret,
media_dir=lambda channel=None: get_media_dir(channel),
logger=self._log,
)
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,
)
# -- Misc routes --------------------------------------------------------
def _dispatch_misc_routes(
self, connection: Any, request: WsRequest, got: str
) -> Response | None:
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
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_workspaces(self, connection: Any, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self.workspaces.payload(controls_available=_is_localhost(connection))
)
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._log.exception("failed to write webui sidebar state")
return _http_error(500, "failed to write sidebar state")
return _http_json_response(state)
# -- Static file serving ------------------------------------------------
def _serve_static(self, request_path: str) -> Response | None:
assert self.static_dist_path is not None
rel = request_path.lstrip("/")
if not rel:
rel = "index.html"
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():
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._log.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"
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)],
)
# -- Media helpers (called by WebSocketChannel.send) --------------------
def _augment_media_urls(self, payload: dict[str, Any]) -> None:
messages = payload.get("messages")
if not isinstance(messages, list):
return
for msg in messages:
if not isinstance(msg, dict):
continue
media = msg.get("media")
if not isinstance(media, list) or not media:
continue
urls: list[dict[str, str]] = []
for entry in media:
if not isinstance(entry, str) or not entry:
continue
signed = self.sign_media_path(Path(entry))
if signed is None:
continue
urls.append({"url": signed, "name": Path(entry).name})
if urls:
msg["media_urls"] = urls
msg.pop("media", None)
def _augment_transcript_user_media(self, paths: list[str]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for pstr in paths:
path = Path(pstr)
att = self.sign_or_stage_media_path(path)
if att is None:
continue
mime, _ = mimetypes.guess_type(path.name)
kind = "video" if mime and mime.startswith("video/") else "image"
out.append(
{"kind": kind, "url": att["url"], "name": att.get("name", path.name)},
)
return out
def _is_websocket_channel_session_key(key: str) -> bool:
return key.startswith("websocket:")