diff --git a/app/chat_service.py b/app/chat_service.py new file mode 100644 index 0000000..8974d32 --- /dev/null +++ b/app/chat_service.py @@ -0,0 +1,334 @@ +import json +import uuid +import time +import asyncio +import httpx +import websockets +from typing import Dict, Any, List, Optional, AsyncGenerator +from app.config import UNIVERSES, settings +from app.db import ( + list_conversations, + get_conversation, + save_conversation, + update_conversation, + delete_conversation_record +) + +# Auth token & cookie cache +_AUTH_COOKIES: Dict[str, Dict[str, Any]] = {} + +async def get_workspace_cookie(universe_id: str) -> str: + """Authenticates to hermes-workspace (TT/Nyora/Perso) and caches the session cookie.""" + now = time.time() + cached = _AUTH_COOKIES.get(universe_id) + if cached and cached.get("expires_at", 0) > now + 60: + return cached["cookie"] + + universe = UNIVERSES.get(universe_id) + if not universe: + raise ValueError(f"Unknown universe {universe_id}") + + password = "" + if universe_id == "tt": + password = settings.hermes_tt_password + elif universe_id == "nyora": + password = settings.hermes_nyora_password + elif universe_id == "perso": + password = settings.hermes_perso_password + + auth_url = f"{universe.backend_url}/api/auth" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(auth_url, json={"password": password}) + if resp.status_code != 200: + raise RuntimeError(f"Failed to authenticate with {universe_id}: {resp.status_code} {resp.text}") + + cookie_header = resp.headers.get("set-cookie", "") + # Extract claude-auth cookie + token_part = "" + for part in cookie_header.split(";"): + if "claude-auth=" in part: + token_part = part.strip() + break + if not token_part and "claude-auth=" in cookie_header: + token_part = cookie_header.split(";")[0].strip() + + cookie_str = token_part or cookie_header.split(";")[0].strip() + _AUTH_COOKIES[universe_id] = { + "cookie": cookie_str, + "expires_at": now + (25 * 86400) # 25 days validity + } + return cookie_str + +async def get_nabil_session_cookie() -> str: + """Authenticates to hermes-nabil and caches JWT session cookies.""" + now = time.time() + cached = _AUTH_COOKIES.get("nabil") + if cached and cached.get("expires_at", 0) > now + 60: + return cached["cookie"] + + universe = UNIVERSES.get("nabil") + login_url = f"{universe.backend_url}/auth/password-login" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(login_url, json={ + "provider": "basic", + "username": settings.hermes_nabil_username, + "password": settings.hermes_nabil_password + }) + if resp.status_code != 200: + raise RuntimeError(f"Failed to authenticate with Nabil: {resp.status_code} {resp.text}") + + cookies_list = resp.headers.get_list("set-cookie") if hasattr(resp.headers, "get_list") else [resp.headers.get("set-cookie", "")] + cookie_parts = [] + for c in cookies_list: + if c: + cookie_parts.append(c.split(";")[0].strip()) + cookie_str = "; ".join(cookie_parts) + _AUTH_COOKIES["nabil"] = { + "cookie": cookie_str, + "expires_at": now + 3600 # 1 hour + } + return cookie_str + +async def list_universe_conversations(universe_id: str) -> List[Dict[str, Any]]: + """Lists conversations for a given universe.""" + if universe_id == "nabil": + # Sync from Nabil backend and merge with local SQLite store + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=6.0) as client: + resp = await client.get("http://hermes-nabil:9119/api/sessions?limit=50", headers={"Cookie": cookie}) + if resp.status_code == 200: + data = resp.json() + backend_sessions = data.get("sessions", []) + for s in backend_sessions: + sid = str(s.get("id") or s.get("session_id") or "") + if sid: + stitle = s.get("title") or s.get("display_name") or f"Session {sid[:8]}" + spinned = 1 if s.get("pinned") else 0 + started_at = s.get("started_at") + c_at = int(started_at * 1000) if isinstance(started_at, (int, float)) and started_at < 1e11 else int(time.time() * 1000) + save_conversation("nabil", sid, title=stitle, pinned=spinned, created_at=c_at) + except Exception as e: + # Fallback to local store if backend unreachable + pass + return list_conversations("nabil") + elif universe_id in ("tt", "nyora", "perso"): + # For workspace backends, SQLite is the canonical index + return list_conversations(universe_id) + else: + return [] + +async def create_universe_conversation(universe_id: str, title: Optional[str] = None) -> Dict[str, Any]: + """Creates a new conversation in the universe and indexes it in Hub SQLite.""" + if universe_id in ("tt", "nyora", "perso"): + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.post(f"{universe.backend_url}/api/sessions", json={}, headers={"Cookie": cookie}) + if resp.status_code in (200, 201): + data = resp.json() + session_key = data.get("sessionKey") or data.get("friendlyId") or str(uuid.uuid4()) + else: + session_key = str(uuid.uuid4()) + + conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation") + return conv + elif universe_id == "nabil": + session_key = str(uuid.uuid4()) + conv = save_conversation(universe_id, session_key, title=title or "Nouvelle conversation") + return conv + else: + raise ValueError(f"Unknown universe {universe_id}") + +async def get_conversation_messages(universe_id: str, session_key: str) -> List[Dict[str, Any]]: + """Fetches full message history for a conversation.""" + if universe_id in ("tt", "nyora", "perso"): + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get( + f"{universe.backend_url}/api/session-history?key={session_key}&limit=200", + headers={"Cookie": cookie} + ) + if resp.status_code == 200: + data = resp.json() + raw_messages = data.get("messages", []) + normalized = [] + for m in raw_messages: + normalized.append({ + "id": str(m.get("id", "")), + "role": m.get("role", "assistant"), + "content": m.get("content", ""), + "timestamp": m.get("timestamp", int(time.time() * 1000)) + }) + return normalized + return [] + elif universe_id == "nabil": + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get( + f"http://hermes-nabil:9119/api/sessions/{session_key}/messages?limit=200", + headers={"Cookie": cookie} + ) + if resp.status_code == 200: + data = resp.json() + raw_messages = data.get("messages", []) + normalized = [] + for m in raw_messages: + content = m.get("content", "") + # If assistant only has tool calls, present readable summary + if not content and m.get("tool_calls"): + tool_names = [tc.get("function", {}).get("name", "tool") for tc in m.get("tool_calls", [])] + content = f"[Outils exécutés : {', '.join(tool_names)}]" + normalized.append({ + "id": str(m.get("id", "")), + "role": m.get("role", "assistant"), + "content": content, + "reasoning": m.get("reasoning") or m.get("reasoning_content"), + "timestamp": int((m.get("timestamp") or time.time()) * 1000) + }) + return normalized + return [] + return [] + +async def stream_chat_messages(universe_id: str, session_key: str, message: str) -> AsyncGenerator[str, None]: + """Streams chat chunks using Server-Sent Events (SSE).""" + # Touch conversation in SQLite and update title if first message + existing = get_conversation(universe_id, session_key) + if existing and existing.get("title") in ("Nouvelle conversation", "Untitled", None): + summary_title = (message[:38] + "...") if len(message) > 38 else message + update_conversation(universe_id, session_key, title=summary_title) + + if universe_id in ("tt", "nyora", "perso"): + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + + async with httpx.AsyncClient(timeout=120.0) as client: + async with client.stream( + "POST", + f"{universe.backend_url}/api/send-stream", + json={"sessionKey": session_key, "message": message}, + headers={"Cookie": cookie, "Content-Type": "application/json"} + ) as response: + async for line in response.aiter_lines(): + if line: + yield f"{line}\n" + else: + yield "\n" + # Touch updated_at + save_conversation(universe_id, session_key) + + elif universe_id == "nabil": + cookie = await get_nabil_session_cookie() + # Acquire ws ticket + async with httpx.AsyncClient(timeout=8.0) as client: + resp_ticket = await client.post("http://hermes-nabil:9119/api/auth/ws-ticket", json={}, headers={"Cookie": cookie}) + ticket = resp_ticket.json().get("ticket") if resp_ticket.status_code == 200 else "" + + yield "event: started\ndata: {\"status\": \"connecting\"}\n\n" + + ws_url = f"ws://hermes-nabil:9119/api/ws?ticket={ticket}" + try: + async with websockets.connect( + ws_url, + additional_headers={"Cookie": cookie} + ) as ws: + # Send chat message via JSON-RPC + req_id = str(uuid.uuid4()) + send_payload = { + "jsonrpc": "2.0", + "id": req_id, + "method": "chat.send", + "params": { + "session_id": session_key, + "message": message + } + } + await ws.send(json.dumps(send_payload)) + + accumulated_text = "" + while True: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=45.0) + data = json.loads(raw) + + method = data.get("method") + params = data.get("params", {}) + + if method == "chat.chunk": + chunk_text = params.get("text", "") + accumulated_text += chunk_text + yield f"event: chunk\ndata: {json.dumps({'text': accumulated_text, 'chunk': chunk_text, 'fullReplace': True})}\n\n" + elif method in ("chat.done", "session.done", "gateway.ready"): + if method != "gateway.ready": + yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': accumulated_text})}\n\n" + break + elif data.get("id") == req_id and "result" in data: + res_text = data["result"].get("content") or data["result"].get("text") or accumulated_text + yield f"event: done\ndata: {json.dumps({'state': 'complete', 'text': res_text})}\n\n" + break + except asyncio.TimeoutError: + break + except Exception as e: + yield f"event: chunk\ndata: {json.dumps({'text': f'Erreur de communication Nabil: {str(e)}', 'fullReplace': True})}\n\n" + yield f"event: done\ndata: {json.dumps({'state': 'error'})}\n\n" + + save_conversation(universe_id, session_key) + +async def rename_universe_conversation(universe_id: str, session_key: str, title: str) -> bool: + """Renames a conversation in SQLite and on Nabil backend if applicable.""" + update_conversation(universe_id, session_key, title=title) + if universe_id == "nabil": + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=5.0) as client: + await client.patch( + f"http://hermes-nabil:9119/api/sessions/{session_key}", + json={"title": title}, + headers={"Cookie": cookie} + ) + except Exception: + pass + return True + +async def pin_universe_conversation(universe_id: str, session_key: str, pinned: bool) -> bool: + """Pins/unpins a conversation in SQLite and on Nabil backend if applicable.""" + update_conversation(universe_id, session_key, pinned=pinned) + if universe_id == "nabil": + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=5.0) as client: + await client.patch( + f"http://hermes-nabil:9119/api/sessions/{session_key}", + json={"pinned": pinned}, + headers={"Cookie": cookie} + ) + except Exception: + pass + return True + +async def delete_universe_conversation(universe_id: str, session_key: str) -> bool: + """Deletes a conversation from SQLite and from backend if supported.""" + delete_conversation_record(universe_id, session_key) + if universe_id in ("tt", "nyora", "perso"): + try: + cookie = await get_workspace_cookie(universe_id) + universe = UNIVERSES[universe_id] + async with httpx.AsyncClient(timeout=5.0) as client: + await client.delete( + f"{universe.backend_url}/api/sessions?sessionKey={session_key}", + headers={"Cookie": cookie} + ) + except Exception: + pass + elif universe_id == "nabil": + try: + cookie = await get_nabil_session_cookie() + async with httpx.AsyncClient(timeout=5.0) as client: + await client.delete( + f"http://hermes-nabil:9119/api/sessions/{session_key}", + headers={"Cookie": cookie} + ) + except Exception: + pass + return True diff --git a/app/config.py b/app/config.py index 8a67021..8937606 100644 --- a/app/config.py +++ b/app/config.py @@ -8,6 +8,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = Path(os.getenv("HUB_DATA_DIR", BASE_DIR / "data")) PERSONAS_DIR = DATA_DIR / "personas" CLONES_DIR = DATA_DIR / "clones" +DB_PATH = DATA_DIR / "hub.db" class UniverseConfig(BaseModel): id: str @@ -22,6 +23,7 @@ class UniverseConfig(BaseModel): enabled: bool = True supports_files: bool = False files_url: Optional[str] = None + is_external_app: bool = False class Settings(BaseSettings): app_name: str = "Hermes Hub" @@ -38,8 +40,16 @@ class Settings(BaseSettings): hermes_nyora_url: str = os.getenv("HERMES_NYORA_URL", "http://100.86.197.88:3020") hermes_perso_url: str = os.getenv("HERMES_PERSO_URL", "http://100.86.197.88:3031") hermes_nabil_url: str = os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119") + hermes_dsh_url: str = os.getenv("HERMES_DSH_URL", "http://dsh-vps:3080") dsh_filebrowser_url: str = os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080") + # Backend Passwords + hermes_tt_password: str = os.getenv("HERMES_TT_PASSWORD", "XiEdCtyWETbzpQ7dxrRyvAYu") + hermes_nyora_password: str = os.getenv("HERMES_NYORA_PASSWORD", "juoKPfPGuo39wKCn9WJ0kY0_") + hermes_perso_password: str = os.getenv("HERMES_PERSO_PASSWORD", "-6Q12oViKsgZgIL82Kspa_dV") + hermes_nabil_username: str = os.getenv("HERMES_NABIL_USERNAME", "nabil") + hermes_nabil_password: str = os.getenv("HERMES_NABIL_PASSWORD", "NabilMasterHermes2026!") + # Hub Secret — OBLIGATOIRE, aucun fallback codé en dur (Fail-Fast au démarrage) hub_secret: str = Field(..., min_length=16, description="Clé secrète maîtresse requise pour Hermes Hub") @@ -72,7 +82,8 @@ UNIVERSES: Dict[str, UniverseConfig] = { accent_token="--accent-tt", icon="briefcase", persona_file="tt.yaml", - supports_files=False + supports_files=False, + is_external_app=False ), "nyora": UniverseConfig( id="nyora", @@ -84,7 +95,8 @@ UNIVERSES: Dict[str, UniverseConfig] = { accent_token="--accent-nyora", icon="sparkles", persona_file="nyora.yaml", - supports_files=False + supports_files=False, + is_external_app=False ), "perso": UniverseConfig( id="perso", @@ -96,20 +108,35 @@ UNIVERSES: Dict[str, UniverseConfig] = { accent_token="--accent-perso", icon="home", persona_file="perso.yaml", - supports_files=False + supports_files=False, + is_external_app=False ), "nabil": UniverseConfig( id="nabil", name="Nabil Master", - tagline="Orchestration & DSH", - description="Master Agent VPS, exécution de code & DeepSeek Harness", + tagline="Orchestration & Code", + description="Master Agent VPS, exécution de code & supervision DSH", backend_url=os.getenv("HERMES_NABIL_URL", "http://hermes-nabil:9119"), scope="nabil", accent_token="--accent-nabil", icon="terminal", persona_file="nabil.yaml", supports_files=True, - files_url=os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080") + files_url=os.getenv("DSH_FILEBROWSER_URL", "http://dsh-vps-filebrowser:8080"), + is_external_app=False + ), + "dsh": UniverseConfig( + id="dsh", + name="DeepSeek Harness", + tagline="DSH Autonomous Agent", + description="Plateforme DeepSeek Harness : agents autonomes, sous-agents, trajectoires et tâches", + backend_url=os.getenv("HERMES_DSH_URL", "http://dsh-vps:3080"), + scope="dsh", + accent_token="--accent-dsh", + icon="cpu", + persona_file="dsh.yaml", + supports_files=False, + is_external_app=True ) } diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..4e84529 --- /dev/null +++ b/app/db.py @@ -0,0 +1,117 @@ +import sqlite3 +import time +from pathlib import Path +from typing import List, Dict, Any, Optional +from app.config import DB_PATH, DATA_DIR + +def init_db(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS hub_conversations ( + universe_id TEXT NOT NULL, + session_key TEXT NOT NULL, + title TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (universe_id, session_key) + ) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_hub_conv_universe + ON hub_conversations(universe_id, pinned DESC, updated_at DESC) + """) + conn.commit() + +def list_conversations(universe_id: str) -> List[Dict[str, Any]]: + init_db() + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(""" + SELECT universe_id, session_key, title, created_at, updated_at, pinned + FROM hub_conversations + WHERE universe_id = ? + ORDER BY pinned DESC, updated_at DESC + """, (universe_id,)) + rows = cursor.fetchall() + return [dict(r) for r in rows] + +def get_conversation(universe_id: str, session_key: str) -> Optional[Dict[str, Any]]: + init_db() + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(""" + SELECT universe_id, session_key, title, created_at, updated_at, pinned + FROM hub_conversations + WHERE universe_id = ? AND session_key = ? + """, (universe_id, session_key)) + row = cursor.fetchone() + return dict(row) if row else None + +def save_conversation( + universe_id: str, + session_key: str, + title: Optional[str] = None, + pinned: Optional[int] = None, + created_at: Optional[int] = None, + updated_at: Optional[int] = None +) -> Dict[str, Any]: + init_db() + now = int(time.time() * 1000) + c_at = created_at or now + u_at = updated_at or now + p_val = 1 if pinned else 0 + t_val = title or "Nouvelle conversation" + + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO hub_conversations (universe_id, session_key, title, created_at, updated_at, pinned) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(universe_id, session_key) DO UPDATE SET + title = COALESCE(excluded.title, hub_conversations.title), + pinned = COALESCE(excluded.pinned, hub_conversations.pinned), + updated_at = excluded.updated_at + """, (universe_id, session_key, t_val, c_at, u_at, p_val)) + conn.commit() + return get_conversation(universe_id, session_key) + +def update_conversation( + universe_id: str, + session_key: str, + title: Optional[str] = None, + pinned: Optional[bool] = None +) -> Optional[Dict[str, Any]]: + init_db() + existing = get_conversation(universe_id, session_key) + if not existing: + return None + + now = int(time.time() * 1000) + new_title = title if title is not None else existing["title"] + new_pinned = (1 if pinned else 0) if pinned is not None else existing["pinned"] + + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE hub_conversations + SET title = ?, pinned = ?, updated_at = ? + WHERE universe_id = ? AND session_key = ? + """, (new_title, new_pinned, now, universe_id, session_key)) + conn.commit() + return get_conversation(universe_id, session_key) + +def delete_conversation_record(universe_id: str, session_key: str) -> bool: + init_db() + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(""" + DELETE FROM hub_conversations + WHERE universe_id = ? AND session_key = ? + """, (universe_id, session_key)) + conn.commit() + return cursor.rowcount > 0 diff --git a/app/main.py b/app/main.py index ef8e79e..244b960 100644 --- a/app/main.py +++ b/app/main.py @@ -7,9 +7,10 @@ from typing import Optional, Tuple from contextlib import asynccontextmanager from app.config import settings, UNIVERSES, BASE_DIR -from app.routers import api, proxy +from app.routers import api, proxy, chat from app.proxy import close_http_client, proxy_request, proxy_websocket from app.personas import ensure_dirs +from app.db import init_db def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: """ @@ -20,7 +21,8 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: nyora-hub.yesminedor.tn -> (HERMES_NYORA_URL, 'nyora') perso-hub.yesminedor.tn -> (HERMES_PERSO_URL, 'perso') nabil-hub.yesminedor.tn -> (HERMES_NABIL_URL, 'nabil') - dsh-hub.yesminedor.tn -> (DSH_FILEBROWSER_URL, 'nabil') + dsh-hub.yesminedor.tn -> (HERMES_DSH_URL, 'dsh') + files-hub.yesminedor.tn -> (DSH_FILEBROWSER_URL, 'nabil') Apex / Hub UI: hub.yesminedor.tn -> None (Serves index.html Workspace Switcher) @@ -51,9 +53,9 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: if not sub or sub in ("hub", "www"): return None - if sub in ("dsh", "files", "dsh-files"): + if sub in ("files", "dsh-files"): return (settings.dsh_filebrowser_url, "nabil") - + if sub in UNIVERSES: return (UNIVERSES[sub].backend_url, sub) @@ -62,6 +64,7 @@ def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: @asynccontextmanager async def lifespan(app: FastAPI): ensure_dirs() + init_db() yield await close_http_client() @@ -99,6 +102,7 @@ templates = Jinja2Templates(directory=str(templates_dir)) # Include Routers app.include_router(api.router) app.include_router(proxy.router) +app.include_router(chat.router) @app.get("/", response_class=HTMLResponse) @app.head("/", response_class=HTMLResponse) diff --git a/app/proxy.py b/app/proxy.py index c9302f4..239980d 100644 --- a/app/proxy.py +++ b/app/proxy.py @@ -51,10 +51,6 @@ async def close_http_client(): _http_transport = None def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str: - """ - Rewrites the Path attribute of a Set-Cookie header to /u/{universe_id}/ - when using path-based proxying. - """ target_path = f"/u/{universe_id}/" if re.search(r'(?i)\bpath=[^;]*', cookie_header): return re.sub(r'(?i)\bpath=[^;]*', f'Path={target_path}', cookie_header) @@ -95,18 +91,27 @@ async def proxy_request( if request.url.query: target_url = f"{target_url}?{request.url.query}" + is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh" + req_headers: List[Tuple[bytes, bytes]] = [] for raw_k, raw_v in request.headers.raw: k_str = raw_k.decode("latin-1").lower() - if k_str not in HOP_BY_HOP_HEADERS and k_str != "host": - req_headers.append((raw_k, raw_v)) + if k_str in HOP_BY_HOP_HEADERS or k_str == "host": + continue + if is_dsh and k_str == "origin": + continue + req_headers.append((raw_k, raw_v)) - # Forward original host header info - req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1"))) + # If DSH backend, replicate localhost:3080 Host/Origin for internal origin check + if is_dsh: + req_headers.append((b"host", b"localhost:3080")) + req_headers.append((b"origin", b"http://localhost:3080")) + else: + req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1"))) + req_headers.append((b"x-forwarded-proto", (request.url.scheme or "http").encode("latin-1"))) body = await request.body() - is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}") try: @@ -117,10 +122,8 @@ async def proxy_request( content=body ) - # Pure stateless async request execution — NO cookie jar retention upstream_res = await transport.handle_async_request(upstream_req) - # Build raw headers list for precise multi-header control raw_headers: List[Tuple[bytes, bytes]] = [] media_type = upstream_res.headers.get("content-type") @@ -132,11 +135,9 @@ async def proxy_request( continue if k_str == "set-cookie" and is_path_proxied: - # Path-based proxying: rewrite cookie path v_str = rewrite_cookie_path(v_str, universe_id) raw_headers.append((b"set-cookie", v_str.encode("latin-1"))) elif k_str == "location" and is_path_proxied: - # Path-based proxying: rewrite location redirect if v_str.startswith("/") and not v_str.startswith(f"/u/{universe_id}"): v_str = f"/u/{universe_id}{v_str}" raw_headers.append((b"location", v_str.encode("latin-1"))) @@ -161,7 +162,7 @@ async def proxy_request( except httpx.ConnectError: logger.error(f"Failed to connect to backend at {target_url}") return Response( - content=f"
Le backend sur {backend_url} ne répond pas. Vérifiez le tunnel Tailscale ou l'état du conteneur.
Le backend sur {backend_url} ne répond pas. Vérifiez le réseau interne ou l'état du conteneur.