feat: implement native subdomain routing (*.hub.yesminedor.tn) for seamless absolute assets resolution across all universes
This commit is contained in:
+50
-1
@@ -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)
|
||||
|
||||
+11
-5
@@ -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):
|
||||
@@ -84,8 +84,14 @@ async def proxy_request(
|
||||
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")))
|
||||
|
||||
+25
-14
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user