Spaces:
Running
Running
| ; | |
| const http = require("http"); | |
| const https = require("https"); | |
| const fs = require("fs"); | |
| const net = require("net"); | |
| const crypto = require("crypto"); | |
| const PORT = Number(process.env.PORT || 7861); | |
| const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642); | |
| const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119); | |
| const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765); | |
| const JUPYTER_PORT = 8888; | |
| const GATEWAY_HOST = "127.0.0.1"; | |
| const TERMINAL_BASE = "/terminal"; | |
| const startTime = Date.now(); | |
| const API_SERVER_KEY = process.env.API_SERVER_KEY || ""; | |
| const APP_BASE = "/app"; | |
| const LOGIN_PATH = "/login"; | |
| const SESSION_COOKIE = "huggingmes_session"; | |
| // ── Private Space redirect support ── | |
| const SPACE_ID = (process.env.SPACE_ID || "").trim(); | |
| function deriveHfSpaceUrl() { | |
| if (SPACE_ID) return `https://huggingface.co/spaces/${SPACE_ID}`; | |
| const host = (process.env.SPACE_HOST || "").replace(/\.hf\.space$/i, ""); | |
| const author = (process.env.SPACE_AUTHOR_NAME || "").trim().toLowerCase(); | |
| if (author && host.toLowerCase().startsWith(author + "-")) { | |
| const spaceName = host.slice(author.length + 1); | |
| return `https://huggingface.co/spaces/${process.env.SPACE_AUTHOR_NAME}/${spaceName}`; | |
| } | |
| return ""; | |
| } | |
| const HF_SPACE_URL = deriveHfSpaceUrl(); | |
| // Privacy detection priority: | |
| // 1. SPACE_PRIVACY env var ("public"/"private") — explicit override, skip API call | |
| // 2. HF API auto-detect with retry | |
| // 3. Fail-secure: treat as private if SPACE_ID set | |
| const _spacPrivacyEnv = (process.env.SPACE_PRIVACY || "").trim().toLowerCase(); | |
| let SPACE_IS_PRIVATE; | |
| let _privacyDetectionDone = false; | |
| let _privacyDetectionResolve; | |
| const privacyDetectionReady = new Promise((res) => { _privacyDetectionResolve = res; }); | |
| if (_spacPrivacyEnv === "public") { | |
| SPACE_IS_PRIVATE = false; | |
| _privacyDetectionDone = true; | |
| console.log("[health-server] Space privacy: public (SPACE_PRIVACY env override)"); | |
| _privacyDetectionResolve(); | |
| } else if (_spacPrivacyEnv === "private") { | |
| SPACE_IS_PRIVATE = true; | |
| _privacyDetectionDone = true; | |
| console.log("[health-server] Space privacy: private (SPACE_PRIVACY env override)"); | |
| _privacyDetectionResolve(); | |
| } else { | |
| // Fail-secure default until API call resolves | |
| SPACE_IS_PRIVATE = !!SPACE_ID; | |
| } | |
| async function detectSpacePrivacy() { | |
| if (_spacPrivacyEnv === "public" || _spacPrivacyEnv === "private") return; | |
| if (!SPACE_ID) { | |
| SPACE_IS_PRIVATE = false; | |
| _privacyDetectionDone = true; | |
| _privacyDetectionResolve(); | |
| return; | |
| } | |
| const token = (process.env.HF_TOKEN || "").trim(); | |
| const reqOptions = { | |
| hostname: "huggingface.co", | |
| path: `/api/spaces/${SPACE_ID}`, | |
| method: "GET", | |
| headers: Object.assign( | |
| { "User-Agent": "HuggingMes/health-server" }, | |
| token ? { Authorization: `Bearer ${token}` } : {} | |
| ), | |
| }; | |
| const MAX_ATTEMPTS = 5; | |
| let detected = false; | |
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | |
| try { | |
| const result = await new Promise((resolve) => { | |
| const r = https.request(reqOptions, (apiRes) => { | |
| let body = ""; | |
| apiRes.on("data", (chunk) => { body += chunk; }); | |
| apiRes.on("end", () => { | |
| try { | |
| if (apiRes.statusCode === 200) { | |
| SPACE_IS_PRIVATE = JSON.parse(body).private === true; | |
| resolve({ ok: true }); | |
| } else if (apiRes.statusCode === 401 || apiRes.statusCode === 403) { | |
| SPACE_IS_PRIVATE = true; | |
| resolve({ ok: true }); | |
| } else { | |
| resolve({ ok: false }); | |
| } | |
| } catch { resolve({ ok: false }); } | |
| }); | |
| }); | |
| r.on("error", () => resolve({ ok: false })); | |
| r.setTimeout(8000, () => { r.destroy(); resolve({ ok: false }); }); | |
| r.end(); | |
| }); | |
| console.log(`[health-server] Privacy detection attempt ${attempt}/${MAX_ATTEMPTS}: ok=${result.ok}`); | |
| if (result.ok) { detected = true; break; } | |
| } catch {} | |
| const delay = Math.min(2000 * attempt, 10000); | |
| if (attempt < MAX_ATTEMPTS) await new Promise((r) => setTimeout(r, delay)); | |
| } | |
| if (!detected) { | |
| console.warn(`[health-server] Privacy detection failed after ${MAX_ATTEMPTS} attempts — defaulting to ${SPACE_IS_PRIVATE ? "private" : "public"}. TIP: Set SPACE_PRIVACY=public in Space secrets to skip API detection.`); | |
| } else { | |
| console.log(`[health-server] Space privacy detected: ${SPACE_IS_PRIVATE ? "private" : "public"}`); | |
| } | |
| _privacyDetectionDone = true; | |
| _privacyDetectionResolve(); | |
| } | |
| if (_spacPrivacyEnv !== "public" && _spacPrivacyEnv !== "private") { | |
| detectSpacePrivacy(); | |
| setInterval(detectSpacePrivacy, 5 * 60 * 1000); | |
| } | |
| const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json"; | |
| const CLOUDFLARE_KEEPALIVE_STATUS_FILE = | |
| "/tmp/huggingmes-cloudflare-keepalive-status.json"; | |
| function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) { | |
| return new Promise((resolve) => { | |
| const socket = net.createConnection({ port, host }); | |
| const done = (ok) => { | |
| socket.removeAllListeners(); | |
| socket.destroy(); | |
| resolve(ok); | |
| }; | |
| socket.setTimeout(timeoutMs); | |
| socket.once("connect", () => done(true)); | |
| socket.once("timeout", () => done(false)); | |
| socket.once("error", () => done(false)); | |
| }); | |
| } | |
| function readJson(path, fallback = null) { | |
| try { | |
| if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8")); | |
| } catch {} | |
| return fallback; | |
| } | |
| function timingSafeEqualString(left, right) { | |
| if (!left || !right) return false; | |
| const leftBuffer = Buffer.from(left); | |
| const rightBuffer = Buffer.from(right); | |
| if (leftBuffer.length !== rightBuffer.length) return false; | |
| return crypto.timingSafeEqual(leftBuffer, rightBuffer); | |
| } | |
| function expectedSessionValue() { | |
| if (!API_SERVER_KEY) return ""; | |
| return crypto | |
| .createHmac("sha256", API_SERVER_KEY) | |
| .update("huggingmes-session-v1") | |
| .digest("hex"); | |
| } | |
| function parseCookies(req) { | |
| const header = req.headers.cookie || ""; | |
| const cookies = {}; | |
| for (const item of header.split(";")) { | |
| const separator = item.indexOf("="); | |
| if (separator < 0) continue; | |
| const name = item.slice(0, separator).trim(); | |
| const value = item.slice(separator + 1).trim(); | |
| if (!name) continue; | |
| try { | |
| cookies[name] = decodeURIComponent(value); | |
| } catch { | |
| cookies[name] = value; | |
| } | |
| } | |
| return cookies; | |
| } | |
| function isHttpsRequest(req) { | |
| return req.headers["x-forwarded-proto"] === "https"; | |
| } | |
| function buildSessionCookie(req) { | |
| const secure = isHttpsRequest(req) ? "; Secure" : ""; | |
| return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`; | |
| } | |
| function getBearerToken(req) { | |
| const value = req.headers.authorization || ""; | |
| const match = /^Bearer\s+(.+)$/i.exec(value); | |
| return match ? match[1] : ""; | |
| } | |
| function isAuthorized(req) { | |
| if (!API_SERVER_KEY) return true; | |
| return ( | |
| timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) || | |
| timingSafeEqualString( | |
| parseCookies(req)[SESSION_COOKIE], | |
| expectedSessionValue(), | |
| ) | |
| ); | |
| } | |
| function sanitizeNext(value) { | |
| if (!value || typeof value !== "string") return `${APP_BASE}/`; | |
| if (!value.startsWith("/") || value.startsWith("//")) return `${APP_BASE}/`; | |
| return value; | |
| } | |
| function loginUrl(nextPath) { | |
| return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`; | |
| } | |
| function renderLoginPage(nextPath, errorMessage = "") { | |
| const safeNext = sanitizeNext(nextPath); | |
| return `<!doctype html><html lang="en"><head> | |
| <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>HuggingMes</title> | |
| <style> | |
| :root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185} | |
| *{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px} | |
| .card{border:1px solid var(--line);background:var(--panel);border-radius:14px;padding:36px 32px;max-width:400px;width:100%;text-align:center} | |
| h1{margin:0 0 8px;font-size:1.4rem} | |
| .sub{color:var(--muted);font-size:.82rem;margin:0 0 24px} | |
| .row{display:flex;gap:8px;margin-top:16px} | |
| input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .15s} | |
| input:focus{border-color:#6366f1} | |
| button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s;white-space:nowrap} | |
| button:hover{opacity:.85} | |
| .err{color:var(--bad);font-size:.82rem;margin-top:10px} | |
| code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em} | |
| </style></head><body> | |
| <div class="card"> | |
| <h1>🪽 HuggingMes</h1> | |
| <p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p> | |
| <form method="post" action="${LOGIN_PATH}"> | |
| <input type="hidden" name="next" value="${escapeHtml(safeNext)}" /> | |
| <div class="row"> | |
| <input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password" required> | |
| <button type="submit">Unlock</button> | |
| </div> | |
| ${errorMessage ? `<p class="err">Invalid token — try again</p>` : ""} | |
| </form> | |
| </div> | |
| </body></html>`; | |
| } | |
| function escapeHtml(value) { | |
| return String(value) | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """); | |
| } | |
| function readRequestBody(req, limit = 64 * 1024) { | |
| return new Promise((resolve, reject) => { | |
| let body = ""; | |
| req.on("data", (chunk) => { | |
| body += chunk; | |
| if (body.length > limit) { | |
| reject(new Error("Request body is too large.")); | |
| req.destroy(); | |
| } | |
| }); | |
| req.on("end", () => resolve(body)); | |
| req.on("error", reject); | |
| }); | |
| } | |
| function requireAuth(req, res) { | |
| if (isAuthorized(req)) return true; | |
| const parsed = new URL(req.url, "http://localhost"); | |
| redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`)); | |
| return false; | |
| } | |
| function wantsHtml(req) { | |
| const accept = String(req.headers.accept || ""); | |
| return accept.includes("text/html"); | |
| } | |
| async function handleLogin(req, res, parsed) { | |
| const nextPath = sanitizeNext( | |
| parsed.searchParams.get("next") || `${APP_BASE}/`, | |
| ); | |
| if (!API_SERVER_KEY) { | |
| redirect(res, nextPath); | |
| return; | |
| } | |
| if (req.method === "GET") { | |
| res.writeHead(200, { | |
| "content-type": "text/html; charset=utf-8", | |
| "cache-control": "no-store", | |
| }); | |
| res.end(renderLoginPage(nextPath)); | |
| return; | |
| } | |
| if (req.method !== "POST") { | |
| res.writeHead(405, { allow: "GET, POST" }); | |
| res.end("Method not allowed"); | |
| return; | |
| } | |
| try { | |
| const body = await readRequestBody(req); | |
| const params = new URLSearchParams(body); | |
| const submittedToken = params.get("token") || ""; | |
| const submittedNext = sanitizeNext(params.get("next") || nextPath); | |
| if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) { | |
| res.writeHead(401, { | |
| "content-type": "text/html; charset=utf-8", | |
| "cache-control": "no-store", | |
| }); | |
| res.end( | |
| renderLoginPage( | |
| submittedNext, | |
| "That token did not match GATEWAY_TOKEN.", | |
| ), | |
| ); | |
| return; | |
| } | |
| res.writeHead(302, { | |
| location: submittedNext, | |
| "set-cookie": buildSessionCookie(req), | |
| "cache-control": "no-store", | |
| }); | |
| res.end(); | |
| } catch (error) { | |
| res.writeHead(400, { | |
| "content-type": "text/plain; charset=utf-8", | |
| "cache-control": "no-store", | |
| }); | |
| res.end(error.message || "Invalid login request."); | |
| } | |
| } | |
| function proxyRequest( | |
| req, | |
| res, | |
| targetPort, | |
| rewritePath = (path) => path, | |
| headerOverrides = {}, | |
| ) { | |
| const parsed = new URL(req.url, "http://localhost"); | |
| const targetPath = rewritePath(parsed.pathname) + parsed.search; | |
| const headers = { | |
| ...req.headers, | |
| ...headerOverrides, | |
| host: `${GATEWAY_HOST}:${targetPort}`, | |
| "x-forwarded-host": req.headers.host || "", | |
| "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https", | |
| }; | |
| const proxy = http.request( | |
| { | |
| hostname: GATEWAY_HOST, | |
| port: targetPort, | |
| method: req.method, | |
| path: targetPath, | |
| headers, | |
| }, | |
| (upstream) => { | |
| res.writeHead(upstream.statusCode || 502, upstream.headers); | |
| upstream.pipe(res); | |
| }, | |
| ); | |
| proxy.on("error", (error) => { | |
| res.writeHead(502, { "content-type": "application/json" }); | |
| res.end(JSON.stringify({ error: "proxy_error", message: error.message })); | |
| }); | |
| req.pipe(proxy); | |
| } | |
| function redirect(res, location, statusCode = 302) { | |
| res.writeHead(statusCode, { location }); | |
| res.end(); | |
| } | |
| function formatUptime(ms) { | |
| const total = Math.floor(ms / 1000); | |
| const days = Math.floor(total / 86400); | |
| const hours = Math.floor((total % 86400) / 3600); | |
| const minutes = Math.floor((total % 3600) / 60); | |
| if (days) return `${days}d ${hours}h ${minutes}m`; | |
| if (hours) return `${hours}h ${minutes}m`; | |
| return `${minutes}m`; | |
| } | |
| async function statusPayload() { | |
| const gateway = await canConnect(GATEWAY_PORT); | |
| const dashboard = await canConnect(DASHBOARD_PORT); | |
| const telegramWebhook = | |
| !!process.env.TELEGRAM_WEBHOOK_URL && | |
| (await canConnect(TELEGRAM_WEBHOOK_PORT)); | |
| const sync = readJson( | |
| SYNC_STATUS_FILE, | |
| process.env.HF_TOKEN | |
| ? { | |
| status: "configured", | |
| message: "Backup is enabled; waiting for the first sync.", | |
| } | |
| : { status: "disabled", message: "HF_TOKEN is not configured." }, | |
| ); | |
| return { | |
| ok: gateway, | |
| uptime: formatUptime(Date.now() - startTime), | |
| startedAt: new Date(startTime).toISOString(), | |
| gateway, | |
| dashboard, | |
| authConfigured: !!API_SERVER_KEY, | |
| ports: { | |
| public: PORT, | |
| gateway: GATEWAY_PORT, | |
| dashboard: DASHBOARD_PORT, | |
| telegramWebhook: TELEGRAM_WEBHOOK_PORT, | |
| }, | |
| telegram: { | |
| configured: !!process.env.TELEGRAM_BOT_TOKEN, | |
| webhook: !!process.env.TELEGRAM_WEBHOOK_URL, | |
| webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "", | |
| webhookListening: telegramWebhook, | |
| proxy: process.env.CLOUDFLARE_PROXY_URL || "", | |
| }, | |
| model: | |
| process.env.MODEL_FOR_CONFIG || | |
| process.env.HERMES_MODEL || | |
| process.env.LLM_MODEL || | |
| "", | |
| provider: | |
| process.env.PROVIDER_FOR_CONFIG || | |
| process.env.HERMES_INFERENCE_PROVIDER || | |
| "auto", | |
| backup: sync, | |
| keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null), | |
| }; | |
| } | |
| function renderPrivateRedirect(targetUrl) { | |
| const safeUrl = escapeHtml(targetUrl); | |
| return `<!doctype html><html lang="en"><head> | |
| <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <meta http-equiv="refresh" content="3;url=${safeUrl}"/> | |
| <title>HuggingMes — Private Space</title> | |
| <style> | |
| :root{color-scheme:dark} | |
| body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center; | |
| font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif; | |
| background:#08080f;color:#f6f4ff;text-align:center;padding:24px} | |
| .card{border:1px solid #26243a;background:#12111b;border-radius:14px;padding:36px 32px;max-width:440px} | |
| h1{margin:0 0 12px;font-size:1.5rem} | |
| p{color:#b8b3d7;line-height:1.6;margin:0 0 24px} | |
| .btn{display:inline-flex;align-items:center;justify-content:center; | |
| background:#fff;color:#000;font-weight:850;font-size:.95rem; | |
| border-radius:8px;padding:12px 28px;text-decoration:none;transition:opacity .15s} | |
| .btn:hover{opacity:.85} | |
| .sub{color:#7f7a9e;font-size:.78rem;margin-top:16px} | |
| </style></head><body> | |
| <div class="card"> | |
| <h1>🔒 Private Space</h1> | |
| <p>This HuggingFace Space is private. You need to be logged in to <strong>huggingface.co</strong> to access it.<br><br>Redirecting you now…</p> | |
| <a class="btn" href="${safeUrl}">Open on Hugging Face →</a> | |
| <div class="sub">Redirecting in 3 seconds…</div> | |
| </div> | |
| <script> | |
| // Only auto-redirect when NOT inside an iframe — navigating an iframe to | |
| // huggingface.co is blocked by X-Frame-Options and causes "refused to connect". | |
| const _inFrame = (() => { try { return window.top !== window.self; } catch { return true; } })(); | |
| if (!_inFrame) { | |
| setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100); | |
| } | |
| </script> | |
| </body></html>`; | |
| } | |
| function badge(label, state) { | |
| return `<span class="badge ${state ? "ok" : "off"}">${escapeHtml(label)}</span>`; | |
| } | |
| function toneBadge(label, tone = "neutral") { | |
| return `<span class="badge ${tone}">${escapeHtml(label)}</span>`; | |
| } | |
| function valueOrUnset(value, fallback = "Not set") { | |
| return value | |
| ? escapeHtml(value) | |
| : `<span class="muted">${escapeHtml(fallback)}</span>`; | |
| } | |
| function renderTile({ | |
| title, | |
| value, | |
| detail = "", | |
| tone = "neutral", | |
| meta = "", | |
| }) { | |
| return `<article class="tile ${tone}"> | |
| <div class="tile-head"> | |
| <span class="tile-title">${escapeHtml(title)}</span> | |
| <span class="tile-dot"></span> | |
| </div> | |
| <div class="tile-value">${value}</div> | |
| ${detail ? `<div class="tile-detail">${detail}</div>` : ""} | |
| ${meta ? `<div class="tile-meta">${meta}</div>` : ""} | |
| </article>`; | |
| } | |
| function renderDashboard(data) { | |
| const syncStatus = String(data.backup?.status || "unknown"); | |
| const syncTone = ["success", "restored", "synced", "configured"].includes( | |
| syncStatus, | |
| ) | |
| ? "ok" | |
| : syncStatus === "disabled" | |
| ? "warn" | |
| : "neutral"; | |
| const telegramTone = data.telegram.configured | |
| ? data.telegram.webhookListening || !data.telegram.webhook | |
| ? "ok" | |
| : "warn" | |
| : "warn"; | |
| const keepaliveConfigured = data.keepalive?.configured === true; | |
| const keepaliveStatus = String( | |
| data.keepalive?.status || | |
| (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"), | |
| ); | |
| const keepAliveTone = keepaliveConfigured | |
| ? "ok" | |
| : process.env.CLOUDFLARE_WORKERS_TOKEN | |
| ? "warn" | |
| : "neutral"; | |
| const telegramDetail = data.telegram.configured | |
| ? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}` | |
| : "Not configured"; | |
| const backupDetail = data.backup?.message | |
| ? escapeHtml(data.backup.message) | |
| : "No status yet"; | |
| const keepAliveDetail = keepaliveConfigured | |
| ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>` | |
| : keepaliveStatus === "error" && data.keepalive?.message | |
| ? escapeHtml(data.keepalive.message) | |
| : process.env.CLOUDFLARE_WORKERS_TOKEN | |
| ? "Worker pending or failed" | |
| : "Not configured"; | |
| const serviceOk = data.gateway && data.dashboard; | |
| const tiles = [ | |
| renderTile({ | |
| title: "Gateway", | |
| value: toneBadge( | |
| data.gateway ? "Online" : "Offline", | |
| data.gateway ? "ok" : "off", | |
| ), | |
| detail: data.gateway | |
| ? `API on port ${data.ports.gateway}` | |
| : `Unreachable`, | |
| tone: data.gateway ? "ok" : "off", | |
| meta: data.authConfigured ? "Protected" : "Unprotected", | |
| }), | |
| renderTile({ | |
| title: "Model", | |
| value: `<code>${valueOrUnset(data.model)}</code>`, | |
| detail: `Provider: ${valueOrUnset(data.provider || "auto")}`, | |
| tone: data.model ? "ok" : "warn", | |
| }), | |
| renderTile({ | |
| title: "Runtime", | |
| value: escapeHtml(data.uptime), | |
| detail: `Port ${data.ports.public}`, | |
| tone: "neutral", | |
| }), | |
| renderTile({ | |
| title: "Telegram", | |
| value: toneBadge( | |
| data.telegram.configured ? "Configured" : "Disabled", | |
| telegramTone, | |
| ), | |
| detail: telegramDetail, | |
| tone: telegramTone, | |
| }), | |
| renderTile({ | |
| title: "Backup", | |
| value: toneBadge(syncStatus.toUpperCase(), syncTone), | |
| detail: backupDetail, | |
| tone: syncTone, | |
| meta: data.backup?.timestamp | |
| ? `<span class="local-time" data-iso="${data.backup.timestamp}"></span>` | |
| : "", | |
| }), | |
| renderTile({ | |
| title: "Keep Awake", | |
| value: toneBadge( | |
| keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), | |
| keepAliveTone, | |
| ), | |
| detail: keepAliveDetail, | |
| tone: keepAliveTone, | |
| }), | |
| ].join(""); | |
| return `<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>HuggingMes</title> | |
| <style> | |
| :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --panel2:#151421; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#6557df; --accent2:#7c6cf2; } | |
| * { box-sizing:border-box; } | |
| body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; } | |
| main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; } | |
| header { text-align:center; margin-bottom:22px; } | |
| h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; } | |
| .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; } | |
| .hero-buttons { display:flex; gap:10px; margin:24px 0 20px; } | |
| .hero-action { display:flex; flex:1; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:#ffffff; color:#000000; text-decoration:none; font-weight:850; font-size:.98rem; transition:background 0.15s ease; } | |
| .hero-action:hover { background:#e5e5e5; } | |
| .hero-action.secondary { background:var(--panel); color:var(--text); border:1px solid var(--line); } | |
| .hero-action.secondary:hover { background:var(--panel2); } | |
| .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; } | |
| .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; } | |
| .tile.ok { border-color:rgba(34,197,94,.22); } | |
| .tile.warn { border-color:rgba(245,197,66,.24); } | |
| .tile.off { border-color:rgba(251,113,133,.28); } | |
| .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; } | |
| .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; } | |
| .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); } | |
| .tile.ok .tile-dot { background:var(--good); } | |
| .tile.warn .tile-dot { background:var(--warn); } | |
| .tile.off .tile-dot { background:var(--bad); } | |
| .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; } | |
| .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; } | |
| .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; } | |
| code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; } | |
| pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; background:#0d0d0d; border:1px solid var(--line); border-radius:7px; padding:10px; color:var(--soft); font-size:.82rem; line-height:1.45; } | |
| .row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; } | |
| .badge { display:inline-flex; align-items:center; width:max-content; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; } | |
| .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); } | |
| .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); } | |
| .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); } | |
| .badge.neutral { color:var(--soft); } | |
| .muted { color:var(--muted); } | |
| .button { display:inline-flex; align-items:center; justify-content:center; min-height:40px; padding:0 16px; border-radius:8px; color:#fff; background:var(--accent); text-decoration:none; font-weight:850; font-size:.9rem; } | |
| .button.secondary { color:var(--text); background:#242424; border:1px solid var(--line); } | |
| footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; } | |
| footer .live { color:var(--good); } | |
| @media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } } | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| <header> | |
| <h1>HuggingMes</h1> | |
| <div class="subtitle">Self-hosted - Hermes Agent</div> | |
| </header> | |
| <div class="hero-buttons"> | |
| <a class="hero-action" data-space-link="app" href="${APP_BASE}/">Open Hermes Agent →</a> | |
| <a class="hero-action secondary" data-space-link="terminal" href="/terminal/">💻 Open Terminal →</a> | |
| <a class="hero-action secondary" data-space-link="env-builder" href="/env-builder">⚙️ ENV Builder →</a> | |
| </div> | |
| <section class="overview"> | |
| ${tiles} | |
| </section> | |
| <footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: none;">@somratpro</a></footer> | |
| </main> | |
| <script> | |
| document.querySelectorAll('.local-time').forEach(el => { | |
| const date = new Date(el.getAttribute('data-iso')); | |
| if (!isNaN(date)) { | |
| el.textContent = 'At ' + date.toLocaleTimeString(); | |
| } | |
| }); | |
| const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })(); | |
| const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname); | |
| const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)}; | |
| // Server-side value may be stale if privacy detection raced — syncPrivacy() corrects it. | |
| let SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)}; | |
| function applyLinkTargets() { | |
| const openInNewTab = !SPACE_IS_PRIVATE && (inEmbeddedApp || isDirectHfSpaceHost); | |
| document.querySelectorAll('a[data-space-link]').forEach((a) => { | |
| if (openInNewTab) { | |
| a.setAttribute('target', '_blank'); | |
| a.setAttribute('rel', 'noopener noreferrer'); | |
| } else { | |
| a.removeAttribute('target'); | |
| a.removeAttribute('rel'); | |
| } | |
| }); | |
| } | |
| applyLinkTargets(); | |
| function syncPrivacy() { | |
| return fetch('/api/is-private', { cache: 'no-store' }) | |
| .then(r => r.json()) | |
| .then(d => { | |
| if (d.isPrivate !== SPACE_IS_PRIVATE) { | |
| SPACE_IS_PRIVATE = d.isPrivate; | |
| applyLinkTargets(); | |
| } | |
| return d.isPrivate; | |
| }) | |
| .catch(() => SPACE_IS_PRIVATE); | |
| } | |
| if (isDirectHfSpaceHost) { | |
| syncPrivacy().then(isPrivate => { | |
| if (isPrivate) { | |
| setTimeout(syncPrivacy, 8000); | |
| setTimeout(syncPrivacy, 16000); | |
| } | |
| }); | |
| } | |
| // Private redirect — only when NOT in iframe (huggingface.co has X-Frame-Options: DENY) | |
| if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) { | |
| const notice = document.createElement('div'); | |
| notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999'; | |
| notice.innerHTML = '<span style="font-size:1.1rem">🔒 Private Space — Redirecting…</span><a href="' + HF_SPACE_URL + '" style="color:#a5b4fc;font-size:.85rem">Click here if not redirected</a>'; | |
| document.body.appendChild(notice); | |
| setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300); | |
| } | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| const server = http.createServer(async (req, res) => { | |
| const parsed = new URL(req.url, "http://localhost"); | |
| const path = parsed.pathname; | |
| // Lightweight endpoint for client-side privacy fallback. | |
| // Called by dashboard JS to correct stale server-rendered SPACE_IS_PRIVATE value. | |
| // No auth required — not sensitive. | |
| if (path === "/api/is-private") { | |
| if (!_privacyDetectionDone) await privacyDetectionReady; | |
| res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); | |
| return res.end(JSON.stringify({ isPrivate: SPACE_IS_PRIVATE })); | |
| } | |
| if (path === LOGIN_PATH) { | |
| await handleLogin(req, res, parsed); | |
| return; | |
| } | |
| // ── Private Space Guard (server-side) ── | |
| // Intercepts browser HTML requests from raw .hf.space hosts when the Space is private. | |
| // /health and /status are always exempt so uptime monitors keep working. | |
| const isHtmlReq = (req.headers.accept || "").includes("text/html"); | |
| // RACE CONDITION FIX: await privacy detection before computing redirect logic. | |
| // Without this, the fail-secure default (SPACE_IS_PRIVATE=true when SPACE_ID is set) | |
| // causes public spaces to redirect during the brief window before API detection completes. | |
| if (isHtmlReq && !_privacyDetectionDone) { | |
| await Promise.race([ | |
| privacyDetectionReady, | |
| new Promise((r) => setTimeout(r, 1500)), | |
| ]); | |
| } | |
| // In-app navigation from same origin or HF App iframe — skip private redirect. | |
| const referer = req.headers.referer || req.headers.referrer || ""; | |
| const isSameOriginNav = !!(referer && typeof req.headers.host === "string" && | |
| referer.startsWith(`https://${req.headers.host}`)); | |
| const isFromHFApp = !!(referer && ( | |
| referer.startsWith("https://huggingface.co") || | |
| referer.startsWith("https://hf.co") | |
| )); | |
| const isDirectHfSpaceReq = SPACE_IS_PRIVATE && | |
| HF_SPACE_URL && | |
| isHtmlReq && | |
| !isSameOriginNav && | |
| !isFromHFApp && | |
| typeof req.headers.host === "string" && | |
| req.headers.host.endsWith(".hf.space"); | |
| if (path === "/hf-redirect" || path === "/hf-redirect/") { | |
| if (HF_SPACE_URL) { | |
| res.writeHead(302, { location: HF_SPACE_URL, "cache-control": "no-store" }); | |
| return res.end(); | |
| } | |
| res.writeHead(404, { "content-type": "text/plain" }); | |
| return res.end("SPACE_ID not configured."); | |
| } | |
| if (path === "/health" || path === `${APP_BASE}/health`) { | |
| const data = await statusPayload(); | |
| // Always 200 — health server up means the app is running. | |
| // Gateway readiness is in the JSON body (gateway: true/false). | |
| // Returning 503 here caused Docker HEALTHCHECK to fail during gateway | |
| // startup, keeping HF Space stuck in RUNNING_APP_STARTING indefinitely. | |
| res.writeHead(200, { "content-type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| ok: data.ok, | |
| gateway: data.gateway, | |
| uptime: data.uptime, | |
| }), | |
| ); | |
| return; | |
| } | |
| if (path === "/status" || path === `${APP_BASE}/status`) { | |
| const data = await statusPayload(); | |
| res.writeHead(200, { "content-type": "application/json" }); | |
| res.end(JSON.stringify(data, null, 2)); | |
| return; | |
| } | |
| if (path === "/env-builder" || path === "/env-builder/") { | |
| if (!requireAuth(req, res)) return; | |
| try { | |
| const html = fs.readFileSync(require("path").join(__dirname, "env-builder.html"), "utf8"); | |
| res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); | |
| res.end(html); | |
| } catch (e) { | |
| res.writeHead(404, { "content-type": "text/plain" }); | |
| res.end("env-builder.html not found"); | |
| } | |
| return; | |
| } | |
| if (path === "/env-builder.js") { | |
| if (!requireAuth(req, res)) return; | |
| try { | |
| const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8"); | |
| res.writeHead(200, { "content-type": "application/javascript; charset=utf-8" }); | |
| res.end(js); | |
| } catch (e) { | |
| res.writeHead(404, { "content-type": "text/plain" }); | |
| res.end("env-builder.js not found"); | |
| } | |
| return; | |
| } | |
| if (path === "/") { | |
| if (isDirectHfSpaceReq) { | |
| res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); | |
| return res.end(renderPrivateRedirect(HF_SPACE_URL)); | |
| } | |
| const data = await statusPayload(); | |
| res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); | |
| res.end(renderDashboard(data)); | |
| return; | |
| } | |
| if (path === "/dashboard" || path === "/dashboard/") { | |
| redirect(res, `${APP_BASE}/${parsed.search}`); | |
| return; | |
| } | |
| if (path === "/telegram" || path.startsWith("/telegram/")) { | |
| proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT); | |
| return; | |
| } | |
| if (path === APP_BASE || path.startsWith(`${APP_BASE}/`)) { | |
| if (!requireAuth(req, res)) return; | |
| proxyRequest( | |
| req, | |
| res, | |
| DASHBOARD_PORT, | |
| (p) => p.replace(/^\/app/, "") || "/", | |
| ); | |
| return; | |
| } | |
| if ( | |
| path === "/favicon.ico" || | |
| path.startsWith("/assets/") || | |
| path.startsWith("/api/") || | |
| path.startsWith("/dashboard-plugins/") || | |
| path.startsWith("/ds-assets/") | |
| ) { | |
| if (!requireAuth(req, res)) return; | |
| proxyRequest(req, res, DASHBOARD_PORT); | |
| return; | |
| } | |
| if ( | |
| [ | |
| "/analytics", | |
| "/chat", | |
| "/config", | |
| "/cron", | |
| "/docs", | |
| "/env", | |
| "/logs", | |
| "/models", | |
| "/plugins", | |
| "/profiles", | |
| "/sessions", | |
| "/skills", | |
| ].some((route) => path === route || path.startsWith(`${route}/`)) | |
| ) { | |
| redirect(res, `${APP_BASE}${path}${parsed.search}`); | |
| return; | |
| } | |
| if (path === "/v1" || path.startsWith("/v1/")) { | |
| if (!isAuthorized(req)) { | |
| if (wantsHtml(req)) { | |
| redirect(res, loginUrl(`${path}${parsed.search}`)); | |
| return; | |
| } | |
| res.writeHead(401, { | |
| "content-type": "application/json", | |
| "cache-control": "no-store", | |
| }); | |
| res.end( | |
| JSON.stringify({ | |
| error: "unauthorized", | |
| message: "Use Authorization: Bearer <GATEWAY_TOKEN>.", | |
| }), | |
| ); | |
| return; | |
| } | |
| const upstreamHeaders = | |
| getBearerToken(req) || !API_SERVER_KEY | |
| ? {} | |
| : { authorization: `Bearer ${API_SERVER_KEY}` }; | |
| proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders); | |
| return; | |
| } | |
| if (path === TERMINAL_BASE || path.startsWith(`${TERMINAL_BASE}/`)) { | |
| if (!requireAuth(req, res)) return; | |
| canConnect(JUPYTER_PORT).then((up) => { | |
| if (!up) { | |
| res.writeHead(503, { "content-type": "text/plain; charset=utf-8" }); | |
| res.end("JupyterLab is not running. Set DEV_MODE=true and JUPYTER_TOKEN in Space secrets to enable /terminal/."); | |
| return; | |
| } | |
| // Inject the Jupyter token so JupyterLab skips its own login screen. | |
| // User already authenticated via GATEWAY_TOKEN — no second prompt needed. | |
| const jToken = process.env.JUPYTER_TOKEN || ""; | |
| const overrides = jToken ? { authorization: `token ${jToken}` } : {}; | |
| proxyRequest(req, res, JUPYTER_PORT, (p) => p, overrides); | |
| }); | |
| return; | |
| } | |
| res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); | |
| res.end("Not found"); | |
| }); | |
| // ── WebSocket upgrade (JupyterLab terminals + kernels need this) ── | |
| server.on("upgrade", (req, socket, head) => { | |
| const { pathname } = new URL(req.url, "http://localhost"); | |
| const isJupyter = pathname === TERMINAL_BASE || pathname.startsWith(`${TERMINAL_BASE}/`); | |
| const targetPort = isJupyter ? JUPYTER_PORT : GATEWAY_PORT; | |
| const ps = net.createConnection(targetPort, GATEWAY_HOST, () => { | |
| ps.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`); | |
| ps.write(`Host: ${GATEWAY_HOST}:${targetPort}\r\n`); | |
| ps.write(`X-Forwarded-Host: ${req.headers.host || ""}\r\n`); | |
| ps.write("X-Forwarded-Proto: https\r\n"); | |
| for (let i = 0; i < req.rawHeaders.length; i += 2) { | |
| const lower = req.rawHeaders[i].toLowerCase(); | |
| if (["host", "x-forwarded-host", "x-forwarded-proto"].includes(lower)) continue; | |
| ps.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`); | |
| } | |
| ps.write("\r\n"); | |
| if (head && head.length) ps.write(head); | |
| ps.pipe(socket).pipe(ps); | |
| }); | |
| ps.on("error", () => socket.destroy()); | |
| ps.on("close", () => socket.destroy()); | |
| socket.on("error", () => ps.destroy()); | |
| socket.on("close", () => ps.destroy()); | |
| }); | |
| server.timeout = 0; | |
| server.keepAliveTimeout = 65000; | |
| server.listen(PORT, "0.0.0.0", () => { | |
| console.log(`HuggingMes dashboard listening on 0.0.0.0:${PORT}`); | |
| }); | |