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.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user