Spaces:
Running
Running
Use token-only login for app route
Browse files- README.md +3 -3
- health-server.js +200 -21
README.md
CHANGED
|
@@ -58,9 +58,9 @@ GATEWAY_TOKEN=your-strong-password-or-token
|
|
| 58 |
|
| 59 |
Then:
|
| 60 |
|
| 61 |
-
- Opening `/app/`
|
| 62 |
-
-
|
| 63 |
-
-
|
| 64 |
- API routes under `/v1/*` accept `Authorization: Bearer <GATEWAY_TOKEN>`.
|
| 65 |
|
| 66 |
## LLM Providers
|
|
|
|
| 58 |
|
| 59 |
Then:
|
| 60 |
|
| 61 |
+
- Opening `/app/` shows a HuggingMess login page with one field.
|
| 62 |
+
- Paste `GATEWAY_TOKEN` into that field.
|
| 63 |
+
- HuggingMess stores an HTTP-only session cookie for the dashboard routes.
|
| 64 |
- API routes under `/v1/*` accept `Authorization: Bearer <GATEWAY_TOKEN>`.
|
| 65 |
|
| 66 |
## LLM Providers
|
health-server.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
const http = require("http");
|
| 4 |
const fs = require("fs");
|
| 5 |
const net = require("net");
|
|
|
|
| 6 |
|
| 7 |
const PORT = Number(process.env.PORT || 7861);
|
| 8 |
const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
|
|
@@ -12,7 +13,9 @@ const GATEWAY_HOST = "127.0.0.1";
|
|
| 12 |
const startTime = Date.now();
|
| 13 |
const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
|
| 14 |
const APP_BASE = "/app";
|
| 15 |
-
const
|
|
|
|
|
|
|
| 16 |
|
| 17 |
const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
|
| 18 |
const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingmess-uptimerobot-status.json";
|
|
@@ -39,39 +42,206 @@ function readJson(path, fallback = null) {
|
|
| 39 |
return fallback;
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
function getBearerToken(req) {
|
| 43 |
const value = req.headers.authorization || "";
|
| 44 |
const match = /^Bearer\s+(.+)$/i.exec(value);
|
| 45 |
return match ? match[1] : "";
|
| 46 |
}
|
| 47 |
|
| 48 |
-
function getBasicPassword(req) {
|
| 49 |
-
const value = req.headers.authorization || "";
|
| 50 |
-
const match = /^Basic\s+(.+)$/i.exec(value);
|
| 51 |
-
if (!match) return "";
|
| 52 |
-
try {
|
| 53 |
-
const decoded = Buffer.from(match[1], "base64").toString("utf8");
|
| 54 |
-
const separator = decoded.indexOf(":");
|
| 55 |
-
return separator >= 0 ? decoded.slice(separator + 1) : "";
|
| 56 |
-
} catch {
|
| 57 |
-
return "";
|
| 58 |
-
}
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
function isAuthorized(req) {
|
| 62 |
if (!API_SERVER_KEY) return true;
|
| 63 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
function requireAuth(req, res) {
|
| 67 |
if (isAuthorized(req)) return true;
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
"cache-control": "no-store",
|
| 72 |
});
|
| 73 |
-
res.end(
|
| 74 |
-
return false;
|
| 75 |
}
|
| 76 |
|
| 77 |
function proxyRequest(req, res, targetPort, rewritePath = (path) => path) {
|
|
@@ -220,6 +390,16 @@ const server = http.createServer(async (req, res) => {
|
|
| 220 |
const parsed = new URL(req.url, "http://localhost");
|
| 221 |
const path = parsed.pathname;
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
if (path === "/health" || path === `${APP_BASE}/health`) {
|
| 224 |
const data = await statusPayload();
|
| 225 |
res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
|
|
@@ -293,7 +473,6 @@ const server = http.createServer(async (req, res) => {
|
|
| 293 |
if (!isAuthorized(req)) {
|
| 294 |
res.writeHead(401, {
|
| 295 |
"content-type": "application/json",
|
| 296 |
-
"www-authenticate": `Bearer realm="${AUTH_REALM}"`,
|
| 297 |
"cache-control": "no-store",
|
| 298 |
});
|
| 299 |
res.end(JSON.stringify({ error: "unauthorized", message: "Use Authorization: Bearer <GATEWAY_TOKEN>." }));
|
|
|
|
| 3 |
const http = require("http");
|
| 4 |
const fs = require("fs");
|
| 5 |
const net = require("net");
|
| 6 |
+
const crypto = require("crypto");
|
| 7 |
|
| 8 |
const PORT = Number(process.env.PORT || 7861);
|
| 9 |
const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
|
|
|
|
| 13 |
const startTime = Date.now();
|
| 14 |
const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
|
| 15 |
const APP_BASE = "/app";
|
| 16 |
+
const LOGIN_PATH = "/login";
|
| 17 |
+
const LOGOUT_PATH = "/logout";
|
| 18 |
+
const SESSION_COOKIE = "huggingmess_session";
|
| 19 |
|
| 20 |
const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
|
| 21 |
const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingmess-uptimerobot-status.json";
|
|
|
|
| 42 |
return fallback;
|
| 43 |
}
|
| 44 |
|
| 45 |
+
function timingSafeEqualString(left, right) {
|
| 46 |
+
if (!left || !right) return false;
|
| 47 |
+
const leftBuffer = Buffer.from(left);
|
| 48 |
+
const rightBuffer = Buffer.from(right);
|
| 49 |
+
if (leftBuffer.length !== rightBuffer.length) return false;
|
| 50 |
+
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function expectedSessionValue() {
|
| 54 |
+
if (!API_SERVER_KEY) return "";
|
| 55 |
+
return crypto
|
| 56 |
+
.createHmac("sha256", API_SERVER_KEY)
|
| 57 |
+
.update("huggingmess-session-v1")
|
| 58 |
+
.digest("hex");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function parseCookies(req) {
|
| 62 |
+
const header = req.headers.cookie || "";
|
| 63 |
+
const cookies = {};
|
| 64 |
+
for (const item of header.split(";")) {
|
| 65 |
+
const separator = item.indexOf("=");
|
| 66 |
+
if (separator < 0) continue;
|
| 67 |
+
const name = item.slice(0, separator).trim();
|
| 68 |
+
const value = item.slice(separator + 1).trim();
|
| 69 |
+
if (!name) continue;
|
| 70 |
+
try {
|
| 71 |
+
cookies[name] = decodeURIComponent(value);
|
| 72 |
+
} catch {
|
| 73 |
+
cookies[name] = value;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
return cookies;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function isHttpsRequest(req) {
|
| 80 |
+
return req.headers["x-forwarded-proto"] === "https";
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function buildSessionCookie(req) {
|
| 84 |
+
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
| 85 |
+
return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function buildClearSessionCookie(req) {
|
| 89 |
+
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
| 90 |
+
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
function getBearerToken(req) {
|
| 94 |
const value = req.headers.authorization || "";
|
| 95 |
const match = /^Bearer\s+(.+)$/i.exec(value);
|
| 96 |
return match ? match[1] : "";
|
| 97 |
}
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
function isAuthorized(req) {
|
| 100 |
if (!API_SERVER_KEY) return true;
|
| 101 |
+
return (
|
| 102 |
+
timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) ||
|
| 103 |
+
timingSafeEqualString(parseCookies(req)[SESSION_COOKIE], expectedSessionValue())
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function sanitizeNext(value) {
|
| 108 |
+
if (!value || typeof value !== "string") return `${APP_BASE}/`;
|
| 109 |
+
if (!value.startsWith("/") || value.startsWith("//")) return `${APP_BASE}/`;
|
| 110 |
+
return value;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function loginUrl(nextPath) {
|
| 114 |
+
return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function renderLoginPage(nextPath, errorMessage = "") {
|
| 118 |
+
const safeNext = sanitizeNext(nextPath);
|
| 119 |
+
const errorHtml = errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : "";
|
| 120 |
+
return `<!doctype html>
|
| 121 |
+
<html lang="en">
|
| 122 |
+
<head>
|
| 123 |
+
<meta charset="utf-8" />
|
| 124 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 125 |
+
<title>HuggingMess Login</title>
|
| 126 |
+
<style>
|
| 127 |
+
:root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --bad:#ef4444; --accent:#38bdf8; }
|
| 128 |
+
* { box-sizing:border-box; }
|
| 129 |
+
body { margin:0; min-height:100vh; display:grid; place-items:center; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); padding:20px; }
|
| 130 |
+
main { width:min(440px, 100%); border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:28px; }
|
| 131 |
+
h1 { margin:0 0 8px; font-size:1.55rem; letter-spacing:0; }
|
| 132 |
+
p { margin:0 0 22px; color:var(--muted); line-height:1.5; }
|
| 133 |
+
label { display:block; color:var(--muted); font-size:.82rem; margin-bottom:8px; }
|
| 134 |
+
input { width:100%; min-height:46px; border:1px solid var(--line); border-radius:7px; background:#0b0f18; color:var(--text); padding:0 12px; font:inherit; }
|
| 135 |
+
button { width:100%; min-height:44px; margin-top:16px; border:0; border-radius:7px; color:#07111f; background:var(--accent); font:inherit; font-weight:750; cursor:pointer; }
|
| 136 |
+
.error { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:#fecaca; border-radius:7px; padding:10px 12px; margin-bottom:16px; }
|
| 137 |
+
</style>
|
| 138 |
+
</head>
|
| 139 |
+
<body>
|
| 140 |
+
<main>
|
| 141 |
+
<h1>Open HuggingMess</h1>
|
| 142 |
+
<p>Enter the <code>GATEWAY_TOKEN</code> from your Space secrets.</p>
|
| 143 |
+
${errorHtml}
|
| 144 |
+
<form method="post" action="${LOGIN_PATH}">
|
| 145 |
+
<input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
|
| 146 |
+
<label for="token">GATEWAY_TOKEN</label>
|
| 147 |
+
<input id="token" name="token" type="password" autocomplete="current-password" autofocus required />
|
| 148 |
+
<button type="submit">Continue</button>
|
| 149 |
+
</form>
|
| 150 |
+
</main>
|
| 151 |
+
</body>
|
| 152 |
+
</html>`;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function escapeHtml(value) {
|
| 156 |
+
return String(value)
|
| 157 |
+
.replace(/&/g, "&")
|
| 158 |
+
.replace(/</g, "<")
|
| 159 |
+
.replace(/>/g, ">")
|
| 160 |
+
.replace(/"/g, """);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function readRequestBody(req, limit = 64 * 1024) {
|
| 164 |
+
return new Promise((resolve, reject) => {
|
| 165 |
+
let body = "";
|
| 166 |
+
req.on("data", (chunk) => {
|
| 167 |
+
body += chunk;
|
| 168 |
+
if (body.length > limit) {
|
| 169 |
+
reject(new Error("Request body is too large."));
|
| 170 |
+
req.destroy();
|
| 171 |
+
}
|
| 172 |
+
});
|
| 173 |
+
req.on("end", () => resolve(body));
|
| 174 |
+
req.on("error", reject);
|
| 175 |
+
});
|
| 176 |
}
|
| 177 |
|
| 178 |
function requireAuth(req, res) {
|
| 179 |
if (isAuthorized(req)) return true;
|
| 180 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 181 |
+
redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`));
|
| 182 |
+
return false;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
async function handleLogin(req, res, parsed) {
|
| 186 |
+
const nextPath = sanitizeNext(parsed.searchParams.get("next") || `${APP_BASE}/`);
|
| 187 |
+
|
| 188 |
+
if (!API_SERVER_KEY) {
|
| 189 |
+
redirect(res, nextPath);
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if (req.method === "GET") {
|
| 194 |
+
res.writeHead(200, {
|
| 195 |
+
"content-type": "text/html; charset=utf-8",
|
| 196 |
+
"cache-control": "no-store",
|
| 197 |
+
});
|
| 198 |
+
res.end(renderLoginPage(nextPath));
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if (req.method !== "POST") {
|
| 203 |
+
res.writeHead(405, { allow: "GET, POST" });
|
| 204 |
+
res.end("Method not allowed");
|
| 205 |
+
return;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
try {
|
| 209 |
+
const body = await readRequestBody(req);
|
| 210 |
+
const params = new URLSearchParams(body);
|
| 211 |
+
const submittedToken = params.get("token") || "";
|
| 212 |
+
const submittedNext = sanitizeNext(params.get("next") || nextPath);
|
| 213 |
+
|
| 214 |
+
if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) {
|
| 215 |
+
res.writeHead(401, {
|
| 216 |
+
"content-type": "text/html; charset=utf-8",
|
| 217 |
+
"cache-control": "no-store",
|
| 218 |
+
});
|
| 219 |
+
res.end(renderLoginPage(submittedNext, "That token did not match GATEWAY_TOKEN."));
|
| 220 |
+
return;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
res.writeHead(302, {
|
| 224 |
+
location: submittedNext,
|
| 225 |
+
"set-cookie": buildSessionCookie(req),
|
| 226 |
+
"cache-control": "no-store",
|
| 227 |
+
});
|
| 228 |
+
res.end();
|
| 229 |
+
} catch (error) {
|
| 230 |
+
res.writeHead(400, {
|
| 231 |
+
"content-type": "text/plain; charset=utf-8",
|
| 232 |
+
"cache-control": "no-store",
|
| 233 |
+
});
|
| 234 |
+
res.end(error.message || "Invalid login request.");
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function handleLogout(req, res) {
|
| 239 |
+
res.writeHead(302, {
|
| 240 |
+
location: LOGIN_PATH,
|
| 241 |
+
"set-cookie": buildClearSessionCookie(req),
|
| 242 |
"cache-control": "no-store",
|
| 243 |
});
|
| 244 |
+
res.end();
|
|
|
|
| 245 |
}
|
| 246 |
|
| 247 |
function proxyRequest(req, res, targetPort, rewritePath = (path) => path) {
|
|
|
|
| 390 |
const parsed = new URL(req.url, "http://localhost");
|
| 391 |
const path = parsed.pathname;
|
| 392 |
|
| 393 |
+
if (path === LOGIN_PATH) {
|
| 394 |
+
await handleLogin(req, res, parsed);
|
| 395 |
+
return;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
if (path === LOGOUT_PATH) {
|
| 399 |
+
handleLogout(req, res);
|
| 400 |
+
return;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
if (path === "/health" || path === `${APP_BASE}/health`) {
|
| 404 |
const data = await statusPayload();
|
| 405 |
res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
|
|
|
|
| 473 |
if (!isAuthorized(req)) {
|
| 474 |
res.writeHead(401, {
|
| 475 |
"content-type": "application/json",
|
|
|
|
| 476 |
"cache-control": "no-store",
|
| 477 |
});
|
| 478 |
res.end(JSON.stringify({ error: "unauthorized", message: "Use Authorization: Bearer <GATEWAY_TOKEN>." }));
|