somratpro commited on
Commit
6b586fa
Β·
1 Parent(s): 0d39314

refactor: move UptimeRobot configuration from client-side UI to server-side environment secret

Browse files
.env.example CHANGED
@@ -73,6 +73,11 @@ OPENCODE_ALLOW_ALL_MODELS=true
73
  # Get it from: https://dash.cloudflare.com/profile/api-tokens
74
  # CLOUDFLARE_WORKERS_TOKEN=xxx
75
 
 
 
 
 
 
76
  # ============================================================================
77
  # Database Backup Configuration
78
  # ============================================================================
@@ -104,8 +109,11 @@ BETTER_AUTH_SECRET=your-random-secret-here-minimum-32-characters
104
  # Monitoring & Uptime
105
  # ============================================================================
106
 
 
 
 
 
107
  # Optional: Webhook URL for restart/failure alerts
108
- # Useful for UptimeRobot or similar monitoring services
109
  # WEBHOOK_URL=https://uptime-robot-webhook-url
110
 
111
  # ============================================================================
 
73
  # Get it from: https://dash.cloudflare.com/profile/api-tokens
74
  # CLOUDFLARE_WORKERS_TOKEN=xxx
75
 
76
+ # Extra domains to proxy, merged with built-in defaults (Telegram, Discord, WhatsApp,
77
+ # Facebook, Google). Comma-separated. Set to "*" to proxy ALL external traffic.
78
+ # Leave unset to proxy only the built-in default domains.
79
+ # CLOUDFLARE_PROXY_DOMAINS=api.sendgrid.com,slack.com
80
+
81
  # ============================================================================
82
  # Database Backup Configuration
83
  # ============================================================================
 
109
  # Monitoring & Uptime
110
  # ============================================================================
111
 
112
+ # UptimeRobot keep-alive: add your Main API key (NOT Read-only or Monitor-specific).
113
+ # Monitor is created automatically at boot. Status shown on the dashboard.
114
+ # UPTIMEROBOT_API_KEY=ur_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
115
+
116
  # Optional: Webhook URL for restart/failure alerts
 
117
  # WEBHOOK_URL=https://uptime-robot-webhook-url
118
 
119
  # ============================================================================
README.md CHANGED
@@ -16,6 +16,8 @@ secrets:
16
  description: Google Gemini API key for Gemini-powered agents.
17
  - name: OPENAI_API_KEY
18
  description: OpenAI API key for GPT-powered agents.
 
 
19
  ---
20
 
21
  <!-- Badges -->
@@ -48,7 +50,7 @@ secrets:
48
  - ⚑ **One-click deploy:** Duplicate the Space and add your API key β€” nothing else needed to get started.
49
  - πŸ’Ύ **Persistent Database:** PostgreSQL database auto-backed up to a private HF Dataset and restored on every restart β€” no data loss.
50
  - πŸ“Š **Visual Dashboard:** Real-time status dashboard at `/` with Paperclip service health, backup status, and uptime.
51
- - ⏰ **Keep-Alive:** Set up a one-time UptimeRobot monitor from the dashboard to prevent free Spaces from sleeping.
52
  - 🌐 **Cloudflare Proxy:** Auto-provisions a Cloudflare Worker proxy for blocked outbound connections.
53
  - πŸ”’ **Secure by Default:** Auth secrets randomly generated on first boot and persisted across restarts.
54
  - 🏠 **100% HF-Native:** Runs entirely on Hugging Face's free infrastructure.
@@ -141,7 +143,7 @@ HuggingClip will:
141
  | :--- | :--- | :--- |
142
  | `CLOUDFLARE_WORKERS_TOKEN` | β€” | Cloudflare API token |
143
  | `CLOUDFLARE_ACCOUNT_ID` | auto | Optional account ID override |
144
- | `CLOUDFLARE_PROXY_DOMAINS` | `*` | Domains to proxy (or `*` for all external) |
145
 
146
  ## πŸ’Ύ Database Backup *(Optional)*
147
 
@@ -160,21 +162,7 @@ HuggingClip automatically backs up your Paperclip PostgreSQL database to a priva
160
 
161
  ## πŸ’“ Staying Alive *(Recommended on Free HF Spaces)*
162
 
163
- Free Hugging Face Spaces can sleep after inactivity. Set up an external UptimeRobot monitor from the dashboard to keep your Space awake.
164
-
165
- Use the **Main API key** from UptimeRobot β€” not the Read-only key or a Monitor-specific key.
166
-
167
- **Setup:**
168
-
169
- 1. Open the dashboard at `/`.
170
- 2. Find **Keep Space Awake**.
171
- 3. Paste your UptimeRobot **Main API key**.
172
- 4. Click **Create Monitor**.
173
-
174
- HuggingClip creates a monitor for `https://your-space.hf.space/health`. UptimeRobot pings it from outside HF every 5 minutes.
175
-
176
- > [!NOTE]
177
- > This works for **public** Spaces only. Private Spaces cannot be reached by external monitors.
178
 
179
  ## πŸ’» Local Development
180
 
@@ -253,7 +241,7 @@ Verify `HF_TOKEN` is set and has write access. Check the dashboard backup status
253
  `HF_TOKEN` is not set. Add it and the next restart will restore from backup. The backup also needs to have been run at least once before the restart.
254
 
255
  **Space keeps sleeping**
256
- Open `/` and use **Keep Space Awake** to create an external UptimeRobot monitor. Requires a public Space.
257
 
258
  **Paperclip unreachable (502 errors)**
259
  Wait 60–90s after boot for Paperclip to initialize. If it stays unreachable, check logs for PostgreSQL connection errors or memory issues.
 
16
  description: Google Gemini API key for Gemini-powered agents.
17
  - name: OPENAI_API_KEY
18
  description: OpenAI API key for GPT-powered agents.
19
+ - name: UPTIMEROBOT_API_KEY
20
+ description: UptimeRobot API key for automatic monitor setup.
21
  ---
22
 
23
  <!-- Badges -->
 
50
  - ⚑ **One-click deploy:** Duplicate the Space and add your API key β€” nothing else needed to get started.
51
  - πŸ’Ύ **Persistent Database:** PostgreSQL database auto-backed up to a private HF Dataset and restored on every restart β€” no data loss.
52
  - πŸ“Š **Visual Dashboard:** Real-time status dashboard at `/` with Paperclip service health, backup status, and uptime.
53
+ - ⏰ **Keep-Alive:** Add `UPTIMEROBOT_API_KEY` as a Space secret and the monitor is created automatically at boot β€” no manual setup.
54
  - 🌐 **Cloudflare Proxy:** Auto-provisions a Cloudflare Worker proxy for blocked outbound connections.
55
  - πŸ”’ **Secure by Default:** Auth secrets randomly generated on first boot and persisted across restarts.
56
  - 🏠 **100% HF-Native:** Runs entirely on Hugging Face's free infrastructure.
 
143
  | :--- | :--- | :--- |
144
  | `CLOUDFLARE_WORKERS_TOKEN` | β€” | Cloudflare API token |
145
  | `CLOUDFLARE_ACCOUNT_ID` | auto | Optional account ID override |
146
+ | `CLOUDFLARE_PROXY_DOMAINS` | β€” | Extra domains to proxy, merged with built-in defaults. Set to `*` to proxy all external traffic. |
147
 
148
  ## πŸ’Ύ Database Backup *(Optional)*
149
 
 
162
 
163
  ## πŸ’“ Staying Alive *(Recommended on Free HF Spaces)*
164
 
165
+ Add your [UptimeRobot](https://uptimerobot.com/) **Main API key** (not the Read-only or Monitor-specific key) as a Space secret named `UPTIMEROBOT_API_KEY`. HuggingClip will automatically create a monitor for `https://your-space.hf.space/health` at boot. The dashboard shows the current status (configured, setting up, or failed).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  ## πŸ’» Local Development
168
 
 
241
  `HF_TOKEN` is not set. Add it and the next restart will restore from backup. The backup also needs to have been run at least once before the restart.
242
 
243
  **Space keeps sleeping**
244
+ Add `UPTIMEROBOT_API_KEY` as a Space secret to enable automatic keep-awake monitoring.
245
 
246
  **Paperclip unreachable (502 errors)**
247
  Wait 60–90s after boot for Paperclip to initialize. If it stays unreachable, check logs for PostgreSQL connection errors or memory issues.
cloudflare-proxy-setup.py CHANGED
@@ -12,13 +12,33 @@ from pathlib import Path
12
  API_BASE = "https://api.cloudflare.com/client/v4"
13
  ENV_FILE = Path("/tmp/huggingclaw-cloudflare-proxy.env")
14
  DEFAULT_ALLOWED = [
 
15
  "api.telegram.org",
16
  "discord.com",
17
  "discordapp.com",
18
  "gateway.discord.gg",
19
  "status.discord.com",
20
  "web.whatsapp.com",
 
21
  "graph.facebook.com",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  "googleapis.com",
23
  "google.com",
24
  "googleusercontent.com",
@@ -207,10 +227,17 @@ def main() -> int:
207
 
208
  worker_name = derive_worker_name()
209
  allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
210
- allow_proxy_all = not allowed_raw or allowed_raw == "*"
211
- allowed_targets = DEFAULT_ALLOWED if not allowed_raw or allow_proxy_all else [
212
- value.strip() for value in allowed_raw.split(",") if value.strip()
213
- ]
 
 
 
 
 
 
 
214
  proxy_secret = existing_secret or secrets.token_urlsafe(24)
215
  worker_source = render_worker(proxy_secret, allowed_targets, allow_proxy_all)
216
 
 
12
  API_BASE = "https://api.cloudflare.com/client/v4"
13
  ENV_FILE = Path("/tmp/huggingclaw-cloudflare-proxy.env")
14
  DEFAULT_ALLOWED = [
15
+ # Messaging
16
  "api.telegram.org",
17
  "discord.com",
18
  "discordapp.com",
19
  "gateway.discord.gg",
20
  "status.discord.com",
21
  "web.whatsapp.com",
22
+ # Social β€” confirmed/likely blocked by HF firewall
23
  "graph.facebook.com",
24
+ "graph.instagram.com",
25
+ "api.twitter.com",
26
+ "api.x.com",
27
+ "upload.twitter.com",
28
+ "api.linkedin.com",
29
+ "www.linkedin.com",
30
+ "open.tiktokapis.com",
31
+ "oauth.reddit.com",
32
+ # Video
33
+ "youtube.com",
34
+ "www.youtube.com",
35
+ # AI APIs
36
+ "api.openai.com",
37
+ # Email HTTP APIs (SMTP ports are blocked; use these instead)
38
+ "api.resend.com",
39
+ "api.sendgrid.com",
40
+ "api.mailgun.net",
41
+ # Google
42
  "googleapis.com",
43
  "google.com",
44
  "googleusercontent.com",
 
227
 
228
  worker_name = derive_worker_name()
229
  allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
230
+ allow_proxy_all = allowed_raw == "*"
231
+ if allow_proxy_all:
232
+ allowed_targets = DEFAULT_ALLOWED
233
+ else:
234
+ extra = [v.strip() for v in allowed_raw.split(",") if v.strip()]
235
+ seen = set(DEFAULT_ALLOWED)
236
+ allowed_targets = list(DEFAULT_ALLOWED)
237
+ for domain in extra:
238
+ if domain not in seen:
239
+ allowed_targets.append(domain)
240
+ seen.add(domain)
241
  proxy_secret = existing_secret or secrets.token_urlsafe(24)
242
  worker_source = render_worker(proxy_secret, allowed_targets, allow_proxy_all)
243
 
cloudflare-proxy.js CHANGED
@@ -23,11 +23,31 @@ if (
23
 
24
  const DEBUG = process.env.CLOUDFLARE_PROXY_DEBUG === "true";
25
  const PROXY_SHARED_SECRET = (process.env.CLOUDFLARE_PROXY_SECRET || "").trim();
26
- const PROXY_DOMAINS = process.env.CLOUDFLARE_PROXY_DOMAINS || "*";
27
- const BLOCKED_DOMAINS = PROXY_DOMAINS.split(",")
28
- .map((domain) => domain.trim())
29
- .filter(Boolean);
30
- const PROXY_ALL = PROXY_DOMAINS === "*";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  if (PROXY_URL) {
33
  try {
 
23
 
24
  const DEBUG = process.env.CLOUDFLARE_PROXY_DEBUG === "true";
25
  const PROXY_SHARED_SECRET = (process.env.CLOUDFLARE_PROXY_SECRET || "").trim();
26
+ const DEFAULT_PROXY_DOMAINS = [
27
+ "api.telegram.org", "discord.com", "discordapp.com",
28
+ "gateway.discord.gg", "status.discord.com", "web.whatsapp.com",
29
+ "graph.facebook.com", "graph.instagram.com",
30
+ "api.twitter.com", "api.x.com", "upload.twitter.com",
31
+ "api.linkedin.com", "www.linkedin.com",
32
+ "open.tiktokapis.com", "oauth.reddit.com",
33
+ "youtube.com", "www.youtube.com",
34
+ "api.openai.com",
35
+ "api.resend.com", "api.sendgrid.com", "api.mailgun.net",
36
+ "googleapis.com", "google.com", "googleusercontent.com", "gstatic.com",
37
+ ];
38
+ const PROXY_DOMAINS_RAW = (process.env.CLOUDFLARE_PROXY_DOMAINS || "").trim();
39
+ const PROXY_ALL = PROXY_DOMAINS_RAW === "*";
40
+ let BLOCKED_DOMAINS;
41
+ if (PROXY_ALL) {
42
+ BLOCKED_DOMAINS = [];
43
+ } else {
44
+ const extra = PROXY_DOMAINS_RAW.split(",").map((d) => d.trim()).filter(Boolean);
45
+ const seen = new Set(DEFAULT_PROXY_DOMAINS);
46
+ BLOCKED_DOMAINS = [...DEFAULT_PROXY_DOMAINS];
47
+ for (const d of extra) {
48
+ if (!seen.has(d)) { BLOCKED_DOMAINS.push(d); seen.add(d); }
49
+ }
50
+ }
51
 
52
  if (PROXY_URL) {
53
  try {
health-server.js CHANGED
@@ -1,6 +1,5 @@
1
  // Single public entrypoint for HF Spaces: local dashboard + reverse proxy to Paperclip.
2
  const http = require("http");
3
- const https = require("https");
4
  const fs = require("fs");
5
  const net = require("net");
6
 
@@ -12,13 +11,8 @@ const startTime = Date.now();
12
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
13
  const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "86400";
14
 
15
- const UPTIMEROBOT_SETUP_ENABLED =
16
- String(process.env.UPTIMEROBOT_SETUP_ENABLED || "true").toLowerCase() === "true";
17
- const UPTIMEROBOT_RATE_WINDOW_MS = 60 * 1000;
18
- const UPTIMEROBOT_RATE_MAX = Number(process.env.UPTIMEROBOT_RATE_LIMIT_PER_MINUTE || 5);
19
- const SPACE_VISIBILITY_TTL_MS = 10 * 60 * 1000;
20
- const spaceVisibilityCache = new Map();
21
- const uptimerobotRateMap = new Map();
22
 
23
  // ============================================================================
24
  // URL helpers
@@ -33,169 +27,20 @@ function parseRequestUrl(url) {
33
  }
34
 
35
  function isLocalRoute(pathname) {
36
- return (
37
- pathname === "/health" ||
38
- pathname === "/status" ||
39
- pathname === "/uptimerobot/setup"
40
- );
41
  }
42
 
43
  // ============================================================================
44
  // UptimeRobot helpers
45
  // ============================================================================
46
 
47
- function getRequesterIp(req) {
48
- const forwarded = req.headers["x-forwarded-for"];
49
- if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
50
- if (Array.isArray(forwarded) && forwarded.length > 0) return String(forwarded[0]).split(",")[0].trim();
51
- return req.socket.remoteAddress || "unknown";
52
- }
53
-
54
- function isRateLimited(req) {
55
- const now = Date.now();
56
- const ip = getRequesterIp(req);
57
- const bucket = uptimerobotRateMap.get(ip) || [];
58
- const recent = bucket.filter((ts) => now - ts < UPTIMEROBOT_RATE_WINDOW_MS);
59
- recent.push(now);
60
- uptimerobotRateMap.set(ip, recent);
61
- return recent.length > UPTIMEROBOT_RATE_MAX;
62
- }
63
-
64
- setInterval(() => {
65
- const cutoff = Date.now() - UPTIMEROBOT_RATE_WINDOW_MS;
66
- for (const [ip, timestamps] of uptimerobotRateMap) {
67
- if (timestamps.every((ts) => ts < cutoff)) uptimerobotRateMap.delete(ip);
68
- }
69
- }, 5 * 60 * 1000).unref();
70
-
71
- function isAllowedUptimeSetupOrigin(req) {
72
- const host = String(req.headers.host || "").toLowerCase();
73
- const origin = String(req.headers.origin || "").toLowerCase();
74
- const referer = String(req.headers.referer || "").toLowerCase();
75
- if (!host) return false;
76
- if (origin && !origin.includes(host)) return false;
77
- if (referer && !referer.includes(host)) return false;
78
- return true;
79
- }
80
-
81
- function isValidUptimeApiKey(key) {
82
- return /^[A-Za-z0-9_-]{20,128}$/.test(String(key || ""));
83
- }
84
-
85
- function decodeJwtPayload(token) {
86
- try {
87
- const parts = String(token || "").split(".");
88
- if (parts.length < 2) return null;
89
- const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
90
- const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
91
- return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
92
- } catch {
93
- return null;
94
- }
95
- }
96
-
97
- function getSpaceRef(parsedUrl) {
98
- const signedToken = parsedUrl.searchParams.get("__sign");
99
- if (!signedToken) return null;
100
- const payload = decodeJwtPayload(signedToken);
101
- const subject = payload && payload.sub;
102
- const match =
103
- typeof subject === "string"
104
- ? subject.match(/^\/spaces\/([^/]+)\/([^/]+)$/)
105
- : null;
106
- if (!match) return null;
107
- return { owner: match[1], repo: match[2] };
108
- }
109
-
110
- function fetchStatusCode(url) {
111
- return new Promise((resolve, reject) => {
112
- const req = https.get(
113
- url,
114
- { headers: { "user-agent": "HuggingClip/1.0", accept: "application/json" } },
115
- (res) => { res.resume(); resolve(res.statusCode || 0); },
116
- );
117
- req.on("error", reject);
118
- req.setTimeout(5000, () => req.destroy(new Error("timeout")));
119
- });
120
- }
121
-
122
- async function resolveSpaceIsPrivate(parsedUrl) {
123
- const ref = getSpaceRef(parsedUrl);
124
- if (!ref) return false;
125
- const cacheKey = `${ref.owner}/${ref.repo}`;
126
- const cached = spaceVisibilityCache.get(cacheKey);
127
- if (cached && Date.now() - cached.timestamp < SPACE_VISIBILITY_TTL_MS) return cached.isPrivate;
128
  try {
129
- const statusCode = await fetchStatusCode(`https://huggingface.co/api/spaces/${ref.owner}/${ref.repo}`);
130
- const isPrivate = statusCode === 401 || statusCode === 403 || statusCode === 404;
131
- spaceVisibilityCache.set(cacheKey, { isPrivate, timestamp: Date.now() });
132
- return isPrivate;
133
- } catch {
134
- if (cached) return cached.isPrivate;
135
- return false;
136
- }
137
- }
138
-
139
- function postUptimeRobot(path, form) {
140
- const body = new URLSearchParams(form).toString();
141
- return new Promise((resolve, reject) => {
142
- const request = https.request(
143
- {
144
- hostname: "api.uptimerobot.com",
145
- port: 443,
146
- method: "POST",
147
- path,
148
- headers: {
149
- "Content-Type": "application/x-www-form-urlencoded",
150
- "Content-Length": Buffer.byteLength(body),
151
- },
152
- },
153
- (response) => {
154
- let raw = "";
155
- response.setEncoding("utf8");
156
- response.on("data", (chunk) => { raw += chunk; });
157
- response.on("end", () => {
158
- try { resolve(JSON.parse(raw)); }
159
- catch { reject(new Error("Unexpected response from UptimeRobot")); }
160
- });
161
- },
162
- );
163
- request.on("error", reject);
164
- request.write(body);
165
- request.end();
166
- });
167
- }
168
-
169
- async function createUptimeRobotMonitor(apiKey, host) {
170
- const cleanHost = String(host || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
171
- if (!cleanHost) throw new Error("Missing Space host.");
172
-
173
- const monitorUrl = `https://${cleanHost}/health`;
174
- const existing = await postUptimeRobot("/v2/getMonitors", {
175
- api_key: apiKey, format: "json", logs: "0",
176
- response_times: "0", response_times_limit: "1",
177
- });
178
-
179
- const existingMonitor = Array.isArray(existing.monitors)
180
- ? existing.monitors.find((m) => m.url === monitorUrl)
181
- : null;
182
-
183
- if (existingMonitor) {
184
- return { created: false, message: `Monitor already exists for ${monitorUrl}` };
185
- }
186
-
187
- const created = await postUptimeRobot("/v2/newMonitor", {
188
- api_key: apiKey, format: "json", type: "1",
189
- friendly_name: `HuggingClip ${cleanHost}`,
190
- url: monitorUrl, interval: "300",
191
- });
192
-
193
- if (created.stat !== "ok") {
194
- const message = created?.error?.message || created?.message || "Failed to create UptimeRobot monitor.";
195
- throw new Error(message);
196
- }
197
-
198
- return { created: true, message: `Monitor created for ${monitorUrl}` };
199
  }
200
 
201
  // ============================================================================
@@ -255,27 +100,25 @@ function formatUptime(seconds) {
255
  // ============================================================================
256
 
257
  function renderDashboard(initialData) {
258
- const keepAwakeHtml = !UPTIMEROBOT_SETUP_ENABLED
259
- ? `<div class="helper-summary">UptimeRobot setup is disabled for this Space.</div>`
260
- : initialData.spacePrivate
261
- ? `<div class="helper-summary"><strong>Space is private.</strong> External monitors cannot access private HF health URLs. Switch to a public Space to use keep-awake.</div>`
262
- : `
263
- <div id="uptimerobot-summary" class="helper-summary">
264
- One-time setup for public Spaces. Paste your UptimeRobot <strong>Main API key</strong> to create the monitor.
265
- </div>
266
- <button id="uptimerobot-toggle" class="helper-toggle" type="button">Set Up Monitor</button>
267
- <div id="uptimerobot-shell" class="helper-shell hidden">
268
- <div class="helper-copy">
269
- Do <strong>not</strong> use the Read-only API key or a Monitor-specific API key.
270
- </div>
271
- <div class="helper-row">
272
- <input id="uptimerobot-key" class="helper-input" type="password"
273
- placeholder="Paste your UptimeRobot Main API key" autocomplete="off" />
274
- <button id="uptimerobot-btn" class="helper-button" type="button">Create Monitor</button>
275
- </div>
276
- <div class="helper-note">One-time setup. Your key is only used to create the monitor for this Space.</div>
277
- </div>
278
- <div id="uptimerobot-result" class="helper-result"></div>`;
279
 
280
  const syncStatus = initialData.sync;
281
  const hasBackup = HF_BACKUP_ENABLED;
@@ -443,9 +286,12 @@ function renderDashboard(initialData) {
443
  margin-top: 14px; padding: 12px 14px; border-radius: 12px;
444
  background: rgba(255,255,255,0.03); color: var(--text-dim);
445
  font-size: 0.9rem; line-height: 1.5;
 
446
  }
447
  .helper-summary strong { color: var(--text); }
 
448
  .helper-summary.success { background: rgba(16,185,129,0.08); }
 
449
  .helper-toggle {
450
  margin-top: 14px; display: inline-flex; align-items: center; justify-content: center;
451
  background: rgba(255,255,255,0.04); color: var(--text);
@@ -549,10 +395,6 @@ function renderDashboard(initialData) {
549
  </div>
550
 
551
  <script>
552
- const KEEP_AWAKE_PRIVATE = ${initialData.spacePrivate ? "true" : "false"};
553
- const KEEP_AWAKE_SETUP_ENABLED = ${UPTIMEROBOT_SETUP_ENABLED ? "true" : "false"};
554
- const monitorStateKey = 'huggingclip_uptimerobot_v1';
555
-
556
  function getCurrentSearch() { return window.location.search || ''; }
557
 
558
  function renderSyncBadge(status, lastSyncTime, lastError) {
@@ -599,98 +441,13 @@ function renderDashboard(initialData) {
599
  }
600
  }
601
 
602
- function setMonitorUiState(isConfigured) {
603
- const summary = document.getElementById('uptimerobot-summary');
604
- const shell = document.getElementById('uptimerobot-shell');
605
- const toggle = document.getElementById('uptimerobot-toggle');
606
- if (!summary || !shell || !toggle) return;
607
- if (isConfigured) {
608
- summary.classList.add('success');
609
- summary.innerHTML = '<strong>Already set up.</strong> Your UptimeRobot monitor should keep this public Space awake.';
610
- shell.classList.add('hidden');
611
- toggle.textContent = 'Set Up Again';
612
- } else {
613
- summary.classList.remove('success');
614
- summary.innerHTML = 'One-time setup for public Spaces. Paste your UptimeRobot <strong>Main API key</strong> to create the monitor.';
615
- toggle.textContent = 'Set Up Monitor';
616
- }
617
- }
618
-
619
- function restoreMonitorUiState() {
620
- try { setMonitorUiState(window.localStorage.getItem(monitorStateKey) === 'done'); }
621
- catch { setMonitorUiState(false); }
622
- }
623
-
624
- async function setupUptimeRobot() {
625
- const input = document.getElementById('uptimerobot-key');
626
- const button = document.getElementById('uptimerobot-btn');
627
- const result = document.getElementById('uptimerobot-result');
628
- const apiKey = input.value.trim();
629
- if (!apiKey) {
630
- result.className = 'helper-result error';
631
- result.textContent = 'Paste your UptimeRobot Main API key first.';
632
- return;
633
- }
634
- button.disabled = true;
635
- button.textContent = 'Creating...';
636
- result.className = 'helper-result';
637
- result.textContent = '';
638
- try {
639
- const res = await fetch('/uptimerobot/setup' + getCurrentSearch(), {
640
- method: 'POST',
641
- headers: { 'Content-Type': 'application/json' },
642
- body: JSON.stringify({ apiKey }),
643
- });
644
- const data = await res.json();
645
- if (!res.ok) throw new Error(data.message || 'Failed to create monitor.');
646
- result.className = 'helper-result ok';
647
- result.textContent = data.message || 'UptimeRobot monitor is ready.';
648
- input.value = '';
649
- try { window.localStorage.setItem(monitorStateKey, 'done'); } catch {}
650
- setMonitorUiState(true);
651
- document.getElementById('uptimerobot-shell').classList.add('hidden');
652
- } catch (error) {
653
- result.className = 'helper-result error';
654
- result.textContent = error.message || 'Failed to create monitor.';
655
- } finally {
656
- button.disabled = false;
657
- button.textContent = 'Create Monitor';
658
- }
659
- }
660
-
661
  updateStatus();
662
  setInterval(updateStatus, 30000);
663
-
664
- if (KEEP_AWAKE_SETUP_ENABLED && !KEEP_AWAKE_PRIVATE) {
665
- restoreMonitorUiState();
666
- const toggleBtn = document.getElementById('uptimerobot-toggle');
667
- const createBtn = document.getElementById('uptimerobot-btn');
668
- if (toggleBtn) toggleBtn.addEventListener('click', () => {
669
- document.getElementById('uptimerobot-shell').classList.toggle('hidden');
670
- });
671
- if (createBtn) createBtn.addEventListener('click', setupUptimeRobot);
672
- }
673
  </script>
674
  </body>
675
  </html>`;
676
  }
677
 
678
- // ============================================================================
679
- // Request body reader
680
- // ============================================================================
681
-
682
- function readRequestBody(req) {
683
- return new Promise((resolve, reject) => {
684
- let body = "";
685
- req.on("data", (chunk) => {
686
- body += chunk;
687
- if (body.length > 64 * 1024) { reject(new Error("Request too large")); req.destroy(); }
688
- });
689
- req.on("end", () => resolve(body));
690
- req.on("error", reject);
691
- });
692
- }
693
-
694
  // ============================================================================
695
  // HTTP Proxy helpers
696
  // ============================================================================
@@ -803,61 +560,14 @@ const server = http.createServer((req, res) => {
803
  return;
804
  }
805
 
806
- // ── UptimeRobot setup endpoint ─────────────────────────────────────────────
807
- if (pathname === "/uptimerobot/setup") {
808
- if (req.method !== "POST") {
809
- res.writeHead(405, { "Content-Type": "application/json" });
810
- res.end(JSON.stringify({ message: "Method not allowed" }));
811
- return;
812
- }
813
- void (async () => {
814
- try {
815
- if (!UPTIMEROBOT_SETUP_ENABLED) {
816
- res.writeHead(403, { "Content-Type": "application/json" });
817
- res.end(JSON.stringify({ message: "Uptime setup is disabled." }));
818
- return;
819
- }
820
- if (isRateLimited(req)) {
821
- res.writeHead(429, { "Content-Type": "application/json" });
822
- res.end(JSON.stringify({ message: "Too many requests." }));
823
- return;
824
- }
825
- if (!isAllowedUptimeSetupOrigin(req)) {
826
- res.writeHead(403, { "Content-Type": "application/json" });
827
- res.end(JSON.stringify({ message: "Invalid request origin." }));
828
- return;
829
- }
830
- const body = await readRequestBody(req);
831
- const parsed = JSON.parse(body || "{}");
832
- const apiKey = String(parsed.apiKey || "").trim();
833
- if (!isValidUptimeApiKey(apiKey)) {
834
- res.writeHead(400, { "Content-Type": "application/json" });
835
- res.end(JSON.stringify({ message: "A valid API key is required." }));
836
- return;
837
- }
838
- const result = await createUptimeRobotMonitor(apiKey, req.headers.host);
839
- res.writeHead(200, { "Content-Type": "application/json" });
840
- res.end(JSON.stringify(result));
841
- } catch (error) {
842
- res.writeHead(400, { "Content-Type": "application/json" });
843
- res.end(JSON.stringify({ message: error?.message || "Failed to create UptimeRobot monitor." }));
844
- }
845
- })();
846
- return;
847
- }
848
-
849
  // ── Dashboard (root) ───────────────────────────────────────────────────────
850
  if (pathname === "/" || pathname === "") {
851
  void (async () => {
852
- const [paperclipStatus, spacePrivate] = await Promise.all([
853
- checkPaperclipHealth(),
854
- resolveSpaceIsPrivate(parsedUrl),
855
- ]);
856
  const initialData = {
857
  paperclipRunning: paperclipStatus.status === "running",
858
  sync: readSyncStatus(),
859
  inviteUrl: readInviteUrl(),
860
- spacePrivate,
861
  };
862
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
863
  res.end(renderDashboard(initialData));
 
1
  // Single public entrypoint for HF Spaces: local dashboard + reverse proxy to Paperclip.
2
  const http = require("http");
 
3
  const fs = require("fs");
4
  const net = require("net");
5
 
 
11
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
12
  const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "86400";
13
 
14
+ const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingclip-uptimerobot-status.json";
15
+ const UPTIMEROBOT_API_KEY_SET = !!process.env.UPTIMEROBOT_API_KEY;
 
 
 
 
 
16
 
17
  // ============================================================================
18
  // URL helpers
 
27
  }
28
 
29
  function isLocalRoute(pathname) {
30
+ return pathname === "/health" || pathname === "/status";
 
 
 
 
31
  }
32
 
33
  // ============================================================================
34
  // UptimeRobot helpers
35
  // ============================================================================
36
 
37
+ function getUptimeRobotStatus() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  try {
39
+ if (fs.existsSync(UPTIMEROBOT_STATUS_FILE)) {
40
+ return JSON.parse(fs.readFileSync(UPTIMEROBOT_STATUS_FILE, "utf8"));
41
+ }
42
+ } catch {}
43
+ return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  // ============================================================================
 
100
  // ============================================================================
101
 
102
  function renderDashboard(initialData) {
103
+ const uptimerobotStatus = getUptimeRobotStatus();
104
+ let keepAwakeHtml;
105
+ if (uptimerobotStatus?.configured) {
106
+ keepAwakeHtml = `<div class="helper-summary success">
107
+ <span class="status-badge status-online"><div class="pulse"></div>Configured</span>
108
+ <span>UptimeRobot monitor active for <code>${uptimerobotStatus.url || "your /health endpoint"}</code>.</span>
109
+ </div>`;
110
+ } else if (uptimerobotStatus?.configured === false) {
111
+ keepAwakeHtml = `<div class="helper-summary error">
112
+ <span class="status-badge status-error">Failed</span>
113
+ <span>Monitor setup failed. Check Space logs.</span>
114
+ </div>`;
115
+ } else if (UPTIMEROBOT_API_KEY_SET) {
116
+ keepAwakeHtml = `<div class="helper-summary"><span class="status-badge status-syncing"><div class="pulse" style="background:#3b82f6"></div>Setting up</span> Setting up UptimeRobot monitor...</div>`;
117
+ } else {
118
+ keepAwakeHtml = `<div class="helper-summary">
119
+ <strong>Not configured.</strong> Add <code>UPTIMEROBOT_API_KEY</code> to Space secrets to enable keep-awake monitoring.
120
+ </div>`;
121
+ }
 
 
122
 
123
  const syncStatus = initialData.sync;
124
  const hasBackup = HF_BACKUP_ENABLED;
 
286
  margin-top: 14px; padding: 12px 14px; border-radius: 12px;
287
  background: rgba(255,255,255,0.03); color: var(--text-dim);
288
  font-size: 0.9rem; line-height: 1.5;
289
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
290
  }
291
  .helper-summary strong { color: var(--text); }
292
+ .helper-summary code { background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 4px; font-size: 0.85em; color: var(--text); }
293
  .helper-summary.success { background: rgba(16,185,129,0.08); }
294
+ .helper-summary.error { background: rgba(239,68,68,0.08); }
295
  .helper-toggle {
296
  margin-top: 14px; display: inline-flex; align-items: center; justify-content: center;
297
  background: rgba(255,255,255,0.04); color: var(--text);
 
395
  </div>
396
 
397
  <script>
 
 
 
 
398
  function getCurrentSearch() { return window.location.search || ''; }
399
 
400
  function renderSyncBadge(status, lastSyncTime, lastError) {
 
441
  }
442
  }
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  updateStatus();
445
  setInterval(updateStatus, 30000);
 
 
 
 
 
 
 
 
 
 
446
  </script>
447
  </body>
448
  </html>`;
449
  }
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  // ============================================================================
452
  // HTTP Proxy helpers
453
  // ============================================================================
 
560
  return;
561
  }
562
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  // ── Dashboard (root) ───────────────────────────────────────────────────────
564
  if (pathname === "/" || pathname === "") {
565
  void (async () => {
566
+ const paperclipStatus = await checkPaperclipHealth();
 
 
 
567
  const initialData = {
568
  paperclipRunning: paperclipStatus.status === "running",
569
  sync: readSyncStatus(),
570
  inviteUrl: readInviteUrl(),
 
571
  };
572
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
573
  res.end(renderDashboard(initialData));
setup-uptimerobot.sh CHANGED
@@ -10,11 +10,12 @@ set -euo pipefail
10
  # Optional:
11
  # - UPTIMEROBOT_MONITOR_NAME: friendly name for the monitor
12
  # - UPTIMEROBOT_ALERT_CONTACTS: dash-separated alert contact IDs, e.g. "123456-789012"
13
- # - UPTIMEROBOT_INTERVAL: monitoring interval in minutes (subject to account limits)
14
 
15
  API_URL="https://api.uptimerobot.com/v2"
16
  API_KEY="${UPTIMEROBOT_API_KEY:-}"
17
  SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
 
18
 
19
  if [ -z "$API_KEY" ]; then
20
  echo "Missing UPTIMEROBOT_API_KEY."
@@ -34,8 +35,8 @@ SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN#http://}"
34
  SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
35
 
36
  MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
37
- MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-HuggingClaw ${SPACE_HOST_CLEAN}}"
38
- INTERVAL="${UPTIMEROBOT_INTERVAL:-5}"
39
 
40
  echo "Checking existing UptimeRobot monitors for ${MONITOR_URL}..."
41
  MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
@@ -50,6 +51,8 @@ MONITOR_ID=$(printf '%s' "$MONITORS_RESPONSE" | jq -r --arg url "$MONITOR_URL" '
50
  ')
51
 
52
  if [ -n "$MONITOR_ID" ]; then
 
 
53
  echo "Monitor already exists (id=${MONITOR_ID}) for ${MONITOR_URL}"
54
  exit 0
55
  fi
@@ -75,10 +78,14 @@ CREATE_RESPONSE=$(curl "${CURL_ARGS[@]}")
75
  CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
76
 
77
  if [ "$CREATE_STATUS" != "ok" ]; then
 
 
78
  echo "Failed to create monitor."
79
  printf '%s\n' "$CREATE_RESPONSE"
80
  exit 1
81
  fi
82
 
83
  NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
 
 
84
  echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
 
10
  # Optional:
11
  # - UPTIMEROBOT_MONITOR_NAME: friendly name for the monitor
12
  # - UPTIMEROBOT_ALERT_CONTACTS: dash-separated alert contact IDs, e.g. "123456-789012"
13
+ # - UPTIMEROBOT_INTERVAL: monitoring interval in seconds (default: 300 = 5 min; min: 30)
14
 
15
  API_URL="https://api.uptimerobot.com/v2"
16
  API_KEY="${UPTIMEROBOT_API_KEY:-}"
17
  SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
18
+ STATUS_FILE="/tmp/huggingclip-uptimerobot-status.json"
19
 
20
  if [ -z "$API_KEY" ]; then
21
  echo "Missing UPTIMEROBOT_API_KEY."
 
35
  SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
36
 
37
  MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
38
+ MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-HuggingClip ${SPACE_HOST_CLEAN}}"
39
+ INTERVAL="${UPTIMEROBOT_INTERVAL:-300}"
40
 
41
  echo "Checking existing UptimeRobot monitors for ${MONITOR_URL}..."
42
  MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
 
51
  ')
52
 
53
  if [ -n "$MONITOR_ID" ]; then
54
+ printf '{"configured":true,"monitorId":"%s","url":"%s","alreadyExisted":true,"timestamp":"%s"}\n' \
55
+ "$MONITOR_ID" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
56
  echo "Monitor already exists (id=${MONITOR_ID}) for ${MONITOR_URL}"
57
  exit 0
58
  fi
 
78
  CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
79
 
80
  if [ "$CREATE_STATUS" != "ok" ]; then
81
+ printf '{"configured":false,"error":"creation failed","timestamp":"%s"}\n' \
82
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
83
  echo "Failed to create monitor."
84
  printf '%s\n' "$CREATE_RESPONSE"
85
  exit 1
86
  fi
87
 
88
  NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
89
+ printf '{"configured":true,"monitorId":"%s","url":"%s","timestamp":"%s"}\n' \
90
+ "${NEW_ID:-}" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
91
  echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
start.sh CHANGED
@@ -208,6 +208,12 @@ fi
208
  # ── Health server ─────────────────────────────────────────────────────────────
209
  node /app/health-server.js &
210
  HEALTH_PID=$!
 
 
 
 
 
 
211
  sleep 2
212
 
213
  # ── Paperclip instance config ─────────────────────────────────────────────────
 
208
  # ── Health server ─────────────────────────────────────────────────────────────
209
  node /app/health-server.js &
210
  HEALTH_PID=$!
211
+
212
+ if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
213
+ echo "Setting up UptimeRobot monitor..."
214
+ bash /app/setup-uptimerobot.sh "${SPACE_HOST}" || true
215
+ fi
216
+
217
  sleep 2
218
 
219
  # ── Paperclip instance config ─────────────────────────────────────────────────