From 5a89f46e3d8b14d49e3956e3f629284a86990793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20Lindahl?= Date: Mon, 16 Feb 2026 10:40:17 +0100 Subject: [PATCH] fix: replace per-path proxy with cookie-based catch-all routing The /session/{id} URL prefix collided with OpenCode's internal /session/{slug} SPA routes, causing a blank page. Now /c/{id} is a thin entry point that sets a session cookie and redirects to /, where the SPA loads at root with its router working correctly. This also replaces ~50 individual per-path proxy route handlers with a single /{path:path} catch-all, and simplifies the Caddyfile from ~180 lines to ~17. --- Makefile | 8 +- nginx/Caddyfile | 183 +--------------- session-manager/routes/proxy.py | 378 ++++---------------------------- 3 files changed, 51 insertions(+), 518 deletions(-) diff --git a/Makefile b/Makefile index 046acfe..f55dd0f 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ try: up sleep 1; \ done && \ echo "" && \ - echo "Opening http://localhost:8080/session/$$SESSION_ID" && \ - (xdg-open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ - open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ - echo "Visit: http://localhost:8080/session/$$SESSION_ID") + echo "Opening http://localhost:8080/c/$$SESSION_ID" && \ + (xdg-open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \ + open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \ + echo "Visit: http://localhost:8080/c/$$SESSION_ID") diff --git a/nginx/Caddyfile b/nginx/Caddyfile index a3c15a5..d88e5de 100644 --- a/nginx/Caddyfile +++ b/nginx/Caddyfile @@ -2,187 +2,16 @@ # Using HTTP for local development (no SSL warnings) # Main web interface - HTTP only for development -http://localhost { - # API endpoints for session management +:80 { + # API endpoints for session management (strip /api prefix) handle /api/* { uri strip_prefix /api reverse_proxy session-manager:8000 } - # OpenCode internal session API (without session_id in path) - # Must be BEFORE /session/{session_id}* to match first - handle /session { + # Everything else goes to session-manager (handles /c/{id} entry + # point and cookie-based proxy to OpenCode containers) + handle { reverse_proxy session-manager:8000 } - - # Session-specific routing - proxy to session manager for dynamic routing - handle /session/{session_id}* { - reverse_proxy session-manager:8000 - } - - # OpenCode SPA runtime requests - route based on session cookie - handle /global/* { - reverse_proxy session-manager:8000 - } - - handle /assets/* { - reverse_proxy session-manager:8000 - } - - handle /provider/* { - reverse_proxy session-manager:8000 - } - - handle /provider { - reverse_proxy session-manager:8000 - } - - handle /project { - reverse_proxy session-manager:8000 - } - - handle /path { - reverse_proxy session-manager:8000 - } - - handle /find/* { - reverse_proxy session-manager:8000 - } - - handle /file { - reverse_proxy session-manager:8000 - } - - handle /file/* { - reverse_proxy session-manager:8000 - } - - # Additional OpenCode API endpoints for root-path operation - handle /agent { - reverse_proxy session-manager:8000 - } - - handle /agent/* { - reverse_proxy session-manager:8000 - } - - handle /config { - reverse_proxy session-manager:8000 - } - - handle /config/* { - reverse_proxy session-manager:8000 - } - - handle /model { - reverse_proxy session-manager:8000 - } - - handle /model/* { - reverse_proxy session-manager:8000 - } - - handle /thread/* { - reverse_proxy session-manager:8000 - } - - handle /chat/* { - reverse_proxy session-manager:8000 - } - - handle /tree { - reverse_proxy session-manager:8000 - } - - handle /tree/* { - reverse_proxy session-manager:8000 - } - - handle /conversation { - reverse_proxy session-manager:8000 - } - - handle /conversation/* { - reverse_proxy session-manager:8000 - } - - handle /project/* { - reverse_proxy session-manager:8000 - } - - # OpenCode communication endpoints for message sending - handle /command { - reverse_proxy session-manager:8000 - } - - handle /command/* { - reverse_proxy session-manager:8000 - } - - handle /mcp { - reverse_proxy session-manager:8000 - } - - handle /mcp/* { - reverse_proxy session-manager:8000 - } - - handle /lsp { - reverse_proxy session-manager:8000 - } - - handle /lsp/* { - reverse_proxy session-manager:8000 - } - - handle /vcs { - reverse_proxy session-manager:8000 - } - - handle /vcs/* { - reverse_proxy session-manager:8000 - } - - handle /permission { - reverse_proxy session-manager:8000 - } - - handle /permission/* { - reverse_proxy session-manager:8000 - } - - handle /question { - reverse_proxy session-manager:8000 - } - - handle /question/* { - reverse_proxy session-manager:8000 - } - - handle /event { - reverse_proxy session-manager:8000 - } - - handle /event/* { - reverse_proxy session-manager:8000 - } - - handle /status { - reverse_proxy session-manager:8000 - } - - handle /status/* { - reverse_proxy session-manager:8000 - } - - # Health check - handle /health { - reverse_proxy session-manager:8000 - } - - # Static files and main interface (fallback) - handle /* { - try_files {path} {path}/ /index.html - file_server - } -} \ No newline at end of file +} diff --git a/session-manager/routes/proxy.py b/session-manager/routes/proxy.py index 3c61ef3..21a7471 100644 --- a/session-manager/routes/proxy.py +++ b/session-manager/routes/proxy.py @@ -1,10 +1,9 @@ import os -import re import time from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, Request, Response -from starlette.responses import StreamingResponse +from starlette.responses import RedirectResponse, StreamingResponse import httpx from session_manager import session_manager @@ -26,249 +25,29 @@ def get_session_from_cookie(request: Request) -> str: if not session_id: raise HTTPException( status_code=400, - detail="No active session - please access via /session/{id}/ first", + detail="No active session - please access via /c/{id} first", ) return session_id -@router.api_route("/global/{path:path}", methods=ALL_METHODS) -async def proxy_global_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"global/{path}") - - -@router.api_route("/assets/{path:path}", methods=ALL_METHODS) -async def proxy_assets_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"assets/{path}") - - -@router.api_route("/provider/{path:path}", methods=ALL_METHODS) -async def proxy_provider_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"provider/{path}") - - -@router.api_route("/provider", methods=ALL_METHODS) -async def proxy_provider_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "provider") - - -@router.api_route("/project", methods=ALL_METHODS) -async def proxy_project_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "project") - - -@router.api_route("/path", methods=ALL_METHODS) -async def proxy_path_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "path") - - -@router.api_route("/find/{path:path}", methods=ALL_METHODS) -async def proxy_find_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"find/{path}") - - -@router.api_route("/file", methods=ALL_METHODS) -async def proxy_file_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "file") - - -@router.api_route("/file/{path:path}", methods=ALL_METHODS) -async def proxy_file_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"file/{path}") - - -# Additional OpenCode API endpoints for root-path operation -@router.api_route("/project/{path:path}", methods=ALL_METHODS) -async def proxy_project_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"project/{path}") - - -@router.api_route("/agent", methods=ALL_METHODS) -async def proxy_agent_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "agent") - - -@router.api_route("/agent/{path:path}", methods=ALL_METHODS) -async def proxy_agent_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"agent/{path}") - - -@router.api_route("/config", methods=ALL_METHODS) -async def proxy_config_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "config") - - -@router.api_route("/config/{path:path}", methods=ALL_METHODS) -async def proxy_config_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"config/{path}") - - -@router.api_route("/model", methods=ALL_METHODS) -async def proxy_model_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "model") - - -@router.api_route("/model/{path:path}", methods=ALL_METHODS) -async def proxy_model_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"model/{path}") - - -@router.api_route("/thread/{path:path}", methods=ALL_METHODS) -async def proxy_thread_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"thread/{path}") - - -@router.api_route("/chat/{path:path}", methods=ALL_METHODS) -async def proxy_chat_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"chat/{path}") - - -@router.api_route("/tree", methods=ALL_METHODS) -async def proxy_tree_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "tree") - - -@router.api_route("/tree/{path:path}", methods=ALL_METHODS) -async def proxy_tree_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"tree/{path}") - - -@router.api_route("/conversation", methods=ALL_METHODS) -async def proxy_conversation_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "conversation") - - -@router.api_route("/conversation/{path:path}", methods=ALL_METHODS) -async def proxy_conversation_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"conversation/{path}") - - -# OpenCode session and communication endpoints for message sending -@router.api_route("/command", methods=ALL_METHODS) -async def proxy_command_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "command") - - -@router.api_route("/command/{path:path}", methods=ALL_METHODS) -async def proxy_command_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"command/{path}") - - -@router.api_route("/mcp", methods=ALL_METHODS) -async def proxy_mcp_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "mcp") - - -@router.api_route("/mcp/{path:path}", methods=ALL_METHODS) -async def proxy_mcp_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"mcp/{path}") - - -@router.api_route("/lsp", methods=ALL_METHODS) -async def proxy_lsp_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "lsp") - - -@router.api_route("/lsp/{path:path}", methods=ALL_METHODS) -async def proxy_lsp_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"lsp/{path}") - - -@router.api_route("/vcs", methods=ALL_METHODS) -async def proxy_vcs_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "vcs") - - -@router.api_route("/vcs/{path:path}", methods=ALL_METHODS) -async def proxy_vcs_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"vcs/{path}") - - -@router.api_route("/permission", methods=ALL_METHODS) -async def proxy_permission_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "permission") - - -@router.api_route("/permission/{path:path}", methods=ALL_METHODS) -async def proxy_permission_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"permission/{path}") - - -@router.api_route("/question", methods=ALL_METHODS) -async def proxy_question_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "question") - - -@router.api_route("/question/{path:path}", methods=ALL_METHODS) -async def proxy_question_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"question/{path}") - - -@router.api_route("/event", methods=ALL_METHODS) -async def proxy_event_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "event") - - -@router.api_route("/event/{path:path}", methods=ALL_METHODS) -async def proxy_event_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"event/{path}") - - -@router.api_route("/status", methods=ALL_METHODS) -async def proxy_status_to_session(request: Request): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, "status") - - -@router.api_route("/status/{path:path}", methods=ALL_METHODS) -async def proxy_status_path_to_session(request: Request, path: str): - session_id = get_session_from_cookie(request) - return await proxy_to_session(request, session_id, f"status/{path}") - - -# OpenCode internal session endpoint (different from our container sessions) -# Must be defined BEFORE /session/{session_id}/{path} to match first -@router.api_route("/session", methods=ALL_METHODS) -async def proxy_internal_session_to_session(request: Request): - session_id = get_session_from_cookie(request) - # Proxy the request directly, preserving query params - path = "session" - return await proxy_to_session(request, session_id, path) +@router.get("/c/{session_id}") +@router.get("/c/{session_id}/{path:path}") +async def enter_session(request: Request, session_id: str, path: str = ""): + """Entry point: set session cookie and redirect to root.""" + session = await session_manager.get_session(session_id) + if not session or session.status != "running": + raise HTTPException( + status_code=404, detail="Session not found or not running" + ) + resp = RedirectResponse(url="/", status_code=302) + resp.set_cookie( + key="lovdata_session", + value=session_id, + httponly=True, + samesite="lax", + max_age=86400, + ) + return resp def _is_sse_request(request: Request, path: str) -> bool: @@ -282,31 +61,19 @@ def _is_sse_request(request: Request, path: str) -> bool: return False -@router.api_route("/session/{session_id}/{path:path}", methods=ALL_METHODS) -async def proxy_to_session(request: Request, session_id: str, path: str): +@router.api_route("/{path:path}", methods=ALL_METHODS) +async def proxy_root_to_container(request: Request, path: str): + """Catch-all: proxy everything to the container identified by cookie.""" + session_id = get_session_from_cookie(request) + return await _proxy_to_container(request, session_id, path) + + +async def _proxy_to_container(request: Request, session_id: str, path: str): start_time = time.time() with RequestContext(): - log_request( - request.method, - f"/session/{session_id}/{path}", - 200, - 0, - operation="proxy_start", - session_id=session_id, - ) - session = await session_manager.get_session(session_id) if not session or session.status != "running": - duration_ms = (time.time() - start_time) * 1000 - log_request( - request.method, - f"/session/{session_id}/{path}", - 404, - duration_ms, - session_id=session_id, - error="Session not found or not running", - ) raise HTTPException( status_code=404, detail="Session not found or not running" ) @@ -335,7 +102,7 @@ async def proxy_to_session(request: Request, session_id: str, path: str): if _is_sse_request(request, path): return await _proxy_sse(request, session_id, path, url, headers, body, start_time) - # --- Buffered path (original behaviour) --- + # --- Buffered path --- try: log_session_operation( session_id, "proxy_request", method=request.method, path=path @@ -351,44 +118,17 @@ async def proxy_to_session(request: Request, session_id: str, path: str): duration_ms = (time.time() - start_time) * 1000 log_request( request.method, - f"/session/{session_id}/{path}", + f"/{path}", response.status_code, duration_ms, session_id=session_id, operation="proxy_complete", ) - log_security_event( - "proxy_access", - "info", - session_id=session_id, - method=request.method, - path=path, - status_code=response.status_code, - ) - - content = response.content - response_headers = dict(response.headers) - content_type = response.headers.get("content-type", "") - - if "text/html" in content_type: - try: - html = content.decode("utf-8") - session_prefix = f"/session/{session_id}" - - html = re.sub(r'src="/', f'src="{session_prefix}/', html) - html = re.sub(r'href="/', f'href="{session_prefix}/', html) - html = re.sub(r'content="/', f'content="{session_prefix}/', html) - - content = html.encode("utf-8") - response_headers["content-length"] = str(len(content)) - except UnicodeDecodeError: - pass - resp = Response( - content=content, + content=response.content, status_code=response.status_code, - headers=response_headers, + headers=dict(response.headers), ) resp.set_cookie( key="lovdata_session", @@ -402,20 +142,8 @@ async def proxy_to_session(request: Request, session_id: str, path: str): except httpx.TimeoutException as e: duration_ms = (time.time() - start_time) * 1000 log_request( - request.method, - f"/session/{session_id}/{path}", - 504, - duration_ms, - session_id=session_id, - error="timeout", - ) - log_security_event( - "proxy_timeout", - "warning", - session_id=session_id, - method=request.method, - path=path, - error=str(e), + request.method, f"/{path}", 504, duration_ms, + session_id=session_id, error="timeout", ) raise HTTPException( status_code=504, detail="Request to session container timed out" @@ -423,20 +151,8 @@ async def proxy_to_session(request: Request, session_id: str, path: str): except httpx.RequestError as e: duration_ms = (time.time() - start_time) * 1000 log_request( - request.method, - f"/session/{session_id}/{path}", - 502, - duration_ms, - session_id=session_id, - error=str(e), - ) - log_security_event( - "proxy_connection_error", - "error", - session_id=session_id, - method=request.method, - path=path, - error=str(e), + request.method, f"/{path}", 502, duration_ms, + session_id=session_id, error=str(e), ) raise HTTPException( status_code=502, @@ -458,11 +174,6 @@ async def _proxy_sse( session_id, "proxy_sse_stream", method=request.method, path=path ) - # We need to keep the httpx stream context alive for the lifetime of the - # StreamingResponse. Starlette calls our async generator and only closes - # it when the client disconnects, so we enter the context manager inside - # the generator and exit on cleanup. - async def event_generator(): try: async with stream_http_request( @@ -475,22 +186,15 @@ async def _proxy_sse( yield chunk except httpx.RequestError as e: log_security_event( - "proxy_sse_error", - "error", - session_id=session_id, - method=request.method, - path=path, - error=str(e), + "proxy_sse_error", "error", + session_id=session_id, method=request.method, + path=path, error=str(e), ) finally: duration_ms = (time.time() - start_time) * 1000 log_request( - request.method, - f"/session/{session_id}/{path}", - 200, - duration_ms, - session_id=session_id, - operation="proxy_sse_complete", + request.method, f"/{path}", 200, duration_ms, + session_id=session_id, operation="proxy_sse_complete", ) resp = StreamingResponse(