feat: implement native subdomain routing (*.hub.yesminedor.tn) for seamless absolute assets resolution across all universes

This commit is contained in:
bolbol
2026-08-20 09:03:30 +01:00
parent b6ce9788f3
commit e074015b72
3 changed files with 86 additions and 20 deletions
+50 -1
View File
@@ -3,13 +3,46 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.config import settings, UNIVERSES, BASE_DIR from app.config import settings, UNIVERSES, BASE_DIR
from app.routers import api, proxy 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 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
ensure_dirs() ensure_dirs()
@@ -22,6 +55,22 @@ app = FastAPI(
lifespan=lifespan 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 # Mount static files
static_dir = BASE_DIR / "static" static_dir = BASE_DIR / "static"
static_dir.mkdir(parents=True, exist_ok=True) static_dir.mkdir(parents=True, exist_ok=True)
+11 -5
View File
@@ -40,7 +40,7 @@ async def close_http_client():
def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str: def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str:
""" """
Rewrites the Path attribute of a Set-Cookie header to /u/{universe_id}/ 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}/" target_path = f"/u/{universe_id}/"
if re.search(r'(?i)\bpath=[^;]*', cookie_header): if re.search(r'(?i)\bpath=[^;]*', cookie_header):
@@ -84,8 +84,14 @@ async def proxy_request(
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host": if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host":
req_headers[key] = value 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() body = await request.body()
is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}")
try: try:
upstream_req = client.build_request( upstream_req = client.build_request(
method=request.method, method=request.method,
@@ -107,12 +113,12 @@ async def proxy_request(
if k_str in HOP_BY_HOP_HEADERS: if k_str in HOP_BY_HOP_HEADERS:
continue continue
if k_str == "set-cookie" and universe_id: if k_str == "set-cookie" and is_path_proxied:
# Cloisonnement strict des cookies de session par univers # Path-based proxying: rewrite cookie path
v_str = rewrite_cookie_path(v_str, universe_id) v_str = rewrite_cookie_path(v_str, universe_id)
raw_headers.append((b"set-cookie", v_str.encode("latin-1"))) raw_headers.append((b"set-cookie", v_str.encode("latin-1")))
elif k_str == "location" and universe_id: elif k_str == "location" and is_path_proxied:
# Réécriture de la redirection si le backend renvoie vers la racine / # Path-based proxying: rewrite location redirect
if v_str.startswith("/") and not v_str.startswith(f"/u/{universe_id}"): if v_str.startswith("/") and not v_str.startswith(f"/u/{universe_id}"):
v_str = f"/u/{universe_id}{v_str}" v_str = f"/u/{universe_id}{v_str}"
raw_headers.append((b"location", v_str.encode("latin-1"))) raw_headers.append((b"location", v_str.encode("latin-1")))
+25 -14
View File
@@ -1,7 +1,7 @@
/** /**
* Hermes Hub — Client-side Workspace Switcher & Context Manager * Hermes Hub — Client-side Workspace Switcher & Context Manager
* Garantit l'isolation stricte du state front-end entre univers * Routage natif par sous-domaine (tt.hub.yesminedor.tn, nyora.hub..., perso.hub..., nabil.hub..., dsh.hub...)
* Intègre le sélecteur toggle DSH (Session vs Explorateur de fichiers) * Résout à 100% le chargement des assets absolus sans réécriture fragile
*/ */
(function () { (function () {
@@ -40,6 +40,26 @@
} }
function getTargetUrl(universeId, mode) { 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") { if (universeId === "nabil" && mode === "files") {
return "/u/nabil/files/"; return "/u/nabil/files/";
} }
@@ -77,13 +97,12 @@
if (!btn) return; if (!btn) return;
// 1. ISOLATION TOTALE DU STATE CLIENT // 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"); loader.classList.remove("hidden");
iframe.src = "about:blank"; iframe.src = "about:blank";
// 2. Mettre à jour l'univers actif // 2. Mettre à jour l'univers actif
activeUniverse = universeId; activeUniverse = universeId;
activeMode = "session"; // Réinitialiser le mode à session par défaut activeMode = "session";
// 3. Mettre à jour la classe active sur la sidebar // 3. Mettre à jour la classe active sur la sidebar
document.querySelectorAll(".hub-universe-btn").forEach((el) => el.classList.remove("active")); document.querySelectorAll(".hub-universe-btn").forEach((el) => el.classList.remove("active"));
@@ -106,7 +125,6 @@
if (nabilModeToggle) { if (nabilModeToggle) {
if (supportsFiles) { if (supportsFiles) {
nabilModeToggle.classList.add("visible"); nabilModeToggle.classList.add("visible");
// Reset tabs visual state
if (tabSession && tabFiles) { if (tabSession && tabFiles) {
tabSession.classList.add("active"); tabSession.classList.add("active");
tabSession.setAttribute("aria-selected", "true"); 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(() => { setTimeout(() => {
iframe.src = getTargetUrl(activeUniverse, activeMode); iframe.src = getTargetUrl(activeUniverse, activeMode);
}, 50); }, 50);
@@ -143,7 +161,7 @@
data.forEach((u) => { data.forEach((u) => {
const dot = document.getElementById(`status-dot-${u.id}`); const dot = document.getElementById(`status-dot-${u.id}`);
if (dot) { 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.className = "hub-status-dot online";
dot.title = `En ligne (${u.health.latency_ms}ms)`; dot.title = `En ligne (${u.health.latency_ms}ms)`;
} else { } else {
@@ -234,7 +252,6 @@
// Setup Event Listeners // Setup Event Listeners
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// Boutons de changement d'univers
document.querySelectorAll(".hub-universe-btn").forEach((btn) => { document.querySelectorAll(".hub-universe-btn").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const uId = btn.dataset.universe; const uId = btn.dataset.universe;
@@ -242,17 +259,14 @@
}); });
}); });
// Toggle Mode DSH (Session vs Files)
tabSession?.addEventListener("click", () => setMode("session")); tabSession?.addEventListener("click", () => setMode("session"));
tabFiles?.addEventListener("click", () => setMode("files")); tabFiles?.addEventListener("click", () => setMode("files"));
// Outils Footer
document.getElementById("btn-edit-persona")?.addEventListener("click", openPersonaModal); document.getElementById("btn-edit-persona")?.addEventListener("click", openPersonaModal);
document.getElementById("btn-clone-universe")?.addEventListener("click", openCloneModal); document.getElementById("btn-clone-universe")?.addEventListener("click", openCloneModal);
document.getElementById("btn-save-persona")?.addEventListener("click", savePersona); document.getElementById("btn-save-persona")?.addEventListener("click", savePersona);
document.getElementById("btn-submit-clone")?.addEventListener("click", submitClone); document.getElementById("btn-submit-clone")?.addEventListener("click", submitClone);
// Boutons fermer modales
document.querySelectorAll(".hub-modal-close").forEach((btn) => { document.querySelectorAll(".hub-modal-close").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
personaModal.classList.remove("open"); personaModal.classList.remove("open");
@@ -260,10 +274,7 @@
}); });
}); });
// Démarrage : activer le premier univers (TT)
switchUniverse("tt"); switchUniverse("tt");
// Lancer la vérification de santé
refreshHealthStatus(); refreshHealthStatus();
setInterval(refreshHealthStatus, 15000); setInterval(refreshHealthStatus, 15000);
}); });