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.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
@@ -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):
|
||||||
@@ -83,9 +83,15 @@ async def proxy_request(
|
|||||||
for key, value in request.headers.items():
|
for key, value in request.headers.items():
|
||||||
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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user