somratpro Claude Opus 4.7 commited on
Commit
ff3303a
ยท
1 Parent(s): 12bbc56

fix: set FRONTEND_URL to bare origin so CORS allow-origin matches browser

Browse files

FRONTEND_URL was set to PUBLIC_URL/app (with basePath suffix). Postiz
backend uses FRONTEND_URL as the CORS allowed-origin. Browsers send
Origin: https://host (scheme+host only, no path per the HTTP spec), so
the /app suffix caused every pre-flight and credentialed request to be
blocked โ€” making login and signup silently fail.

Fix: FRONTEND_URL = PUBLIC_URL (no /app). Email links generated by
Postiz (e.g. ${FRONTEND_URL}/auth/login) still work because health-server
now redirects any bare /auth/* path to /app/auth/*.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

.env.example CHANGED
@@ -58,7 +58,15 @@ STORAGE_PROVIDER=local
58
 
59
  # โ”€โ”€ Cloudflare proxy (Optional, fixes blocked outbound) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
60
  # CLOUDFLARE_WORKERS_TOKEN=
61
- # CLOUDFLARE_PROXY_DOMAINS=* # or comma-separated list
 
 
 
 
 
 
 
 
62
 
63
 
64
  # โ”€โ”€ Email (Optional โ€” controls signup activation flow) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
58
 
59
  # โ”€โ”€ Cloudflare proxy (Optional, fixes blocked outbound) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
60
  # CLOUDFLARE_WORKERS_TOKEN=
61
+ # Extra domains to proxy, merged with built-in defaults (Telegram, Discord, WhatsApp,
62
+ # Facebook, Google). Comma-separated. Set to "*" to proxy ALL external traffic.
63
+ # Leave unset to proxy only the built-in default domains.
64
+ # CLOUDFLARE_PROXY_DOMAINS=api.sendgrid.com,smtp.resend.com,slack.com
65
+
66
+ # โ”€โ”€ UptimeRobot keep-alive โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67
+ # Add your Main API key (NOT Read-only or Monitor-specific).
68
+ # Monitor is created automatically at boot. Status shown on the dashboard.
69
+ # UPTIMEROBOT_API_KEY=ur_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
70
 
71
 
72
  # โ”€โ”€ Email (Optional โ€” controls signup activation flow) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
README.md CHANGED
@@ -55,7 +55,7 @@ secrets:
55
  - ๐Ÿ“… **30+ Social Platforms** โ€” schedule posts to X, LinkedIn, Facebook, Threads, TikTok, YouTube, Reddit, Mastodon, Discord, Slack, Pinterest, etc.
56
  - โšก **One-click deploy** โ€” duplicate the Space, add `HF_TOKEN`, you're done.
57
  - ๐Ÿ’พ **Persistent across restarts** โ€” PostgreSQL + uploaded media auto-backed up to a private HF Dataset every 5 min and restored on boot.
58
- - ๐Ÿ’“ **Keep-Alive** โ€” built-in dashboard helper to set up an UptimeRobot monitor so scheduled posts actually fire.
59
  - ๐ŸŒ **Outbound firewall workaround** โ€” optional Cloudflare Worker proxy auto-provisioned for blocked platform APIs.
60
  - ๐Ÿ”’ **Secrets generated** โ€” `JWT_SECRET` auto-generated on first boot and persisted, no manual setup.
61
  - ๐Ÿ  **100% HF-Native** โ€” no external Postgres/Redis/storage accounts needed for the default path.
@@ -88,7 +88,7 @@ Land on the HuggingPost dashboard. Click **Open Postiz โ†’** to reach the login
88
 
89
  ### Step 5: Set Up Keep-Alive (1 min)
90
 
91
- On the dashboard, paste your UptimeRobot **Main API key** to create an external monitor that pings `/health` every 5 min. Without this, the Space will sleep and scheduled posts won't fire.
92
 
93
  ## ๐Ÿ”‘ Configuration
94
 
@@ -151,9 +151,7 @@ huggingface-cli download --repo-type dataset <your-username>/huggingpost-backup
151
 
152
  ## ๐Ÿ’“ Keep It Awake
153
 
154
- Free HF Spaces sleep after ~48h of no traffic. A sleeping Space cannot fire scheduled posts. The dashboard has a one-time setup form for [UptimeRobot](https://uptimerobot.com) โ€” create a free account, copy your **Main API key** (NOT a Read-only or Monitor-specific key), paste it in the dashboard.
155
-
156
- This works for **public** Spaces only. Private Spaces cannot be reached by external monitors.
157
 
158
  ## ๐ŸŒ Cloudflare Proxy *(Optional)*
159
 
@@ -165,7 +163,7 @@ Hugging Face Spaces sometimes block outbound HTTP to specific social-platform AP
165
  2. Add `CLOUDFLARE_WORKERS_TOKEN` as a Space secret.
166
  3. Restart the Space.
167
 
168
- HuggingPost will create or update a Worker named `<your-space-host>-proxy` and route blocked outbound traffic through it transparently. You can scope it with `CLOUDFLARE_PROXY_DOMAINS` (default `*` = all external).
169
 
170
  ## ๐Ÿ”Œ Connecting Social Accounts
171
 
@@ -203,8 +201,8 @@ HuggingPost/
203
 
204
  | Path | Target | Notes |
205
  | :--- | :--- | :--- |
206
- | `/` | HuggingPost dashboard (local) | Status + UptimeRobot setup |
207
- | `/health`, `/status`, `/uptimerobot/setup` | local | JSON handlers |
208
  | `/app` or `/app/*` | Postiz nginx `:5000` | `/app` stripped โ€” Next.js built with `basePath="/app"` |
209
  | `/_next/*`, `/static/*` | 301 โ†’ `/app/<path>` | Catches absolute-URL leaks |
210
  | anything else | 404 | โ€” |
@@ -240,7 +238,7 @@ On restarts after the first boot, wait 30โ€“90 s for PM2 processes to come up. C
240
  Either move media to Cloudflare R2 (`STORAGE_PROVIDER=cloudflare`) or raise `SYNC_MAX_FILE_BYTES`. The HF Dataset itself supports much larger files, but huge backups slow restart.
241
 
242
  **Scheduled posts didn't fire while I was away**
243
- The Space slept. Set up UptimeRobot from the dashboard.
244
 
245
  **OAuth callback fails for X/Facebook/LinkedIn**
246
  Some platforms reject `*.hf.space` subdomains as redirect URIs. You may need to put a custom domain in front (Cloudflare โ†’ HF Space CNAME).
 
55
  - ๐Ÿ“… **30+ Social Platforms** โ€” schedule posts to X, LinkedIn, Facebook, Threads, TikTok, YouTube, Reddit, Mastodon, Discord, Slack, Pinterest, etc.
56
  - โšก **One-click deploy** โ€” duplicate the Space, add `HF_TOKEN`, you're done.
57
  - ๐Ÿ’พ **Persistent across restarts** โ€” PostgreSQL + uploaded media auto-backed up to a private HF Dataset every 5 min and restored on boot.
58
+ - ๐Ÿ’“ **Keep-Alive** โ€” add `UPTIMEROBOT_API_KEY` as a Space secret and the monitor is created automatically at boot.
59
  - ๐ŸŒ **Outbound firewall workaround** โ€” optional Cloudflare Worker proxy auto-provisioned for blocked platform APIs.
60
  - ๐Ÿ”’ **Secrets generated** โ€” `JWT_SECRET` auto-generated on first boot and persisted, no manual setup.
61
  - ๐Ÿ  **100% HF-Native** โ€” no external Postgres/Redis/storage accounts needed for the default path.
 
88
 
89
  ### Step 5: Set Up Keep-Alive (1 min)
90
 
91
+ Add your [UptimeRobot](https://uptimerobot.com) **Main API key** as a Space secret named `UPTIMEROBOT_API_KEY`. HuggingPost will automatically create a monitor that pings `/health` every 5 min at boot. Without this, the Space will sleep and scheduled posts won't fire.
92
 
93
  ## ๐Ÿ”‘ Configuration
94
 
 
151
 
152
  ## ๐Ÿ’“ Keep It Awake
153
 
154
+ Free HF Spaces sleep after ~48h of no traffic. A sleeping Space cannot fire scheduled posts. Add your [UptimeRobot](https://uptimerobot.com) **Main API key** (NOT a Read-only or Monitor-specific key) as a Space secret named `UPTIMEROBOT_API_KEY`. HuggingPost will automatically create the monitor at boot. The dashboard shows the current status.
 
 
155
 
156
  ## ๐ŸŒ Cloudflare Proxy *(Optional)*
157
 
 
163
  2. Add `CLOUDFLARE_WORKERS_TOKEN` as a Space secret.
164
  3. Restart the Space.
165
 
166
+ HuggingPost will create or update a Worker named `<your-space-host>-proxy` and route blocked outbound traffic through it transparently. You can add extra domains with `CLOUDFLARE_PROXY_DOMAINS` (comma-separated, merged with built-in defaults). Set to `*` to proxy all external traffic.
167
 
168
  ## ๐Ÿ”Œ Connecting Social Accounts
169
 
 
201
 
202
  | Path | Target | Notes |
203
  | :--- | :--- | :--- |
204
+ | `/` | HuggingPost dashboard (local) | Status + UptimeRobot badge |
205
+ | `/health`, `/status` | local | JSON handlers |
206
  | `/app` or `/app/*` | Postiz nginx `:5000` | `/app` stripped โ€” Next.js built with `basePath="/app"` |
207
  | `/_next/*`, `/static/*` | 301 โ†’ `/app/<path>` | Catches absolute-URL leaks |
208
  | anything else | 404 | โ€” |
 
238
  Either move media to Cloudflare R2 (`STORAGE_PROVIDER=cloudflare`) or raise `SYNC_MAX_FILE_BYTES`. The HF Dataset itself supports much larger files, but huge backups slow restart.
239
 
240
  **Scheduled posts didn't fire while I was away**
241
+ The Space slept. Add `UPTIMEROBOT_API_KEY` as a Space secret to enable automatic keep-awake monitoring.
242
 
243
  **OAuth callback fails for X/Facebook/LinkedIn**
244
  Some platforms reject `*.hf.space` subdomains as redirect URIs. You may need to put a custom domain in front (Cloudflare โ†’ HF Space CNAME).
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/huggingpost-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/huggingpost-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
@@ -19,7 +19,6 @@
19
  // reached after we strip the /app prefix.
20
 
21
  const http = require("http");
22
- const https = require("https");
23
  const fs = require("fs");
24
  const net = require("net");
25
  const path = require("path");
@@ -44,13 +43,8 @@ const POSTIZ_PORT = 5000;
44
  const startTime = Date.now();
45
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
46
  const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "300";
47
- const UPTIMEROBOT_SETUP_ENABLED =
48
- String(process.env.UPTIMEROBOT_SETUP_ENABLED || "true").toLowerCase() === "true";
49
- const UPTIMEROBOT_RATE_WINDOW_MS = 60 * 1000;
50
- const UPTIMEROBOT_RATE_MAX = Number(process.env.UPTIMEROBOT_RATE_LIMIT_PER_MINUTE || 5);
51
- const SPACE_VISIBILITY_TTL_MS = 10 * 60 * 1000;
52
- const spaceVisibilityCache = new Map();
53
- const uptimerobotRateMap = new Map();
54
 
55
  // ============================================================================
56
  // URL helpers
@@ -65,7 +59,6 @@ function isLocalRoute(pathname) {
65
  return (
66
  pathname === "/health" ||
67
  pathname === "/status" ||
68
- pathname === "/uptimerobot/setup" ||
69
  pathname === "/" ||
70
  pathname === ""
71
  );
@@ -75,139 +68,13 @@ function isLocalRoute(pathname) {
75
  // UptimeRobot helpers
76
  // ============================================================================
77
 
78
- function getRequesterIp(req) {
79
- const forwarded = req.headers["x-forwarded-for"];
80
- if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
81
- if (Array.isArray(forwarded) && forwarded.length > 0) return String(forwarded[0]).split(",")[0].trim();
82
- return req.socket.remoteAddress || "unknown";
83
- }
84
-
85
- function isRateLimited(req) {
86
- const now = Date.now();
87
- const ip = getRequesterIp(req);
88
- const bucket = uptimerobotRateMap.get(ip) || [];
89
- const recent = bucket.filter((ts) => now - ts < UPTIMEROBOT_RATE_WINDOW_MS);
90
- recent.push(now);
91
- uptimerobotRateMap.set(ip, recent);
92
- return recent.length > UPTIMEROBOT_RATE_MAX;
93
- }
94
-
95
- setInterval(() => {
96
- const cutoff = Date.now() - UPTIMEROBOT_RATE_WINDOW_MS;
97
- for (const [ip, timestamps] of uptimerobotRateMap) {
98
- if (timestamps.every((ts) => ts < cutoff)) uptimerobotRateMap.delete(ip);
99
- }
100
- }, 5 * 60 * 1000).unref();
101
-
102
- function isAllowedUptimeSetupOrigin(req) {
103
- const host = String(req.headers.host || "").toLowerCase();
104
- const origin = String(req.headers.origin || "").toLowerCase();
105
- const referer = String(req.headers.referer || "").toLowerCase();
106
- if (!host) return false;
107
- if (origin && !origin.includes(host)) return false;
108
- if (referer && !referer.includes(host)) return false;
109
- return true;
110
- }
111
-
112
- function isValidUptimeApiKey(key) {
113
- return /^[A-Za-z0-9_-]{20,128}$/.test(String(key || ""));
114
- }
115
-
116
- function decodeJwtPayload(token) {
117
- try {
118
- const parts = String(token || "").split(".");
119
- if (parts.length < 2) return null;
120
- const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
121
- const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
122
- return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
123
- } catch { return null; }
124
- }
125
-
126
- function getSpaceRef(parsedUrl) {
127
- const signedToken = parsedUrl.searchParams.get("__sign");
128
- if (!signedToken) return null;
129
- const payload = decodeJwtPayload(signedToken);
130
- const subject = payload && payload.sub;
131
- const match = typeof subject === "string"
132
- ? subject.match(/^\/spaces\/([^/]+)\/([^/]+)$/)
133
- : null;
134
- if (!match) return null;
135
- return { owner: match[1], repo: match[2] };
136
- }
137
-
138
- function fetchStatusCode(url) {
139
- return new Promise((resolve, reject) => {
140
- const req = https.get(
141
- url,
142
- { headers: { "user-agent": "HuggingPost/1.0", accept: "application/json" } },
143
- (res) => { res.resume(); resolve(res.statusCode || 0); },
144
- );
145
- req.on("error", reject);
146
- req.setTimeout(5000, () => req.destroy(new Error("timeout")));
147
- });
148
- }
149
-
150
- async function resolveSpaceIsPrivate(parsedUrl) {
151
- const ref = getSpaceRef(parsedUrl);
152
- if (!ref) return false;
153
- const cacheKey = `${ref.owner}/${ref.repo}`;
154
- const cached = spaceVisibilityCache.get(cacheKey);
155
- if (cached && Date.now() - cached.timestamp < SPACE_VISIBILITY_TTL_MS) return cached.isPrivate;
156
  try {
157
- const statusCode = await fetchStatusCode(`https://huggingface.co/api/spaces/${ref.owner}/${ref.repo}`);
158
- const isPrivate = statusCode === 401 || statusCode === 403 || statusCode === 404;
159
- spaceVisibilityCache.set(cacheKey, { isPrivate, timestamp: Date.now() });
160
- return isPrivate;
161
- } catch {
162
- if (cached) return cached.isPrivate;
163
- return false;
164
- }
165
- }
166
-
167
- function postUptimeRobot(path, form) {
168
- const body = new URLSearchParams(form).toString();
169
- return new Promise((resolve, reject) => {
170
- const request = https.request(
171
- {
172
- hostname: "api.uptimerobot.com", port: 443, method: "POST", path,
173
- headers: { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(body) },
174
- },
175
- (response) => {
176
- let raw = "";
177
- response.setEncoding("utf8");
178
- response.on("data", (c) => { raw += c; });
179
- response.on("end", () => {
180
- try { resolve(JSON.parse(raw)); }
181
- catch { reject(new Error("Unexpected response from UptimeRobot")); }
182
- });
183
- },
184
- );
185
- request.on("error", reject);
186
- request.write(body);
187
- request.end();
188
- });
189
- }
190
-
191
- async function createUptimeRobotMonitor(apiKey, host) {
192
- const cleanHost = String(host || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
193
- if (!cleanHost) throw new Error("Missing Space host.");
194
- const monitorUrl = `https://${cleanHost}/health`;
195
- const existing = await postUptimeRobot("/v2/getMonitors", {
196
- api_key: apiKey, format: "json", logs: "0", response_times: "0", response_times_limit: "1",
197
- });
198
- const existingMonitor = Array.isArray(existing.monitors)
199
- ? existing.monitors.find((m) => m.url === monitorUrl) : null;
200
- if (existingMonitor) return { created: false, message: `Monitor already exists for ${monitorUrl}` };
201
- const created = await postUptimeRobot("/v2/newMonitor", {
202
- api_key: apiKey, format: "json", type: "1",
203
- friendly_name: `HuggingPost ${cleanHost}`,
204
- url: monitorUrl, interval: "300",
205
- });
206
- if (created.stat !== "ok") {
207
- const message = created?.error?.message || created?.message || "Failed to create UptimeRobot monitor.";
208
- throw new Error(message);
209
- }
210
- return { created: true, message: `Monitor created for ${monitorUrl}` };
211
  }
212
 
213
  // ============================================================================
@@ -255,27 +122,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 reach private HF Spaces. Switch to public 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;
@@ -420,15 +285,12 @@ function renderDashboard(initialData) {
420
  margin-top: 14px; padding: 12px 14px; border-radius: 12px;
421
  background: rgba(255,255,255,0.03); color: var(--text-dim);
422
  font-size: 0.9rem; line-height: 1.5;
 
423
  }
424
  .helper-summary strong { color: var(--text); }
 
425
  .helper-summary.success { background: rgba(16,185,129,0.08); }
426
- .helper-toggle {
427
- margin-top: 14px; display: inline-flex; align-items: center; justify-content: center;
428
- background: rgba(255,255,255,0.04); color: var(--text);
429
- border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;
430
- padding: 12px 16px; font: inherit; font-weight: 600; cursor: pointer;
431
- }
432
  .footer { text-align: center; color: var(--text-dim); font-size: 0.8rem; margin-top: 20px; }
433
  @media (max-width: 700px) {
434
  body { padding: 16px 0; }
@@ -499,10 +361,6 @@ function renderDashboard(initialData) {
499
  </div>
500
 
501
  <script>
502
- const KEEP_AWAKE_PRIVATE = ${initialData.spacePrivate ? "true" : "false"};
503
- const KEEP_AWAKE_SETUP_ENABLED = ${UPTIMEROBOT_SETUP_ENABLED ? "true" : "false"};
504
- const monitorStateKey = 'huggingpost_uptimerobot_v1';
505
-
506
  function getCurrentSearch() { return window.location.search || ''; }
507
 
508
  function renderSyncBadge(status, lastSyncTime, lastError) {
@@ -542,98 +400,13 @@ function renderDashboard(initialData) {
542
  } catch (e) { console.error('Status update failed:', e); }
543
  }
544
 
545
- function setMonitorUiState(isConfigured) {
546
- const summary = document.getElementById('uptimerobot-summary');
547
- const shell = document.getElementById('uptimerobot-shell');
548
- const toggle = document.getElementById('uptimerobot-toggle');
549
- if (!summary || !shell || !toggle) return;
550
- if (isConfigured) {
551
- summary.classList.add('success');
552
- summary.innerHTML = '<strong>Already set up.</strong> Your UptimeRobot monitor should keep this public Space awake.';
553
- shell.classList.add('hidden');
554
- toggle.textContent = 'Set Up Again';
555
- } else {
556
- summary.classList.remove('success');
557
- summary.innerHTML = 'One-time setup for public Spaces. Paste your UptimeRobot <strong>Main API key</strong> to create the monitor.';
558
- toggle.textContent = 'Set Up Monitor';
559
- }
560
- }
561
-
562
- function restoreMonitorUiState() {
563
- try { setMonitorUiState(window.localStorage.getItem(monitorStateKey) === 'done'); }
564
- catch { setMonitorUiState(false); }
565
- }
566
-
567
- async function setupUptimeRobot() {
568
- const input = document.getElementById('uptimerobot-key');
569
- const button = document.getElementById('uptimerobot-btn');
570
- const result = document.getElementById('uptimerobot-result');
571
- const apiKey = input.value.trim();
572
- if (!apiKey) {
573
- result.className = 'helper-result error';
574
- result.textContent = 'Paste your UptimeRobot Main API key first.';
575
- return;
576
- }
577
- button.disabled = true;
578
- button.textContent = 'Creating...';
579
- result.className = 'helper-result';
580
- result.textContent = '';
581
- try {
582
- const res = await fetch('/uptimerobot/setup' + getCurrentSearch(), {
583
- method: 'POST',
584
- headers: { 'Content-Type': 'application/json' },
585
- body: JSON.stringify({ apiKey }),
586
- });
587
- const data = await res.json();
588
- if (!res.ok) throw new Error(data.message || 'Failed to create monitor.');
589
- result.className = 'helper-result ok';
590
- result.textContent = data.message || 'UptimeRobot monitor is ready.';
591
- input.value = '';
592
- try { window.localStorage.setItem(monitorStateKey, 'done'); } catch {}
593
- setMonitorUiState(true);
594
- document.getElementById('uptimerobot-shell').classList.add('hidden');
595
- } catch (error) {
596
- result.className = 'helper-result error';
597
- result.textContent = error.message || 'Failed to create monitor.';
598
- } finally {
599
- button.disabled = false;
600
- button.textContent = 'Create Monitor';
601
- }
602
- }
603
-
604
  updateStatus();
605
  setInterval(updateStatus, 30000);
606
-
607
- if (KEEP_AWAKE_SETUP_ENABLED && !KEEP_AWAKE_PRIVATE) {
608
- restoreMonitorUiState();
609
- const toggleBtn = document.getElementById('uptimerobot-toggle');
610
- const createBtn = document.getElementById('uptimerobot-btn');
611
- if (toggleBtn) toggleBtn.addEventListener('click', () => {
612
- document.getElementById('uptimerobot-shell').classList.toggle('hidden');
613
- });
614
- if (createBtn) createBtn.addEventListener('click', setupUptimeRobot);
615
- }
616
  </script>
617
  </body>
618
  </html>`;
619
  }
620
 
621
- // ============================================================================
622
- // Request body reader
623
- // ============================================================================
624
-
625
- function readRequestBody(req) {
626
- return new Promise((resolve, reject) => {
627
- let body = "";
628
- req.on("data", (chunk) => {
629
- body += chunk;
630
- if (body.length > 64 * 1024) { reject(new Error("Request too large")); req.destroy(); }
631
- });
632
- req.on("end", () => resolve(body));
633
- req.on("error", reject);
634
- });
635
- }
636
-
637
  // ============================================================================
638
  // Reverse proxy
639
  // ============================================================================
@@ -782,60 +555,13 @@ const server = http.createServer((req, res) => {
782
  return;
783
  }
784
 
785
- // โ”€โ”€ /uptimerobot/setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
786
- if (pathname === "/uptimerobot/setup") {
787
- if (req.method !== "POST") {
788
- res.writeHead(405, { "Content-Type": "application/json" });
789
- res.end(JSON.stringify({ message: "Method not allowed" }));
790
- return;
791
- }
792
- void (async () => {
793
- try {
794
- if (!UPTIMEROBOT_SETUP_ENABLED) {
795
- res.writeHead(403, { "Content-Type": "application/json" });
796
- res.end(JSON.stringify({ message: "Uptime setup is disabled." }));
797
- return;
798
- }
799
- if (isRateLimited(req)) {
800
- res.writeHead(429, { "Content-Type": "application/json" });
801
- res.end(JSON.stringify({ message: "Too many requests." }));
802
- return;
803
- }
804
- if (!isAllowedUptimeSetupOrigin(req)) {
805
- res.writeHead(403, { "Content-Type": "application/json" });
806
- res.end(JSON.stringify({ message: "Invalid request origin." }));
807
- return;
808
- }
809
- const body = await readRequestBody(req);
810
- const parsed = JSON.parse(body || "{}");
811
- const apiKey = String(parsed.apiKey || "").trim();
812
- if (!isValidUptimeApiKey(apiKey)) {
813
- res.writeHead(400, { "Content-Type": "application/json" });
814
- res.end(JSON.stringify({ message: "A valid API key is required." }));
815
- return;
816
- }
817
- const result = await createUptimeRobotMonitor(apiKey, req.headers.host);
818
- res.writeHead(200, { "Content-Type": "application/json" });
819
- res.end(JSON.stringify(result));
820
- } catch (error) {
821
- res.writeHead(400, { "Content-Type": "application/json" });
822
- res.end(JSON.stringify({ message: error?.message || "Failed to create UptimeRobot monitor." }));
823
- }
824
- })();
825
- return;
826
- }
827
-
828
  // โ”€โ”€ Dashboard at exact / โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
829
  if (pathname === "/" || pathname === "") {
830
  void (async () => {
831
- const [postiz, spacePrivate] = await Promise.all([
832
- checkPostizHealth(),
833
- resolveSpaceIsPrivate(parsedUrl),
834
- ]);
835
  const initialData = {
836
  postizRunning: postiz.status === "running",
837
  sync: readSyncStatus(),
838
- spacePrivate,
839
  };
840
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
841
  res.end(renderDashboard(initialData));
 
19
  // reached after we strip the /app prefix.
20
 
21
  const http = require("http");
 
22
  const fs = require("fs");
23
  const net = require("net");
24
  const path = require("path");
 
43
  const startTime = Date.now();
44
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
45
  const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "300";
46
+ const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingpost-uptimerobot-status.json";
47
+ const UPTIMEROBOT_API_KEY_SET = !!process.env.UPTIMEROBOT_API_KEY;
 
 
 
 
 
48
 
49
  // ============================================================================
50
  // URL helpers
 
59
  return (
60
  pathname === "/health" ||
61
  pathname === "/status" ||
 
62
  pathname === "/" ||
63
  pathname === ""
64
  );
 
68
  // UptimeRobot helpers
69
  // ============================================================================
70
 
71
+ function getUptimeRobotStatus() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  try {
73
+ if (fs.existsSync(UPTIMEROBOT_STATUS_FILE)) {
74
+ return JSON.parse(fs.readFileSync(UPTIMEROBOT_STATUS_FILE, "utf8"));
75
+ }
76
+ } catch {}
77
+ return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
 
80
  // ============================================================================
 
122
  // ============================================================================
123
 
124
  function renderDashboard(initialData) {
125
+ const uptimerobotStatus = getUptimeRobotStatus();
126
+ let keepAwakeHtml;
127
+ if (uptimerobotStatus?.configured) {
128
+ keepAwakeHtml = `<div class="helper-summary success">
129
+ <span class="status-badge status-online"><div class="pulse"></div>Configured</span>
130
+ <span>UptimeRobot monitor active for <code>${uptimerobotStatus.url || "your /health endpoint"}</code>.</span>
131
+ </div>`;
132
+ } else if (uptimerobotStatus?.configured === false) {
133
+ keepAwakeHtml = `<div class="helper-summary error">
134
+ <span class="status-badge status-error">Failed</span>
135
+ <span>Monitor setup failed. Check Space logs.</span>
136
+ </div>`;
137
+ } else if (UPTIMEROBOT_API_KEY_SET) {
138
+ 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>`;
139
+ } else {
140
+ keepAwakeHtml = `<div class="helper-summary">
141
+ <strong>Not configured.</strong> Add <code>UPTIMEROBOT_API_KEY</code> to Space secrets to enable keep-awake monitoring.
142
+ </div>`;
143
+ }
 
 
144
 
145
  const syncStatus = initialData.sync;
146
  const hasBackup = HF_BACKUP_ENABLED;
 
285
  margin-top: 14px; padding: 12px 14px; border-radius: 12px;
286
  background: rgba(255,255,255,0.03); color: var(--text-dim);
287
  font-size: 0.9rem; line-height: 1.5;
288
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
289
  }
290
  .helper-summary strong { color: var(--text); }
291
+ .helper-summary code { background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 4px; font-size: 0.85em; color: var(--text); }
292
  .helper-summary.success { background: rgba(16,185,129,0.08); }
293
+ .helper-summary.error { background: rgba(239,68,68,0.08); }
 
 
 
 
 
294
  .footer { text-align: center; color: var(--text-dim); font-size: 0.8rem; margin-top: 20px; }
295
  @media (max-width: 700px) {
296
  body { padding: 16px 0; }
 
361
  </div>
362
 
363
  <script>
 
 
 
 
364
  function getCurrentSearch() { return window.location.search || ''; }
365
 
366
  function renderSyncBadge(status, lastSyncTime, lastError) {
 
400
  } catch (e) { console.error('Status update failed:', e); }
401
  }
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  updateStatus();
404
  setInterval(updateStatus, 30000);
 
 
 
 
 
 
 
 
 
 
405
  </script>
406
  </body>
407
  </html>`;
408
  }
409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  // ============================================================================
411
  // Reverse proxy
412
  // ============================================================================
 
555
  return;
556
  }
557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  // โ”€โ”€ Dashboard at exact / โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
559
  if (pathname === "/" || pathname === "") {
560
  void (async () => {
561
+ const postiz = await checkPostizHealth();
 
 
 
562
  const initialData = {
563
  postizRunning: postiz.status === "running",
564
  sync: readSyncStatus(),
 
565
  };
566
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
567
  res.end(renderDashboard(initialData));
setup-uptimerobot.sh CHANGED
@@ -15,6 +15,7 @@ set -euo pipefail
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."
@@ -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}"
 
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/huggingpost-uptimerobot-status.json"
19
 
20
  if [ -z "$API_KEY" ]; then
21
  echo "Missing UPTIMEROBOT_API_KEY."
 
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
@@ -61,9 +61,14 @@ export PGPASSWORD="${DB_PASSWORD}"
61
  # include /app/api so frontend code calls the right path; health-server
62
  # strips /app before passing to nginx :5000, which then routes /api โ†’ backend
63
  # (port 3000) and /uploads โ†’ file system.
 
 
 
 
 
64
  export DATABASE_URL="${DATABASE_URL:-postgresql://postiz:${DB_PASSWORD}@localhost:5432/postiz}"
65
  export REDIS_URL="${REDIS_URL:-redis://localhost:6379}"
66
- export FRONTEND_URL="${FRONTEND_URL:-${PUBLIC_URL}/app}"
67
  export NEXT_PUBLIC_BACKEND_URL="${NEXT_PUBLIC_BACKEND_URL:-${PUBLIC_URL}/app/api}"
68
  export BACKEND_INTERNAL_URL="${BACKEND_INTERNAL_URL:-http://localhost:3000}"
69
  export STORAGE_PROVIDER="${STORAGE_PROVIDER:-local}"
@@ -230,6 +235,12 @@ fi
230
  # โ”€โ”€ Health server (public port 7860) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
231
  node /opt/healthsrv/health-server.js &
232
  HEALTH_PID=$!
 
 
 
 
 
 
233
  sleep 1
234
 
235
  # โ”€โ”€ Postiz: nginx + PM2 (mirrors upstream CMD `nginx && pnpm run pm2`) โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
61
  # include /app/api so frontend code calls the right path; health-server
62
  # strips /app before passing to nginx :5000, which then routes /api โ†’ backend
63
  # (port 3000) and /uploads โ†’ file system.
64
+ #
65
+ # FRONTEND_URL must be the bare origin (scheme+host, NO /app path suffix).
66
+ # The backend uses this for the CORS allow-origin response header. Browsers
67
+ # send Origin: https://host (no path), so including /app causes a mismatch
68
+ # and blocks every API call (login, signup, etc.).
69
  export DATABASE_URL="${DATABASE_URL:-postgresql://postiz:${DB_PASSWORD}@localhost:5432/postiz}"
70
  export REDIS_URL="${REDIS_URL:-redis://localhost:6379}"
71
+ export FRONTEND_URL="${FRONTEND_URL:-${PUBLIC_URL}}"
72
  export NEXT_PUBLIC_BACKEND_URL="${NEXT_PUBLIC_BACKEND_URL:-${PUBLIC_URL}/app/api}"
73
  export BACKEND_INTERNAL_URL="${BACKEND_INTERNAL_URL:-http://localhost:3000}"
74
  export STORAGE_PROVIDER="${STORAGE_PROVIDER:-local}"
 
235
  # โ”€โ”€ Health server (public port 7860) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
236
  node /opt/healthsrv/health-server.js &
237
  HEALTH_PID=$!
238
+
239
+ if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
240
+ echo "Setting up UptimeRobot monitor..."
241
+ bash /opt/setup-uptimerobot.sh "${SPACE_HOST}" || true
242
+ fi
243
+
244
  sleep 1
245
 
246
  # โ”€โ”€ Postiz: nginx + PM2 (mirrors upstream CMD `nginx && pnpm run pm2`) โ”€โ”€โ”€โ”€โ”€โ”€โ”€