Spaces:
Running
fix(health-server): resolve iframe redirect causing 'refused to connect' (#11)
Browse files- health-server.js: guard private-space redirect behind iframe detection
(window.top !== window.self) — fixes X-Frame-Options DENY on huggingface.co
blocking iframe navigation
- health-server.js: add privacyDetectionReady Promise to eliminate race
condition where fail-secure default triggered false redirects on public spaces
- health-server.js: add /api/is-private endpoint + client-side syncPrivacy()
- health-server.js: add SPACE_PRIVACY env var override to skip HF API detection
- health-server.js: add isFromHFApp referer guard to skip redirect inside HF UI
- cloudflare-proxy-setup.py: remove AI provider domains from DEFAULT_ALLOWED
to prevent routing API keys through worker without explicit opt-in
- env-builder.js: add tag badge system (critical/credential/feature/optional/
advanced/build) with per-field tags; add SPACE_PRIVACY field
- env-builder.js: split bundle generation — explicit generateBundle() button
vs auto refresh() for summary/counts only
- env-builder.html: add tag legend, ⚡ Required button, # Generate Bundle
button, toolbar hint; fix body overflow for proper scroll
- Dockerfile: add dbus/dbus-x11 for Chromium stability; fix libasound2
fallback for Debian bookworm rename
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile +2 -0
- cloudflare-proxy-setup.py +13 -1
- env-builder.html +88 -12
- env-builder.js +65 -43
- health-server.js +163 -47
|
@@ -14,6 +14,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 14 |
python3-venv \
|
| 15 |
python3-pip \
|
| 16 |
chromium \
|
|
|
|
|
|
|
| 17 |
libnss3 \
|
| 18 |
libatk1.0-0 \
|
| 19 |
libatk-bridge2.0-0 \
|
|
|
|
| 14 |
python3-venv \
|
| 15 |
python3-pip \
|
| 16 |
chromium \
|
| 17 |
+
dbus \
|
| 18 |
+
dbus-x11 \
|
| 19 |
libnss3 \
|
| 20 |
libatk1.0-0 \
|
| 21 |
libatk-bridge2.0-0 \
|
|
@@ -16,6 +16,8 @@ API_BASE = "https://api.cloudflare.com/client/v4"
|
|
| 16 |
ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
|
| 17 |
ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
|
| 18 |
DEFAULT_ALLOWED = [
|
|
|
|
|
|
|
| 19 |
"api.telegram.org",
|
| 20 |
"discord.com",
|
| 21 |
"discordapp.com",
|
|
@@ -24,13 +26,23 @@ DEFAULT_ALLOWED = [
|
|
| 24 |
"slack.com",
|
| 25 |
"api.slack.com",
|
| 26 |
"web.whatsapp.com",
|
|
|
|
| 27 |
"graph.facebook.com",
|
| 28 |
"graph.instagram.com",
|
| 29 |
-
"api.
|
|
|
|
|
|
|
| 30 |
"googleapis.com",
|
| 31 |
"google.com",
|
| 32 |
"googleusercontent.com",
|
| 33 |
"gstatic.com",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
]
|
| 35 |
|
| 36 |
|
|
|
|
| 16 |
ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
|
| 17 |
ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
|
| 18 |
DEFAULT_ALLOWED = [
|
| 19 |
+
# Messaging & social — primary use-case for Cloudflare proxy on HF Spaces
|
| 20 |
+
# (geo-restrictions on Telegram, Discord, WhatsApp, etc.)
|
| 21 |
"api.telegram.org",
|
| 22 |
"discord.com",
|
| 23 |
"discordapp.com",
|
|
|
|
| 26 |
"slack.com",
|
| 27 |
"api.slack.com",
|
| 28 |
"web.whatsapp.com",
|
| 29 |
+
# Social — confirmed/likely blocked by HF firewall
|
| 30 |
"graph.facebook.com",
|
| 31 |
"graph.instagram.com",
|
| 32 |
+
"api.twitter.com",
|
| 33 |
+
"api.x.com",
|
| 34 |
+
# Google
|
| 35 |
"googleapis.com",
|
| 36 |
"google.com",
|
| 37 |
"googleusercontent.com",
|
| 38 |
"gstatic.com",
|
| 39 |
+
# Email HTTP APIs (SMTP ports are blocked)
|
| 40 |
+
"api.resend.com",
|
| 41 |
+
"api.sendgrid.com",
|
| 42 |
+
# NOTE: AI-provider domains (api.openai.com, api.anthropic.com, etc.) are
|
| 43 |
+
# intentionally NOT included here. Proxying AI calls routes API keys through
|
| 44 |
+
# the Cloudflare Worker without explicit opt-in. Users who need AI API calls
|
| 45 |
+
# proxied can add specific domains via CLOUDFLARE_PROXY_DOMAINS env var.
|
| 46 |
]
|
| 47 |
|
| 48 |
|
|
@@ -36,16 +36,16 @@
|
|
| 36 |
--panel-w: 340px;
|
| 37 |
}
|
| 38 |
|
| 39 |
-
html { scroll-behavior: smooth; }
|
| 40 |
|
| 41 |
body {
|
| 42 |
font-family: var(--sans);
|
| 43 |
background: var(--bg);
|
| 44 |
color: var(--text);
|
| 45 |
-
|
|
|
|
| 46 |
display: flex;
|
| 47 |
flex-direction: column;
|
| 48 |
-
overflow-x: hidden;
|
| 49 |
}
|
| 50 |
|
| 51 |
.topbar {
|
|
@@ -396,17 +396,64 @@ body {
|
|
| 396 |
border-radius: 20px;
|
| 397 |
}
|
| 398 |
|
| 399 |
-
|
| 400 |
-
background: rgba(240,
|
| 401 |
-
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
}
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
|
| 411 |
.card-input { position: relative; }
|
| 412 |
|
|
@@ -696,6 +743,7 @@ body {
|
|
| 696 |
<main class="main">
|
| 697 |
|
| 698 |
<div class="toolbar">
|
|
|
|
| 699 |
<div class="search-wrap">
|
| 700 |
<span class="search-icon">⌕</span>
|
| 701 |
<input id="search" type="text" placeholder="Search variables…" autocomplete="off" spellcheck="false">
|
|
@@ -703,6 +751,7 @@ body {
|
|
| 703 |
|
| 704 |
<div class="tb-sep"></div>
|
| 705 |
|
|
|
|
| 706 |
<button id="selectCommon" class="btn">★ Common</button>
|
| 707 |
<button id="selectVisible" class="btn">☑ Visible</button>
|
| 708 |
<button id="clearAll" class="btn btn-ghost">✕ Clear</button>
|
|
@@ -711,6 +760,30 @@ body {
|
|
| 711 |
<div class="content-wrap">
|
| 712 |
|
| 713 |
<div class="sections-scroll">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
<div id="sections"></div>
|
| 715 |
|
| 716 |
<div id="customSec" class="sec" data-section="Custom Env">
|
|
@@ -741,8 +814,11 @@ body {
|
|
| 741 |
<span class="pblock-title">📦 Bundle Output</span>
|
| 742 |
</div>
|
| 743 |
<div class="pblock-body">
|
| 744 |
-
<textarea id="bundleOut" placeholder="
|
| 745 |
<input type="text" id="envLineOut" placeholder="HUGGINGMES_ENV_BUNDLE=…" readonly spellcheck="false">
|
|
|
|
|
|
|
|
|
|
| 746 |
<div class="row-btns">
|
| 747 |
<button id="copyBundle" class="btn btn-amber">⎘ Bundle</button>
|
| 748 |
<button id="copyEnvLine" class="btn">⎘ Env Line</button>
|
|
|
|
| 36 |
--panel-w: 340px;
|
| 37 |
}
|
| 38 |
|
| 39 |
+
html { scroll-behavior: smooth; height: 100%; }
|
| 40 |
|
| 41 |
body {
|
| 42 |
font-family: var(--sans);
|
| 43 |
background: var(--bg);
|
| 44 |
color: var(--text);
|
| 45 |
+
height: 100vh;
|
| 46 |
+
overflow: hidden;
|
| 47 |
display: flex;
|
| 48 |
flex-direction: column;
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
.topbar {
|
|
|
|
| 396 |
border-radius: 20px;
|
| 397 |
}
|
| 398 |
|
| 399 |
+
/* Tag badge styles */
|
| 400 |
+
.badge-critical { background: rgba(240,80,80,.14); color: #f05f5f; border: 1px solid rgba(240,80,80,.3); }
|
| 401 |
+
.badge-credential{ background: rgba(220,140,60,.13); color: #e09040; border: 1px solid rgba(220,140,60,.28); }
|
| 402 |
+
.badge-feature { background: rgba(70,140,250,.12); color: #5a9eff; border: 1px solid rgba(70,140,250,.25); }
|
| 403 |
+
.badge-optional { background: rgba(61,214,140,.10); color: #3dd68c; border: 1px solid rgba(61,214,140,.22); }
|
| 404 |
+
.badge-advanced { background: rgba(160,100,230,.12);color: #b07ae0; border: 1px solid rgba(160,100,230,.25); }
|
| 405 |
+
.badge-build { background: rgba(240,185,60,.12); color: #e0b030; border: 1px solid rgba(240,185,60,.28); }
|
| 406 |
+
|
| 407 |
+
/* Card left-border accents */
|
| 408 |
+
.env-card:has(.badge-critical) { border-left: 3px solid rgba(240,80,80,.4); }
|
| 409 |
+
.env-card:has(.badge-critical):hover { border-left-color: rgba(240,80,80,.7); }
|
| 410 |
+
.env-card:has(.badge-critical).selected { border-left-color: #f05f5f; }
|
| 411 |
+
.env-card:has(.badge-credential){ border-left: 3px solid rgba(220,140,60,.3); }
|
| 412 |
+
|
| 413 |
+
/* Section count badge */
|
| 414 |
+
.sec-count {
|
| 415 |
+
font-family: var(--mono);
|
| 416 |
+
font-size: 10px;
|
| 417 |
+
color: var(--text3);
|
| 418 |
+
background: var(--bg3);
|
| 419 |
+
border: 1px solid var(--border);
|
| 420 |
+
border-radius: 10px;
|
| 421 |
+
padding: 1px 7px;
|
| 422 |
}
|
| 423 |
|
| 424 |
+
/* Tag legend */
|
| 425 |
+
.tag-legend {
|
| 426 |
+
margin-bottom: 14px;
|
| 427 |
+
background: var(--bg2);
|
| 428 |
+
border: 1px solid var(--border);
|
| 429 |
+
border-radius: var(--r);
|
| 430 |
+
overflow: hidden;
|
| 431 |
+
}
|
| 432 |
+
.legend-summary {
|
| 433 |
+
display: flex;
|
| 434 |
+
align-items: center;
|
| 435 |
+
gap: 10px;
|
| 436 |
+
padding: 7px 12px;
|
| 437 |
+
cursor: pointer;
|
| 438 |
+
list-style: none;
|
| 439 |
+
user-select: none;
|
| 440 |
+
outline: none;
|
| 441 |
}
|
| 442 |
+
.legend-summary::-webkit-details-marker { display: none; }
|
| 443 |
+
.legend-chips { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
|
| 444 |
+
.legend-hint { font-size: 10px; color: var(--text3); white-space: nowrap; flex-shrink: 0; }
|
| 445 |
+
.tag-legend[open] .legend-hint { opacity: 0; }
|
| 446 |
+
.legend-body {
|
| 447 |
+
padding: 8px 12px 10px;
|
| 448 |
+
border-top: 1px solid var(--border);
|
| 449 |
+
display: flex;
|
| 450 |
+
flex-direction: column;
|
| 451 |
+
gap: 6px;
|
| 452 |
+
}
|
| 453 |
+
.legend-row { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text2); }
|
| 454 |
+
.legend-row .badge { flex-shrink: 0; width: 74px; text-align: center; }
|
| 455 |
+
.legend-tip { font-size: 9.5px; color: var(--text3); margin-top: 4px; padding-top: 6px; border-top: 1px solid var(--border); }
|
| 456 |
+
.toolbar-hint { color: var(--text3); font-size: 12px; margin-right: 6px; white-space: nowrap; }
|
| 457 |
|
| 458 |
.card-input { position: relative; }
|
| 459 |
|
|
|
|
| 743 |
<main class="main">
|
| 744 |
|
| 745 |
<div class="toolbar">
|
| 746 |
+
<span class="toolbar-hint">Tip: Start with <strong>⚡ Required</strong>, then fill keys and click <strong># Generate Bundle</strong>.</span>
|
| 747 |
<div class="search-wrap">
|
| 748 |
<span class="search-icon">⌕</span>
|
| 749 |
<input id="search" type="text" placeholder="Search variables…" autocomplete="off" spellcheck="false">
|
|
|
|
| 751 |
|
| 752 |
<div class="tb-sep"></div>
|
| 753 |
|
| 754 |
+
<button id="selectRequired" class="btn">⚡ Required</button>
|
| 755 |
<button id="selectCommon" class="btn">★ Common</button>
|
| 756 |
<button id="selectVisible" class="btn">☑ Visible</button>
|
| 757 |
<button id="clearAll" class="btn btn-ghost">✕ Clear</button>
|
|
|
|
| 760 |
<div class="content-wrap">
|
| 761 |
|
| 762 |
<div class="sections-scroll">
|
| 763 |
+
|
| 764 |
+
<details class="tag-legend">
|
| 765 |
+
<summary class="legend-summary">
|
| 766 |
+
<div class="legend-chips">
|
| 767 |
+
<span class="badge badge-critical">critical</span>
|
| 768 |
+
<span class="badge badge-credential">credential</span>
|
| 769 |
+
<span class="badge badge-feature">feature</span>
|
| 770 |
+
<span class="badge badge-optional">optional</span>
|
| 771 |
+
<span class="badge badge-advanced">advanced</span>
|
| 772 |
+
<span class="badge badge-build">build</span>
|
| 773 |
+
</div>
|
| 774 |
+
<span class="legend-hint">▸ Tag legend</span>
|
| 775 |
+
</summary>
|
| 776 |
+
<div class="legend-body">
|
| 777 |
+
<div class="legend-row"><span class="badge badge-critical">critical</span> Required for the space to function at all</div>
|
| 778 |
+
<div class="legend-row"><span class="badge badge-credential">credential</span> API keys, tokens, secrets — keep private</div>
|
| 779 |
+
<div class="legend-row"><span class="badge badge-feature">feature</span> Unlocks an optional feature or integration</div>
|
| 780 |
+
<div class="legend-row"><span class="badge badge-optional">optional</span> Useful but not required; has a default</div>
|
| 781 |
+
<div class="legend-row"><span class="badge badge-advanced">advanced</span> Fine-tuning for specific deployments</div>
|
| 782 |
+
<div class="legend-row"><span class="badge badge-build">build</span> Affects Docker build, not runtime</div>
|
| 783 |
+
<div class="legend-tip">Use <strong>⚡ Required</strong> to auto-select all critical fields, then fill in credential keys.</div>
|
| 784 |
+
</div>
|
| 785 |
+
</details>
|
| 786 |
+
|
| 787 |
<div id="sections"></div>
|
| 788 |
|
| 789 |
<div id="customSec" class="sec" data-section="Custom Env">
|
|
|
|
| 814 |
<span class="pblock-title">📦 Bundle Output</span>
|
| 815 |
</div>
|
| 816 |
<div class="pblock-body">
|
| 817 |
+
<textarea id="bundleOut" placeholder="Select variables and click # Generate Bundle…" readonly spellcheck="false"></textarea>
|
| 818 |
<input type="text" id="envLineOut" placeholder="HUGGINGMES_ENV_BUNDLE=…" readonly spellcheck="false">
|
| 819 |
+
<div class="row-btns">
|
| 820 |
+
<button id="generateBundle" class="btn btn-amber" style="width:100%;"># Generate Bundle</button>
|
| 821 |
+
</div>
|
| 822 |
<div class="row-btns">
|
| 823 |
<button id="copyBundle" class="btn btn-amber">⎘ Bundle</button>
|
| 824 |
<button id="copyEnvLine" class="btn">⎘ Env Line</button>
|
|
@@ -103,26 +103,27 @@ const ICONS = {
|
|
| 103 |
};
|
| 104 |
|
| 105 |
// ── Field definitions ──
|
|
|
|
| 106 |
const FIELDS = [
|
| 107 |
// ── Core ──
|
| 108 |
{
|
| 109 |
"g": "Core", "icon": "⚡",
|
| 110 |
"k": "GATEWAY_TOKEN",
|
| 111 |
"lbl": "Gateway token — protects the Hermes web UI",
|
| 112 |
-
"type": "password", "secret": 1, "common": 1
|
| 113 |
},
|
| 114 |
{
|
| 115 |
"g": "Core", "icon": "⚡",
|
| 116 |
"k": "LLM_MODEL",
|
| 117 |
"lbl": "Default model (provider/model-name format)",
|
| 118 |
"type": "model", "options_key": "LLM_MODEL",
|
| 119 |
-
"ph": "gemini/gemini-2.5-flash", "common": 1
|
| 120 |
},
|
| 121 |
{
|
| 122 |
"g": "Core", "icon": "⚡",
|
| 123 |
"k": "LLM_API_KEY",
|
| 124 |
"lbl": "API key for the chosen provider",
|
| 125 |
-
"type": "password", "secret": 1, "common": 1
|
| 126 |
},
|
| 127 |
|
| 128 |
// ── Backup ──
|
|
@@ -130,19 +131,19 @@ const FIELDS = [
|
|
| 130 |
"g": "Backup", "icon": "💾",
|
| 131 |
"k": "HF_TOKEN",
|
| 132 |
"lbl": "HuggingFace token — enables state backup to a private dataset",
|
| 133 |
-
"type": "password", "secret": 1, "common": 1
|
| 134 |
},
|
| 135 |
{
|
| 136 |
"g": "Backup", "icon": "💾",
|
| 137 |
"k": "BACKUP_DATASET_NAME",
|
| 138 |
"lbl": "Name of the HF dataset used for backups",
|
| 139 |
-
"type": "text", "ph": "huggingmes-backup", "common": 1
|
| 140 |
},
|
| 141 |
{
|
| 142 |
"g": "Backup", "icon": "💾",
|
| 143 |
"k": "SYNC_INTERVAL",
|
| 144 |
"lbl": "Backup sync interval (seconds)",
|
| 145 |
-
"type": "number", "ph": "600"
|
| 146 |
},
|
| 147 |
|
| 148 |
// ── Telegram ──
|
|
@@ -150,13 +151,13 @@ const FIELDS = [
|
|
| 150 |
"g": "Telegram", "icon": "📱",
|
| 151 |
"k": "TELEGRAM_BOT_TOKEN",
|
| 152 |
"lbl": "Telegram bot token from @BotFather",
|
| 153 |
-
"type": "password", "secret": 1, "common": 1
|
| 154 |
},
|
| 155 |
{
|
| 156 |
"g": "Telegram", "icon": "📱",
|
| 157 |
"k": "TELEGRAM_ALLOWED_USERS",
|
| 158 |
"lbl": "Allowed Telegram user IDs (comma-separated)",
|
| 159 |
-
"type": "text", "ph": "123456789,987654321", "common": 1
|
| 160 |
},
|
| 161 |
{
|
| 162 |
"g": "Telegram", "icon": "📱",
|
|
@@ -164,19 +165,19 @@ const FIELDS = [
|
|
| 164 |
"lbl": "Telegram update mode",
|
| 165 |
"type": "select",
|
| 166 |
"options": ["webhook", "polling"],
|
| 167 |
-
"ph": "webhook"
|
| 168 |
},
|
| 169 |
{
|
| 170 |
"g": "Telegram", "icon": "📱",
|
| 171 |
"k": "TELEGRAM_WEBHOOK_URL",
|
| 172 |
"lbl": "Override webhook URL (auto-detected from SPACE_HOST if blank)",
|
| 173 |
-
"type": "text", "ph": "https://your-space.hf.space/telegram"
|
| 174 |
},
|
| 175 |
{
|
| 176 |
"g": "Telegram", "icon": "📱",
|
| 177 |
"k": "TELEGRAM_BASE_URL",
|
| 178 |
"lbl": "Custom Telegram API base URL (for proxies)",
|
| 179 |
-
"type": "text", "ph": "https://proxy.example.com/bot"
|
| 180 |
},
|
| 181 |
|
| 182 |
// ── Terminal ──
|
|
@@ -184,19 +185,19 @@ const FIELDS = [
|
|
| 184 |
"g": "Terminal", "icon": "💻",
|
| 185 |
"k": "DEV_MODE",
|
| 186 |
"lbl": "Enable JupyterLab terminal (on by default)",
|
| 187 |
-
"type": "toggle", "ph": "true", "common": 1
|
| 188 |
},
|
| 189 |
{
|
| 190 |
"g": "Terminal", "icon": "💻",
|
| 191 |
"k": "JUPYTER_TOKEN",
|
| 192 |
"lbl": "Override terminal password (defaults to GATEWAY_TOKEN)",
|
| 193 |
-
"type": "password", "secret": 1
|
| 194 |
},
|
| 195 |
{
|
| 196 |
"g": "Terminal", "icon": "💻",
|
| 197 |
"k": "JUPYTER_ROOT_DIR",
|
| 198 |
"lbl": "JupyterLab root directory",
|
| 199 |
-
"type": "text", "ph": "/opt/data/workspace"
|
| 200 |
},
|
| 201 |
|
| 202 |
// ── Providers ──
|
|
@@ -204,43 +205,43 @@ const FIELDS = [
|
|
| 204 |
"g": "Providers", "icon": "🔑",
|
| 205 |
"k": "ANTHROPIC_API_KEY",
|
| 206 |
"lbl": "Anthropic API key",
|
| 207 |
-
"type": "password", "secret": 1
|
| 208 |
},
|
| 209 |
{
|
| 210 |
"g": "Providers", "icon": "🔑",
|
| 211 |
"k": "OPENAI_API_KEY",
|
| 212 |
"lbl": "OpenAI API key",
|
| 213 |
-
"type": "password", "secret": 1
|
| 214 |
},
|
| 215 |
{
|
| 216 |
"g": "Providers", "icon": "🔑",
|
| 217 |
"k": "GOOGLE_API_KEY",
|
| 218 |
"lbl": "Google / Gemini API key",
|
| 219 |
-
"type": "password", "secret": 1
|
| 220 |
},
|
| 221 |
{
|
| 222 |
"g": "Providers", "icon": "🔑",
|
| 223 |
"k": "GEMINI_API_KEY",
|
| 224 |
"lbl": "Gemini API key (alias for GOOGLE_API_KEY)",
|
| 225 |
-
"type": "password", "secret": 1
|
| 226 |
},
|
| 227 |
{
|
| 228 |
"g": "Providers", "icon": "🔑",
|
| 229 |
"k": "OPENROUTER_API_KEY",
|
| 230 |
"lbl": "OpenRouter API key",
|
| 231 |
-
"type": "password", "secret": 1
|
| 232 |
},
|
| 233 |
{
|
| 234 |
"g": "Providers", "icon": "🔑",
|
| 235 |
"k": "DEEPSEEK_API_KEY",
|
| 236 |
"lbl": "DeepSeek API key",
|
| 237 |
-
"type": "password", "secret": 1
|
| 238 |
},
|
| 239 |
{
|
| 240 |
"g": "Providers", "icon": "🔑",
|
| 241 |
"k": "XAI_API_KEY",
|
| 242 |
"lbl": "xAI (Grok) API key",
|
| 243 |
-
"type": "password", "secret": 1
|
| 244 |
},
|
| 245 |
{
|
| 246 |
"g": "Providers", "icon": "🔑",
|
|
@@ -248,37 +249,37 @@ const FIELDS = [
|
|
| 248 |
"lbl": "Force Hermes inference provider (overrides auto-detect)",
|
| 249 |
"type": "select",
|
| 250 |
"options": ["auto", "anthropic", "openai", "gemini", "openrouter", "huggingface", "custom", "deepseek", "xai"],
|
| 251 |
-
"ph": "auto"
|
| 252 |
},
|
| 253 |
{
|
| 254 |
"g": "Providers", "icon": "🔑",
|
| 255 |
"k": "CUSTOM_BASE_URL",
|
| 256 |
"lbl": "Custom OpenAI-compatible base URL",
|
| 257 |
-
"type": "text", "ph": "https://your-api.example.com/v1"
|
| 258 |
},
|
| 259 |
{
|
| 260 |
"g": "Providers", "icon": "🔑",
|
| 261 |
"k": "CUSTOM_API_KEY",
|
| 262 |
"lbl": "API key for the custom provider",
|
| 263 |
-
"type": "password", "secret": 1
|
| 264 |
},
|
| 265 |
{
|
| 266 |
"g": "Providers", "icon": "🔑",
|
| 267 |
"k": "CUSTOM_PROVIDER",
|
| 268 |
"lbl": "Provider name for custom endpoints",
|
| 269 |
-
"type": "text", "ph": "custom"
|
| 270 |
},
|
| 271 |
{
|
| 272 |
"g": "Providers", "icon": "🔑",
|
| 273 |
"k": "CUSTOM_MODEL_CONTEXT_LENGTH",
|
| 274 |
"lbl": "Context length for custom model",
|
| 275 |
-
"type": "number", "ph": "131072"
|
| 276 |
},
|
| 277 |
{
|
| 278 |
"g": "Providers", "icon": "🔑",
|
| 279 |
"k": "CUSTOM_MODEL_MAX_TOKENS",
|
| 280 |
"lbl": "Max output tokens for custom model",
|
| 281 |
-
"type": "number", "ph": "8192"
|
| 282 |
},
|
| 283 |
|
| 284 |
// ── Cloudflare ──
|
|
@@ -286,19 +287,19 @@ const FIELDS = [
|
|
| 286 |
"g": "Cloudflare", "icon": "☁️",
|
| 287 |
"k": "CLOUDFLARE_WORKERS_TOKEN",
|
| 288 |
"lbl": "Cloudflare Workers API token (for Telegram proxy setup)",
|
| 289 |
-
"type": "password", "secret": 1
|
| 290 |
},
|
| 291 |
{
|
| 292 |
"g": "Cloudflare", "icon": "☁️",
|
| 293 |
"k": "CLOUDFLARE_PROXY_URL",
|
| 294 |
"lbl": "Cloudflare proxy URL for Telegram (if already deployed)",
|
| 295 |
-
"type": "text", "ph": "https://your-worker.your-subdomain.workers.dev"
|
| 296 |
},
|
| 297 |
{
|
| 298 |
"g": "Cloudflare", "icon": "☁️",
|
| 299 |
"k": "CLOUDFLARE_PROXY_DEBUG",
|
| 300 |
"lbl": "Enable Cloudflare proxy debug logging",
|
| 301 |
-
"type": "toggle", "ph": "false"
|
| 302 |
},
|
| 303 |
|
| 304 |
// ── Advanced ──
|
|
@@ -306,25 +307,31 @@ const FIELDS = [
|
|
| 306 |
"g": "Advanced", "icon": "⚙️",
|
| 307 |
"k": "WEBHOOK_URL",
|
| 308 |
"lbl": "URL to POST a JSON notification on gateway (re)start",
|
| 309 |
-
"type": "text", "ph": "https://..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
},
|
| 311 |
{
|
| 312 |
"g": "Advanced", "icon": "⚙️",
|
| 313 |
"k": "GATEWAY_READY_TIMEOUT",
|
| 314 |
"lbl": "Seconds to wait for gateway API port before failing",
|
| 315 |
-
"type": "number", "ph": "120"
|
| 316 |
},
|
| 317 |
{
|
| 318 |
"g": "Advanced", "icon": "⚙️",
|
| 319 |
"k": "API_SERVER_PORT",
|
| 320 |
"lbl": "Hermes gateway internal API port",
|
| 321 |
-
"type": "number", "ph": "8642"
|
| 322 |
},
|
| 323 |
{
|
| 324 |
"g": "Advanced", "icon": "⚙️",
|
| 325 |
"k": "DASHBOARD_PORT",
|
| 326 |
"lbl": "Hermes dashboard internal port",
|
| 327 |
-
"type": "number", "ph": "9119"
|
| 328 |
},
|
| 329 |
{
|
| 330 |
"g": "Advanced", "icon": "⚙️",
|
|
@@ -332,13 +339,13 @@ const FIELDS = [
|
|
| 332 |
"lbl": "Background process notification level",
|
| 333 |
"type": "select",
|
| 334 |
"options": ["result", "progress", "none"],
|
| 335 |
-
"ph": "result"
|
| 336 |
},
|
| 337 |
{
|
| 338 |
"g": "Advanced", "icon": "⚙️",
|
| 339 |
"k": "TELEGRAM_WEBHOOK_SECRET",
|
| 340 |
"lbl": "Secret token for Telegram webhook validation (auto-generated if blank)",
|
| 341 |
-
"type": "password", "secret": 1
|
| 342 |
}
|
| 343 |
];
|
| 344 |
|
|
@@ -478,18 +485,21 @@ function valueControlHTML(field) {
|
|
| 478 |
</div>`;
|
| 479 |
}
|
| 480 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
function cardHTML(f) {
|
| 482 |
-
const
|
| 483 |
-
|
| 484 |
-
: '<span class="badge badge-f">safe</span>';
|
| 485 |
-
return `<div class="env-card" data-row data-group="${esc(f.g)}" data-search="${esc((f.g + ' ' + f.k + ' ' + (f.lbl || '')).toLowerCase())}">
|
| 486 |
<div class="card-top">
|
| 487 |
-
<input type="checkbox" class="card-check" data-check="${esc(f.k)}" ${f.common ? 'data-common="1"' : ''}>
|
| 488 |
<div class="card-info">
|
| 489 |
<div class="card-key">${esc(f.k)}</div>
|
| 490 |
<div class="card-lbl">${esc(f.lbl || '')}</div>
|
| 491 |
</div>
|
| 492 |
-
${
|
| 493 |
</div>
|
| 494 |
<div class="card-input">${valueControlHTML(f)}</div>
|
| 495 |
</div>`;
|
|
@@ -550,12 +560,18 @@ function collect() {
|
|
| 550 |
return obj;
|
| 551 |
}
|
| 552 |
|
| 553 |
-
function
|
| 554 |
const obj = collect();
|
| 555 |
const keys = Object.keys(obj).sort();
|
| 556 |
const bundle = keys.length ? encodeBundle(Object.fromEntries(keys.map(k => [k, obj[k]]))) : '';
|
| 557 |
$('bundleOut').value = bundle;
|
| 558 |
$('envLineOut').value = bundle ? `${BUNDLE_KEY}=${bundle}` : '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
const s = $('summary');
|
| 560 |
if (keys.length) {
|
| 561 |
s.innerHTML = `<strong>${keys.length}</strong> variable${keys.length > 1 ? 's' : ''} selected<div class="sum-keys">${keys.map(k => `<span class="sum-key">${esc(k)}</span>`).join('')}</div>`;
|
|
@@ -755,6 +771,11 @@ refresh();
|
|
| 755 |
|
| 756 |
// ── Events ──
|
| 757 |
$('search').oninput = filter;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
$('selectCommon').onclick = () => {
|
| 759 |
document.querySelectorAll('[data-common="1"]').forEach(c => c.checked = true);
|
| 760 |
markSelected(); refresh();
|
|
@@ -764,6 +785,7 @@ $('selectVisible').onclick = () => {
|
|
| 764 |
markSelected(); refresh();
|
| 765 |
};
|
| 766 |
$('clearAll').onclick = () => { clearForm(); markSelected(); filter(); refresh(); };
|
|
|
|
| 767 |
$('applyImport').onclick = () => {
|
| 768 |
try { applyObj(parseEnv($('importText').value), true); showToast('Imported ✓'); }
|
| 769 |
catch (e) { showToast('Import failed'); alert(e.message); }
|
|
|
|
| 103 |
};
|
| 104 |
|
| 105 |
// ── Field definitions ──
|
| 106 |
+
// tag: "critical" | "credential" | "feature" | "optional" | "advanced" | "build"
|
| 107 |
const FIELDS = [
|
| 108 |
// ── Core ──
|
| 109 |
{
|
| 110 |
"g": "Core", "icon": "⚡",
|
| 111 |
"k": "GATEWAY_TOKEN",
|
| 112 |
"lbl": "Gateway token — protects the Hermes web UI",
|
| 113 |
+
"type": "password", "secret": 1, "common": 1, "tag": "critical"
|
| 114 |
},
|
| 115 |
{
|
| 116 |
"g": "Core", "icon": "⚡",
|
| 117 |
"k": "LLM_MODEL",
|
| 118 |
"lbl": "Default model (provider/model-name format)",
|
| 119 |
"type": "model", "options_key": "LLM_MODEL",
|
| 120 |
+
"ph": "gemini/gemini-2.5-flash", "common": 1, "tag": "critical"
|
| 121 |
},
|
| 122 |
{
|
| 123 |
"g": "Core", "icon": "⚡",
|
| 124 |
"k": "LLM_API_KEY",
|
| 125 |
"lbl": "API key for the chosen provider",
|
| 126 |
+
"type": "password", "secret": 1, "common": 1, "tag": "credential"
|
| 127 |
},
|
| 128 |
|
| 129 |
// ── Backup ──
|
|
|
|
| 131 |
"g": "Backup", "icon": "💾",
|
| 132 |
"k": "HF_TOKEN",
|
| 133 |
"lbl": "HuggingFace token — enables state backup to a private dataset",
|
| 134 |
+
"type": "password", "secret": 1, "common": 1, "tag": "credential"
|
| 135 |
},
|
| 136 |
{
|
| 137 |
"g": "Backup", "icon": "💾",
|
| 138 |
"k": "BACKUP_DATASET_NAME",
|
| 139 |
"lbl": "Name of the HF dataset used for backups",
|
| 140 |
+
"type": "text", "ph": "huggingmes-backup", "common": 1, "tag": "optional"
|
| 141 |
},
|
| 142 |
{
|
| 143 |
"g": "Backup", "icon": "💾",
|
| 144 |
"k": "SYNC_INTERVAL",
|
| 145 |
"lbl": "Backup sync interval (seconds)",
|
| 146 |
+
"type": "number", "ph": "600", "tag": "optional"
|
| 147 |
},
|
| 148 |
|
| 149 |
// ── Telegram ──
|
|
|
|
| 151 |
"g": "Telegram", "icon": "📱",
|
| 152 |
"k": "TELEGRAM_BOT_TOKEN",
|
| 153 |
"lbl": "Telegram bot token from @BotFather",
|
| 154 |
+
"type": "password", "secret": 1, "common": 1, "tag": "credential"
|
| 155 |
},
|
| 156 |
{
|
| 157 |
"g": "Telegram", "icon": "📱",
|
| 158 |
"k": "TELEGRAM_ALLOWED_USERS",
|
| 159 |
"lbl": "Allowed Telegram user IDs (comma-separated)",
|
| 160 |
+
"type": "text", "ph": "123456789,987654321", "common": 1, "tag": "feature"
|
| 161 |
},
|
| 162 |
{
|
| 163 |
"g": "Telegram", "icon": "📱",
|
|
|
|
| 165 |
"lbl": "Telegram update mode",
|
| 166 |
"type": "select",
|
| 167 |
"options": ["webhook", "polling"],
|
| 168 |
+
"ph": "webhook", "tag": "optional"
|
| 169 |
},
|
| 170 |
{
|
| 171 |
"g": "Telegram", "icon": "📱",
|
| 172 |
"k": "TELEGRAM_WEBHOOK_URL",
|
| 173 |
"lbl": "Override webhook URL (auto-detected from SPACE_HOST if blank)",
|
| 174 |
+
"type": "text", "ph": "https://your-space.hf.space/telegram", "tag": "optional"
|
| 175 |
},
|
| 176 |
{
|
| 177 |
"g": "Telegram", "icon": "📱",
|
| 178 |
"k": "TELEGRAM_BASE_URL",
|
| 179 |
"lbl": "Custom Telegram API base URL (for proxies)",
|
| 180 |
+
"type": "text", "ph": "https://proxy.example.com/bot", "tag": "optional"
|
| 181 |
},
|
| 182 |
|
| 183 |
// ── Terminal ──
|
|
|
|
| 185 |
"g": "Terminal", "icon": "💻",
|
| 186 |
"k": "DEV_MODE",
|
| 187 |
"lbl": "Enable JupyterLab terminal (on by default)",
|
| 188 |
+
"type": "toggle", "ph": "true", "common": 1, "tag": "feature"
|
| 189 |
},
|
| 190 |
{
|
| 191 |
"g": "Terminal", "icon": "💻",
|
| 192 |
"k": "JUPYTER_TOKEN",
|
| 193 |
"lbl": "Override terminal password (defaults to GATEWAY_TOKEN)",
|
| 194 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 195 |
},
|
| 196 |
{
|
| 197 |
"g": "Terminal", "icon": "💻",
|
| 198 |
"k": "JUPYTER_ROOT_DIR",
|
| 199 |
"lbl": "JupyterLab root directory",
|
| 200 |
+
"type": "text", "ph": "/opt/data/workspace", "tag": "optional"
|
| 201 |
},
|
| 202 |
|
| 203 |
// ── Providers ──
|
|
|
|
| 205 |
"g": "Providers", "icon": "🔑",
|
| 206 |
"k": "ANTHROPIC_API_KEY",
|
| 207 |
"lbl": "Anthropic API key",
|
| 208 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 209 |
},
|
| 210 |
{
|
| 211 |
"g": "Providers", "icon": "🔑",
|
| 212 |
"k": "OPENAI_API_KEY",
|
| 213 |
"lbl": "OpenAI API key",
|
| 214 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 215 |
},
|
| 216 |
{
|
| 217 |
"g": "Providers", "icon": "🔑",
|
| 218 |
"k": "GOOGLE_API_KEY",
|
| 219 |
"lbl": "Google / Gemini API key",
|
| 220 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 221 |
},
|
| 222 |
{
|
| 223 |
"g": "Providers", "icon": "🔑",
|
| 224 |
"k": "GEMINI_API_KEY",
|
| 225 |
"lbl": "Gemini API key (alias for GOOGLE_API_KEY)",
|
| 226 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 227 |
},
|
| 228 |
{
|
| 229 |
"g": "Providers", "icon": "🔑",
|
| 230 |
"k": "OPENROUTER_API_KEY",
|
| 231 |
"lbl": "OpenRouter API key",
|
| 232 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 233 |
},
|
| 234 |
{
|
| 235 |
"g": "Providers", "icon": "🔑",
|
| 236 |
"k": "DEEPSEEK_API_KEY",
|
| 237 |
"lbl": "DeepSeek API key",
|
| 238 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 239 |
},
|
| 240 |
{
|
| 241 |
"g": "Providers", "icon": "🔑",
|
| 242 |
"k": "XAI_API_KEY",
|
| 243 |
"lbl": "xAI (Grok) API key",
|
| 244 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 245 |
},
|
| 246 |
{
|
| 247 |
"g": "Providers", "icon": "🔑",
|
|
|
|
| 249 |
"lbl": "Force Hermes inference provider (overrides auto-detect)",
|
| 250 |
"type": "select",
|
| 251 |
"options": ["auto", "anthropic", "openai", "gemini", "openrouter", "huggingface", "custom", "deepseek", "xai"],
|
| 252 |
+
"ph": "auto", "tag": "advanced"
|
| 253 |
},
|
| 254 |
{
|
| 255 |
"g": "Providers", "icon": "🔑",
|
| 256 |
"k": "CUSTOM_BASE_URL",
|
| 257 |
"lbl": "Custom OpenAI-compatible base URL",
|
| 258 |
+
"type": "text", "ph": "https://your-api.example.com/v1", "tag": "feature"
|
| 259 |
},
|
| 260 |
{
|
| 261 |
"g": "Providers", "icon": "🔑",
|
| 262 |
"k": "CUSTOM_API_KEY",
|
| 263 |
"lbl": "API key for the custom provider",
|
| 264 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 265 |
},
|
| 266 |
{
|
| 267 |
"g": "Providers", "icon": "🔑",
|
| 268 |
"k": "CUSTOM_PROVIDER",
|
| 269 |
"lbl": "Provider name for custom endpoints",
|
| 270 |
+
"type": "text", "ph": "custom", "tag": "advanced"
|
| 271 |
},
|
| 272 |
{
|
| 273 |
"g": "Providers", "icon": "🔑",
|
| 274 |
"k": "CUSTOM_MODEL_CONTEXT_LENGTH",
|
| 275 |
"lbl": "Context length for custom model",
|
| 276 |
+
"type": "number", "ph": "131072", "tag": "advanced"
|
| 277 |
},
|
| 278 |
{
|
| 279 |
"g": "Providers", "icon": "🔑",
|
| 280 |
"k": "CUSTOM_MODEL_MAX_TOKENS",
|
| 281 |
"lbl": "Max output tokens for custom model",
|
| 282 |
+
"type": "number", "ph": "8192", "tag": "advanced"
|
| 283 |
},
|
| 284 |
|
| 285 |
// ── Cloudflare ──
|
|
|
|
| 287 |
"g": "Cloudflare", "icon": "☁️",
|
| 288 |
"k": "CLOUDFLARE_WORKERS_TOKEN",
|
| 289 |
"lbl": "Cloudflare Workers API token (for Telegram proxy setup)",
|
| 290 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 291 |
},
|
| 292 |
{
|
| 293 |
"g": "Cloudflare", "icon": "☁️",
|
| 294 |
"k": "CLOUDFLARE_PROXY_URL",
|
| 295 |
"lbl": "Cloudflare proxy URL for Telegram (if already deployed)",
|
| 296 |
+
"type": "text", "ph": "https://your-worker.your-subdomain.workers.dev", "tag": "feature"
|
| 297 |
},
|
| 298 |
{
|
| 299 |
"g": "Cloudflare", "icon": "☁️",
|
| 300 |
"k": "CLOUDFLARE_PROXY_DEBUG",
|
| 301 |
"lbl": "Enable Cloudflare proxy debug logging",
|
| 302 |
+
"type": "toggle", "ph": "false", "tag": "advanced"
|
| 303 |
},
|
| 304 |
|
| 305 |
// ── Advanced ──
|
|
|
|
| 307 |
"g": "Advanced", "icon": "⚙️",
|
| 308 |
"k": "WEBHOOK_URL",
|
| 309 |
"lbl": "URL to POST a JSON notification on gateway (re)start",
|
| 310 |
+
"type": "text", "ph": "https://...", "tag": "optional"
|
| 311 |
+
},
|
| 312 |
+
{
|
| 313 |
+
"g": "Advanced", "icon": "⚙️",
|
| 314 |
+
"k": "SPACE_PRIVACY",
|
| 315 |
+
"lbl": "Override Space privacy detection (public/private) — skips HF API call",
|
| 316 |
+
"type": "select", "options": ["public", "private"], "ph": "public", "tag": "advanced"
|
| 317 |
},
|
| 318 |
{
|
| 319 |
"g": "Advanced", "icon": "⚙️",
|
| 320 |
"k": "GATEWAY_READY_TIMEOUT",
|
| 321 |
"lbl": "Seconds to wait for gateway API port before failing",
|
| 322 |
+
"type": "number", "ph": "120", "tag": "advanced"
|
| 323 |
},
|
| 324 |
{
|
| 325 |
"g": "Advanced", "icon": "⚙️",
|
| 326 |
"k": "API_SERVER_PORT",
|
| 327 |
"lbl": "Hermes gateway internal API port",
|
| 328 |
+
"type": "number", "ph": "8642", "tag": "advanced"
|
| 329 |
},
|
| 330 |
{
|
| 331 |
"g": "Advanced", "icon": "⚙️",
|
| 332 |
"k": "DASHBOARD_PORT",
|
| 333 |
"lbl": "Hermes dashboard internal port",
|
| 334 |
+
"type": "number", "ph": "9119", "tag": "advanced"
|
| 335 |
},
|
| 336 |
{
|
| 337 |
"g": "Advanced", "icon": "⚙️",
|
|
|
|
| 339 |
"lbl": "Background process notification level",
|
| 340 |
"type": "select",
|
| 341 |
"options": ["result", "progress", "none"],
|
| 342 |
+
"ph": "result", "tag": "optional"
|
| 343 |
},
|
| 344 |
{
|
| 345 |
"g": "Advanced", "icon": "⚙️",
|
| 346 |
"k": "TELEGRAM_WEBHOOK_SECRET",
|
| 347 |
"lbl": "Secret token for Telegram webhook validation (auto-generated if blank)",
|
| 348 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 349 |
}
|
| 350 |
];
|
| 351 |
|
|
|
|
| 485 |
</div>`;
|
| 486 |
}
|
| 487 |
|
| 488 |
+
function tagBadgeHTML(f) {
|
| 489 |
+
const t = f.tag || (f.secret ? 'credential' : 'optional');
|
| 490 |
+
return `<span class="badge badge-${t}">${t}</span>`;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
function cardHTML(f) {
|
| 494 |
+
const tagStr = (f.tag || '') + ' ' + (f.secret ? 'credential' : '') + ' ' + (f.g + ' ' + f.k + ' ' + (f.lbl || '')).toLowerCase();
|
| 495 |
+
return `<div class="env-card" data-row data-group="${esc(f.g)}" data-tag="${esc(f.tag || '')}" data-search="${esc(tagStr.toLowerCase())}">
|
|
|
|
|
|
|
| 496 |
<div class="card-top">
|
| 497 |
+
<input type="checkbox" class="card-check" data-check="${esc(f.k)}" ${f.common ? 'data-common="1"' : ''} ${f.tag === 'critical' ? 'data-critical="1"' : ''}>
|
| 498 |
<div class="card-info">
|
| 499 |
<div class="card-key">${esc(f.k)}</div>
|
| 500 |
<div class="card-lbl">${esc(f.lbl || '')}</div>
|
| 501 |
</div>
|
| 502 |
+
${tagBadgeHTML(f)}
|
| 503 |
</div>
|
| 504 |
<div class="card-input">${valueControlHTML(f)}</div>
|
| 505 |
</div>`;
|
|
|
|
| 560 |
return obj;
|
| 561 |
}
|
| 562 |
|
| 563 |
+
function generateBundle() {
|
| 564 |
const obj = collect();
|
| 565 |
const keys = Object.keys(obj).sort();
|
| 566 |
const bundle = keys.length ? encodeBundle(Object.fromEntries(keys.map(k => [k, obj[k]]))) : '';
|
| 567 |
$('bundleOut').value = bundle;
|
| 568 |
$('envLineOut').value = bundle ? `${BUNDLE_KEY}=${bundle}` : '';
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
function refresh() {
|
| 572 |
+
// Refresh summary + counts — does NOT auto-regenerate bundle (requires explicit button click)
|
| 573 |
+
const obj = collect();
|
| 574 |
+
const keys = Object.keys(obj).sort();
|
| 575 |
const s = $('summary');
|
| 576 |
if (keys.length) {
|
| 577 |
s.innerHTML = `<strong>${keys.length}</strong> variable${keys.length > 1 ? 's' : ''} selected<div class="sum-keys">${keys.map(k => `<span class="sum-key">${esc(k)}</span>`).join('')}</div>`;
|
|
|
|
| 771 |
|
| 772 |
// ── Events ──
|
| 773 |
$('search').oninput = filter;
|
| 774 |
+
$('selectRequired').onclick = () => {
|
| 775 |
+
document.querySelectorAll('[data-critical="1"]').forEach(c => c.checked = true);
|
| 776 |
+
markSelected(); refresh();
|
| 777 |
+
showToast('Critical fields selected ✓');
|
| 778 |
+
};
|
| 779 |
$('selectCommon').onclick = () => {
|
| 780 |
document.querySelectorAll('[data-common="1"]').forEach(c => c.checked = true);
|
| 781 |
markSelected(); refresh();
|
|
|
|
| 785 |
markSelected(); refresh();
|
| 786 |
};
|
| 787 |
$('clearAll').onclick = () => { clearForm(); markSelected(); filter(); refresh(); };
|
| 788 |
+
$('generateBundle').onclick = () => { generateBundle(); showToast('Bundle generated ✓'); };
|
| 789 |
$('applyImport').onclick = () => {
|
| 790 |
try { applyObj(parseEnv($('importText').value), true); showToast('Imported ✓'); }
|
| 791 |
catch (e) { showToast('Import failed'); alert(e.message); }
|
|
@@ -33,44 +33,94 @@ function deriveHfSpaceUrl() {
|
|
| 33 |
}
|
| 34 |
const HF_SPACE_URL = deriveHfSpaceUrl();
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
async function detectSpacePrivacy() {
|
| 38 |
-
if (
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
});
|
|
|
|
|
|
|
|
|
|
| 65 |
});
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
-
detectSpacePrivacy();
|
| 74 |
|
| 75 |
const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
|
| 76 |
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
|
|
@@ -427,7 +477,14 @@ function renderPrivateRedirect(targetUrl) {
|
|
| 427 |
<a class="btn" href="${safeUrl}">Open on Hugging Face →</a>
|
| 428 |
<div class="sub">Redirecting in 3 seconds…</div>
|
| 429 |
</div>
|
| 430 |
-
<script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
</body></html>`;
|
| 432 |
}
|
| 433 |
|
|
@@ -632,7 +689,46 @@ function renderDashboard(data) {
|
|
| 632 |
const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
|
| 633 |
const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
|
| 634 |
const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
|
| 635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
|
| 637 |
const notice = document.createElement('div');
|
| 638 |
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';
|
|
@@ -640,17 +736,6 @@ function renderDashboard(data) {
|
|
| 640 |
document.body.appendChild(notice);
|
| 641 |
setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
|
| 642 |
}
|
| 643 |
-
// Force new-tab navigation when running inside the HF App iframe or on a raw .hf.space link
|
| 644 |
-
const openInNewTab = inEmbeddedApp || isDirectHfSpaceHost;
|
| 645 |
-
document.querySelectorAll('a[data-space-link]').forEach((a) => {
|
| 646 |
-
if (openInNewTab) {
|
| 647 |
-
a.setAttribute('target', '_blank');
|
| 648 |
-
a.setAttribute('rel', 'noopener noreferrer');
|
| 649 |
-
} else {
|
| 650 |
-
a.removeAttribute('target');
|
| 651 |
-
a.removeAttribute('rel');
|
| 652 |
-
}
|
| 653 |
-
});
|
| 654 |
</script>
|
| 655 |
</body>
|
| 656 |
</html>`;
|
|
@@ -660,6 +745,15 @@ const server = http.createServer(async (req, res) => {
|
|
| 660 |
const parsed = new URL(req.url, "http://localhost");
|
| 661 |
const path = parsed.pathname;
|
| 662 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
if (path === LOGIN_PATH) {
|
| 664 |
await handleLogin(req, res, parsed);
|
| 665 |
return;
|
|
@@ -669,9 +763,31 @@ const server = http.createServer(async (req, res) => {
|
|
| 669 |
// Intercepts browser HTML requests from raw .hf.space hosts when the Space is private.
|
| 670 |
// /health and /status are always exempt so uptime monitors keep working.
|
| 671 |
const isHtmlReq = (req.headers.accept || "").includes("text/html");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
const isDirectHfSpaceReq = SPACE_IS_PRIVATE &&
|
| 673 |
HF_SPACE_URL &&
|
| 674 |
isHtmlReq &&
|
|
|
|
|
|
|
| 675 |
typeof req.headers.host === "string" &&
|
| 676 |
req.headers.host.endsWith(".hf.space");
|
| 677 |
|
|
|
|
| 33 |
}
|
| 34 |
const HF_SPACE_URL = deriveHfSpaceUrl();
|
| 35 |
|
| 36 |
+
// Privacy detection priority:
|
| 37 |
+
// 1. SPACE_PRIVACY env var ("public"/"private") — explicit override, skip API call
|
| 38 |
+
// 2. HF API auto-detect with retry
|
| 39 |
+
// 3. Fail-secure: treat as private if SPACE_ID set
|
| 40 |
+
const _spacPrivacyEnv = (process.env.SPACE_PRIVACY || "").trim().toLowerCase();
|
| 41 |
+
let SPACE_IS_PRIVATE;
|
| 42 |
+
let _privacyDetectionDone = false;
|
| 43 |
+
let _privacyDetectionResolve;
|
| 44 |
+
const privacyDetectionReady = new Promise((res) => { _privacyDetectionResolve = res; });
|
| 45 |
+
|
| 46 |
+
if (_spacPrivacyEnv === "public") {
|
| 47 |
+
SPACE_IS_PRIVATE = false;
|
| 48 |
+
_privacyDetectionDone = true;
|
| 49 |
+
console.log("[health-server] Space privacy: public (SPACE_PRIVACY env override)");
|
| 50 |
+
_privacyDetectionResolve();
|
| 51 |
+
} else if (_spacPrivacyEnv === "private") {
|
| 52 |
+
SPACE_IS_PRIVATE = true;
|
| 53 |
+
_privacyDetectionDone = true;
|
| 54 |
+
console.log("[health-server] Space privacy: private (SPACE_PRIVACY env override)");
|
| 55 |
+
_privacyDetectionResolve();
|
| 56 |
+
} else {
|
| 57 |
+
// Fail-secure default until API call resolves
|
| 58 |
+
SPACE_IS_PRIVATE = !!SPACE_ID;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
async function detectSpacePrivacy() {
|
| 62 |
+
if (_spacPrivacyEnv === "public" || _spacPrivacyEnv === "private") return;
|
| 63 |
+
if (!SPACE_ID) {
|
| 64 |
+
SPACE_IS_PRIVATE = false;
|
| 65 |
+
_privacyDetectionDone = true;
|
| 66 |
+
_privacyDetectionResolve();
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
const token = (process.env.HF_TOKEN || "").trim();
|
| 70 |
+
const reqOptions = {
|
| 71 |
+
hostname: "huggingface.co",
|
| 72 |
+
path: `/api/spaces/${SPACE_ID}`,
|
| 73 |
+
method: "GET",
|
| 74 |
+
headers: Object.assign(
|
| 75 |
+
{ "User-Agent": "HuggingMes/health-server" },
|
| 76 |
+
token ? { Authorization: `Bearer ${token}` } : {}
|
| 77 |
+
),
|
| 78 |
+
};
|
| 79 |
+
const MAX_ATTEMPTS = 5;
|
| 80 |
+
let detected = false;
|
| 81 |
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
| 82 |
+
try {
|
| 83 |
+
const result = await new Promise((resolve) => {
|
| 84 |
+
const r = https.request(reqOptions, (apiRes) => {
|
| 85 |
+
let body = "";
|
| 86 |
+
apiRes.on("data", (chunk) => { body += chunk; });
|
| 87 |
+
apiRes.on("end", () => {
|
| 88 |
+
try {
|
| 89 |
+
if (apiRes.statusCode === 200) {
|
| 90 |
+
SPACE_IS_PRIVATE = JSON.parse(body).private === true;
|
| 91 |
+
resolve({ ok: true });
|
| 92 |
+
} else if (apiRes.statusCode === 401 || apiRes.statusCode === 403) {
|
| 93 |
+
SPACE_IS_PRIVATE = true;
|
| 94 |
+
resolve({ ok: true });
|
| 95 |
+
} else {
|
| 96 |
+
resolve({ ok: false });
|
| 97 |
+
}
|
| 98 |
+
} catch { resolve({ ok: false }); }
|
| 99 |
+
});
|
| 100 |
});
|
| 101 |
+
r.on("error", () => resolve({ ok: false }));
|
| 102 |
+
r.setTimeout(8000, () => { r.destroy(); resolve({ ok: false }); });
|
| 103 |
+
r.end();
|
| 104 |
});
|
| 105 |
+
console.log(`[health-server] Privacy detection attempt ${attempt}/${MAX_ATTEMPTS}: ok=${result.ok}`);
|
| 106 |
+
if (result.ok) { detected = true; break; }
|
| 107 |
+
} catch {}
|
| 108 |
+
const delay = Math.min(2000 * attempt, 10000);
|
| 109 |
+
if (attempt < MAX_ATTEMPTS) await new Promise((r) => setTimeout(r, delay));
|
| 110 |
+
}
|
| 111 |
+
if (!detected) {
|
| 112 |
+
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.`);
|
| 113 |
+
} else {
|
| 114 |
+
console.log(`[health-server] Space privacy detected: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
|
| 115 |
+
}
|
| 116 |
+
_privacyDetectionDone = true;
|
| 117 |
+
_privacyDetectionResolve();
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if (_spacPrivacyEnv !== "public" && _spacPrivacyEnv !== "private") {
|
| 121 |
+
detectSpacePrivacy();
|
| 122 |
+
setInterval(detectSpacePrivacy, 5 * 60 * 1000);
|
| 123 |
}
|
|
|
|
| 124 |
|
| 125 |
const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
|
| 126 |
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
|
|
|
|
| 477 |
<a class="btn" href="${safeUrl}">Open on Hugging Face →</a>
|
| 478 |
<div class="sub">Redirecting in 3 seconds…</div>
|
| 479 |
</div>
|
| 480 |
+
<script>
|
| 481 |
+
// Only auto-redirect when NOT inside an iframe — navigating an iframe to
|
| 482 |
+
// huggingface.co is blocked by X-Frame-Options and causes "refused to connect".
|
| 483 |
+
const _inFrame = (() => { try { return window.top !== window.self; } catch { return true; } })();
|
| 484 |
+
if (!_inFrame) {
|
| 485 |
+
setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100);
|
| 486 |
+
}
|
| 487 |
+
</script>
|
| 488 |
</body></html>`;
|
| 489 |
}
|
| 490 |
|
|
|
|
| 689 |
const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
|
| 690 |
const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
|
| 691 |
const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
|
| 692 |
+
// Server-side value may be stale if privacy detection raced — syncPrivacy() corrects it.
|
| 693 |
+
let SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
|
| 694 |
+
|
| 695 |
+
function applyLinkTargets() {
|
| 696 |
+
const openInNewTab = !SPACE_IS_PRIVATE && (inEmbeddedApp || isDirectHfSpaceHost);
|
| 697 |
+
document.querySelectorAll('a[data-space-link]').forEach((a) => {
|
| 698 |
+
if (openInNewTab) {
|
| 699 |
+
a.setAttribute('target', '_blank');
|
| 700 |
+
a.setAttribute('rel', 'noopener noreferrer');
|
| 701 |
+
} else {
|
| 702 |
+
a.removeAttribute('target');
|
| 703 |
+
a.removeAttribute('rel');
|
| 704 |
+
}
|
| 705 |
+
});
|
| 706 |
+
}
|
| 707 |
+
applyLinkTargets();
|
| 708 |
+
|
| 709 |
+
function syncPrivacy() {
|
| 710 |
+
return fetch('/api/is-private', { cache: 'no-store' })
|
| 711 |
+
.then(r => r.json())
|
| 712 |
+
.then(d => {
|
| 713 |
+
if (d.isPrivate !== SPACE_IS_PRIVATE) {
|
| 714 |
+
SPACE_IS_PRIVATE = d.isPrivate;
|
| 715 |
+
applyLinkTargets();
|
| 716 |
+
}
|
| 717 |
+
return d.isPrivate;
|
| 718 |
+
})
|
| 719 |
+
.catch(() => SPACE_IS_PRIVATE);
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
if (isDirectHfSpaceHost) {
|
| 723 |
+
syncPrivacy().then(isPrivate => {
|
| 724 |
+
if (isPrivate) {
|
| 725 |
+
setTimeout(syncPrivacy, 8000);
|
| 726 |
+
setTimeout(syncPrivacy, 16000);
|
| 727 |
+
}
|
| 728 |
+
});
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
// Private redirect — only when NOT in iframe (huggingface.co has X-Frame-Options: DENY)
|
| 732 |
if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
|
| 733 |
const notice = document.createElement('div');
|
| 734 |
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';
|
|
|
|
| 736 |
document.body.appendChild(notice);
|
| 737 |
setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
|
| 738 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
</script>
|
| 740 |
</body>
|
| 741 |
</html>`;
|
|
|
|
| 745 |
const parsed = new URL(req.url, "http://localhost");
|
| 746 |
const path = parsed.pathname;
|
| 747 |
|
| 748 |
+
// Lightweight endpoint for client-side privacy fallback.
|
| 749 |
+
// Called by dashboard JS to correct stale server-rendered SPACE_IS_PRIVATE value.
|
| 750 |
+
// No auth required — not sensitive.
|
| 751 |
+
if (path === "/api/is-private") {
|
| 752 |
+
if (!_privacyDetectionDone) await privacyDetectionReady;
|
| 753 |
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
| 754 |
+
return res.end(JSON.stringify({ isPrivate: SPACE_IS_PRIVATE }));
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
if (path === LOGIN_PATH) {
|
| 758 |
await handleLogin(req, res, parsed);
|
| 759 |
return;
|
|
|
|
| 763 |
// Intercepts browser HTML requests from raw .hf.space hosts when the Space is private.
|
| 764 |
// /health and /status are always exempt so uptime monitors keep working.
|
| 765 |
const isHtmlReq = (req.headers.accept || "").includes("text/html");
|
| 766 |
+
|
| 767 |
+
// RACE CONDITION FIX: await privacy detection before computing redirect logic.
|
| 768 |
+
// Without this, the fail-secure default (SPACE_IS_PRIVATE=true when SPACE_ID is set)
|
| 769 |
+
// causes public spaces to redirect during the brief window before API detection completes.
|
| 770 |
+
if (isHtmlReq && !_privacyDetectionDone) {
|
| 771 |
+
await Promise.race([
|
| 772 |
+
privacyDetectionReady,
|
| 773 |
+
new Promise((r) => setTimeout(r, 1500)),
|
| 774 |
+
]);
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
// In-app navigation from same origin or HF App iframe — skip private redirect.
|
| 778 |
+
const referer = req.headers.referer || req.headers.referrer || "";
|
| 779 |
+
const isSameOriginNav = !!(referer && typeof req.headers.host === "string" &&
|
| 780 |
+
referer.startsWith(`https://${req.headers.host}`));
|
| 781 |
+
const isFromHFApp = !!(referer && (
|
| 782 |
+
referer.startsWith("https://huggingface.co") ||
|
| 783 |
+
referer.startsWith("https://hf.co")
|
| 784 |
+
));
|
| 785 |
+
|
| 786 |
const isDirectHfSpaceReq = SPACE_IS_PRIVATE &&
|
| 787 |
HF_SPACE_URL &&
|
| 788 |
isHtmlReq &&
|
| 789 |
+
!isSameOriginNav &&
|
| 790 |
+
!isFromHFApp &&
|
| 791 |
typeof req.headers.host === "string" &&
|
| 792 |
req.headers.host.endsWith(".hf.space");
|
| 793 |
|