From e074015b728e09391e597e151692e150b994029d Mon Sep 17 00:00:00 2001 From: bolbol Date: Thu, 20 Aug 2026 09:03:30 +0100 Subject: [PATCH] feat: implement native subdomain routing (*.hub.yesminedor.tn) for seamless absolute assets resolution across all universes --- app/main.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++- app/proxy.py | 16 ++++++++++----- static/js/hub.js | 39 +++++++++++++++++++++++------------- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/app/main.py b/app/main.py index 7a9169b..341e9ed 100644 --- a/app/main.py +++ b/app/main.py @@ -3,13 +3,46 @@ from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pathlib import Path +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.proxy import close_http_client +from app.proxy import close_http_client, proxy_request from app.personas import ensure_dirs +def get_subdomain_target(host: str) -> Optional[Tuple[str, str]]: + """ + Inspects Host header and returns (target_backend_url, universe_id) if it matches a subdomain. + Examples: + tt.hub.yesminedor.tn -> (HERMES_TT_URL, 'tt') + 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') + """ + if not host: + return None + + hostname = host.split(":")[0].lower() + + sub = None + if hostname.endswith(".hub.yesminedor.tn"): + sub = hostname.rsplit(".hub.yesminedor.tn", 1)[0] + elif hostname.endswith(".localhost"): + sub = hostname.rsplit(".localhost", 1)[0] + + if not sub or sub in ("hub", "www"): + return None + + if sub in ("dsh", "files", "dsh-files"): + return (settings.dsh_filebrowser_url, "nabil") + + if sub in UNIVERSES: + return (UNIVERSES[sub].backend_url, sub) + + return None + @asynccontextmanager async def lifespan(app: FastAPI): ensure_dirs() @@ -22,6 +55,22 @@ app = FastAPI( lifespan=lifespan ) +# Subdomain Routing Middleware +@app.middleware("http") +async def subdomain_routing_middleware(request: Request, call_next): + host = request.headers.get("host", "") + target = get_subdomain_target(host) + if target: + backend_url, universe_id = target + path = request.url.path + return await proxy_request( + request=request, + backend_url=backend_url, + path=path, + universe_id=universe_id + ) + return await call_next(request) + # Mount static files static_dir = BASE_DIR / "static" static_dir.mkdir(parents=True, exist_ok=True) diff --git a/app/proxy.py b/app/proxy.py index f6a3f17..89d73ce 100644 --- a/app/proxy.py +++ b/app/proxy.py @@ -40,7 +40,7 @@ async def close_http_client(): def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str: """ Rewrites the Path attribute of a Set-Cookie header to /u/{universe_id}/ - to strictly isolate session cookies between Hermes universes. + when using path-based proxying. """ target_path = f"/u/{universe_id}/" if re.search(r'(?i)\bpath=[^;]*', cookie_header): @@ -83,9 +83,15 @@ async def proxy_request( for key, value in request.headers.items(): if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host": req_headers[key] = value + + # Forward original host header info + req_headers["x-forwarded-host"] = request.headers.get("host", "") + req_headers["x-forwarded-proto"] = request.url.scheme or "http" body = await request.body() + is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}") + try: upstream_req = client.build_request( method=request.method, @@ -107,12 +113,12 @@ async def proxy_request( if k_str in HOP_BY_HOP_HEADERS: continue - if k_str == "set-cookie" and universe_id: - # Cloisonnement strict des cookies de session par univers + 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 universe_id: - # Réécriture de la redirection si le backend renvoie vers la racine / + 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"))) diff --git a/static/js/hub.js b/static/js/hub.js index 70e5e59..68757a3 100644 --- a/static/js/hub.js +++ b/static/js/hub.js @@ -1,7 +1,7 @@ /** * Hermes Hub — Client-side Workspace Switcher & Context Manager - * Garantit l'isolation stricte du state front-end entre univers - * Intègre le sélecteur toggle DSH (Session vs Explorateur de fichiers) + * Routage natif par sous-domaine (tt.hub.yesminedor.tn, nyora.hub..., perso.hub..., nabil.hub..., dsh.hub...) + * Résout à 100% le chargement des assets absolus sans réécriture fragile */ (function () { @@ -40,6 +40,26 @@ } function getTargetUrl(universeId, mode) { + const hostname = window.location.hostname; + + // 1. Production *.hub.yesminedor.tn (Routage par sous-domaine natif) + if (hostname === "hub.yesminedor.tn" || hostname.endsWith(".hub.yesminedor.tn")) { + if (universeId === "nabil" && mode === "files") { + return "https://dsh.hub.yesminedor.tn/"; + } + return `https://${universeId}.hub.yesminedor.tn/`; + } + + // 2. Test local *.localhost + if (hostname.endsWith(".localhost") || hostname === "localhost") { + const port = window.location.port ? `:${window.location.port}` : ""; + if (universeId === "nabil" && mode === "files") { + return `${window.location.protocol}//dsh.localhost${port}/`; + } + return `${window.location.protocol}//${universeId}.localhost${port}/`; + } + + // 3. Fallback IP direct / chemin relatif (pour tests curl/SSH) if (universeId === "nabil" && mode === "files") { return "/u/nabil/files/"; } @@ -77,13 +97,12 @@ if (!btn) return; // 1. ISOLATION TOTALE DU STATE CLIENT - // Décharger immédiatement l'iframe précédente pour purger la mémoire JS loader.classList.remove("hidden"); iframe.src = "about:blank"; // 2. Mettre à jour l'univers actif activeUniverse = universeId; - activeMode = "session"; // Réinitialiser le mode à session par défaut + activeMode = "session"; // 3. Mettre à jour la classe active sur la sidebar document.querySelectorAll(".hub-universe-btn").forEach((el) => el.classList.remove("active")); @@ -106,7 +125,6 @@ if (nabilModeToggle) { if (supportsFiles) { nabilModeToggle.classList.add("visible"); - // Reset tabs visual state if (tabSession && tabFiles) { tabSession.classList.add("active"); tabSession.setAttribute("aria-selected", "true"); @@ -118,7 +136,7 @@ } } - // 7. Charger le nouvel univers via le proxy dédié + // 7. Charger le nouvel univers via son sous-domaine dédié setTimeout(() => { iframe.src = getTargetUrl(activeUniverse, activeMode); }, 50); @@ -143,7 +161,7 @@ data.forEach((u) => { const dot = document.getElementById(`status-dot-${u.id}`); if (dot) { - if (u.health && u.health.status === "online") { + if (u.health && (u.health.status === "online" || u.health.status_code === 200 || u.health.status_code === 302)) { dot.className = "hub-status-dot online"; dot.title = `En ligne (${u.health.latency_ms}ms)`; } else { @@ -234,7 +252,6 @@ // Setup Event Listeners document.addEventListener("DOMContentLoaded", () => { - // Boutons de changement d'univers document.querySelectorAll(".hub-universe-btn").forEach((btn) => { btn.addEventListener("click", () => { const uId = btn.dataset.universe; @@ -242,17 +259,14 @@ }); }); - // Toggle Mode DSH (Session vs Files) tabSession?.addEventListener("click", () => setMode("session")); tabFiles?.addEventListener("click", () => setMode("files")); - // Outils Footer document.getElementById("btn-edit-persona")?.addEventListener("click", openPersonaModal); document.getElementById("btn-clone-universe")?.addEventListener("click", openCloneModal); document.getElementById("btn-save-persona")?.addEventListener("click", savePersona); document.getElementById("btn-submit-clone")?.addEventListener("click", submitClone); - // Boutons fermer modales document.querySelectorAll(".hub-modal-close").forEach((btn) => { btn.addEventListener("click", () => { personaModal.classList.remove("open"); @@ -260,10 +274,7 @@ }); }); - // Démarrage : activer le premier univers (TT) switchUniverse("tt"); - - // Lancer la vérification de santé refreshHealthStatus(); setInterval(refreshHealthStatus, 15000); });