somratpro commited on
Commit
05a6a72
·
1 Parent(s): 751478a

fix: improve keep-alive status message handling in renderDashboard function

Browse files
Files changed (1) hide show
  1. health-server.js +705 -223
health-server.js CHANGED
@@ -31,11 +31,19 @@ const PORT = 7860;
31
  // is simpler and faster.
32
  const NEXTJS_PUBLIC_DIR = "/app/apps/frontend/public";
33
  const MIME_TYPES = {
34
- ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
35
- ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
36
- ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2",
37
- ".ttf": "font/ttf", ".eot": "application/vnd.ms-fontobject",
38
- ".txt": "text/plain; charset=utf-8", ".xml": "application/xml",
 
 
 
 
 
 
 
 
39
  };
40
  const POSTIZ_HOST = "127.0.0.1";
41
  const POSTIZ_PORT = 5000;
@@ -43,7 +51,8 @@ const POSTIZ_PORT = 5000;
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 CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/huggingpost-cloudflare-keepalive-status.json";
 
47
 
48
  // Social platform env-var presence check (for dashboard status grid).
49
  // Each entry: { name, emoji, ready: bool, setupUrl, envVars, noOAuth }
@@ -51,49 +60,156 @@ function getSocialPlatforms() {
51
  const e = process.env;
52
  return [
53
  // ── Works immediately (connect inside Postiz UI, no env vars needed) ─────
54
- { name: "Bluesky", emoji: "🦋", noOAuth: true, ready: true, note: "Username + App Password in Postiz" },
55
- { name: "Mastodon", emoji: "🐘", noOAuth: true, ready: true, note: "Instance URL + credentials in Postiz" },
56
- { name: "Telegram", emoji: "✈️", noOAuth: true, ready: true, note: "Bot token from @BotFather in Postiz" },
57
- { name: "Nostr", emoji: "🔑", noOAuth: true, ready: true, note: "Private key in Postiz" },
58
- { name: "Lemmy", emoji: "🐾", noOAuth: true, ready: true, note: "Instance + credentials in Postiz" },
59
- { name: "Warpcast", emoji: "🟣", noOAuth: true, ready: true, note: "FID + private key in Postiz" },
60
- { name: "Dev.to", emoji: "💻", noOAuth: true, ready: true, note: "API key from dev.to settings" },
61
- { name: "Hashnode", emoji: "📰", noOAuth: true, ready: true, note: "API token from Hashnode settings" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  // ── Needs OAuth app (env vars required) ───────────────────────────────────
63
- { id: "linkedin", name: "LinkedIn", emoji: "💼", ready: !!(e.LINKEDIN_CLIENT_ID && e.LINKEDIN_CLIENT_ID !== "undefined"),
 
 
 
 
64
  setupUrl: "https://www.linkedin.com/developers/apps/new",
65
- envVars: ["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"] },
66
- { id: "x", name: "X / Twitter",emoji: "🐦", ready: !!(e.X_API_KEY),
 
 
 
 
 
67
  setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps",
68
- envVars: ["X_API_KEY", "X_API_SECRET"] },
69
- { id: "facebook", name: "Facebook", emoji: "📘", ready: !!(e.FACEBOOK_APP_ID),
 
 
 
 
 
70
  setupUrl: "https://developers.facebook.com/apps/create/",
71
- envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"] },
72
- { id: "instagram", name: "Instagram", emoji: "📸", ready: !!(e.FACEBOOK_APP_ID),
 
 
 
 
 
73
  setupUrl: "https://developers.facebook.com/apps/create/",
74
  envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"],
75
- note: "Uses same app as Facebook" },
76
- { id: "threads", name: "Threads", emoji: "🧵", ready: !!(e.THREADS_APP_ID),
 
 
 
 
 
77
  setupUrl: "https://developers.facebook.com/apps/create/",
78
- envVars: ["THREADS_APP_ID", "THREADS_APP_SECRET"] },
79
- { id: "youtube", name: "YouTube", emoji: "▶️", ready: !!(e.YOUTUBE_CLIENT_ID),
 
 
 
 
 
80
  setupUrl: "https://console.cloud.google.com/apis/credentials",
81
- envVars: ["YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET"] },
82
- { id: "tiktok", name: "TikTok", emoji: "🎵", ready: !!(e.TIKTOK_CLIENT_ID),
 
 
 
 
 
83
  setupUrl: "https://developers.tiktok.com/",
84
- envVars: ["TIKTOK_CLIENT_ID", "TIKTOK_CLIENT_SECRET"] },
85
- { id: "reddit", name: "Reddit", emoji: "🤖", ready: !!(e.REDDIT_CLIENT_ID),
 
 
 
 
 
86
  setupUrl: "https://www.reddit.com/prefs/apps",
87
- envVars: ["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"] },
88
- { id: "pinterest", name: "Pinterest", emoji: "📌", ready: !!(e.PINTEREST_CLIENT_ID),
 
 
 
 
 
89
  setupUrl: "https://developers.pinterest.com/apps/",
90
- envVars: ["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"] },
91
- { id: "discord", name: "Discord", emoji: "🎮", ready: !!(e.DISCORD_CLIENT_ID),
 
 
 
 
 
92
  setupUrl: "https://discord.com/developers/applications",
93
- envVars: ["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET", "DISCORD_BOT_TOKEN_ID"] },
94
- { id: "slack", name: "Slack", emoji: "💬", ready: !!(e.SLACK_ID),
 
 
 
 
 
 
 
 
 
95
  setupUrl: "https://api.slack.com/apps?new_app=1",
96
- envVars: ["SLACK_ID", "SLACK_SECRET", "SLACK_SIGNING_SECRET"] },
 
97
  ];
98
  }
99
 
@@ -108,18 +224,42 @@ function getOAuthPlatformDetails(publicUrl) {
108
  name: "LinkedIn",
109
  emoji: "💼",
110
  setupUrl: "https://www.linkedin.com/developers/apps/new",
111
- docsUrl: "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow",
 
112
  callbackUrl: cb("linkedin"),
113
  envVars: [
114
- { name: "LINKEDIN_CLIENT_ID", desc: "Client ID", set: !!e.LINKEDIN_CLIENT_ID },
115
- { name: "LINKEDIN_CLIENT_SECRET", desc: "Client Secret", set: !!e.LINKEDIN_CLIENT_SECRET },
 
 
 
 
 
 
 
 
116
  ],
117
  steps: [
118
- { title: "Create a LinkedIn App", body: 'Visit the developer portal. Create a new app; set <strong>App type = Web</strong>.' },
119
- { title: "Add OAuth redirect URL", body: 'In the <strong>Auth</strong> tab → OAuth 2.0 settings, paste the callback URL below.' },
120
- { title: "Enable products", body: 'Add <strong>Sign In with LinkedIn using OpenID Connect</strong> and <strong>Share on LinkedIn</strong> products.' },
121
- { title: "Copy credentials", body: 'From the Auth tab, copy <strong>Client ID</strong> and <strong>Client Secret</strong>.' },
122
- { title: "Add to Space secrets", body: 'Open your HF Space settings, add both env vars below, then restart the Space.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  ],
124
  },
125
  {
@@ -127,18 +267,42 @@ function getOAuthPlatformDetails(publicUrl) {
127
  name: "X / Twitter",
128
  emoji: "🐦",
129
  setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps",
130
- docsUrl: "https://developer.twitter.com/en/docs/authentication/oauth-1-0a",
 
131
  callbackUrl: cb("x"),
132
  envVars: [
133
- { name: "X_API_KEY", desc: "API Key (Consumer Key)", set: !!e.X_API_KEY },
134
- { name: "X_API_SECRET", desc: "API Secret (Consumer Secret)", set: !!e.X_API_SECRET },
 
 
 
 
 
 
 
 
135
  ],
136
  steps: [
137
- { title: "Create an X Developer App", body: 'Apply for a developer account at <a href="https://developer.twitter.com" target="_blank" rel="noopener" style="color:#f472b6">developer.twitter.com</a> if you don\'t have one. Create a new project + app.' },
138
- { title: "Enable OAuth 1.0a + set permissions", body: 'On your app page → <strong>User authentication settings → Set up</strong>. Enable <strong>OAuth 1.0a</strong>. Set App permissions to <strong>Read and Write</strong>. Set Type of App to <strong>Native App</strong> (⚠️ must be Native App, not Web App — Web App breaks OAuth 1.0a).' },
139
- { title: "Add callback URL", body: 'In the same setup screen, under <strong>Callback URI / Redirect URL</strong>, paste the Callback URL shown below.' },
140
- { title: "Get your Consumer Secret", body: '<strong>⚠️ The Consumer Secret (X_API_SECRET) is only shown once</strong> — right after app creation, or after you click <strong>Regenerate</strong> on the Consumer Key row in the Keys &amp; Tokens tab.<br><br>If you don\'t have it saved: go to <strong>Keys &amp; Tokens → OAuth 1.0 Keys → Regenerate</strong>. Copy <em>both</em> the new Consumer Key and Consumer Secret that appear in the popup.' },
141
- { title: "Add to Space secrets", body: 'Add both env vars below to your HF Space settings → Variables &amp; Secrets, then restart the Space.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  ],
143
  },
144
  {
@@ -149,16 +313,38 @@ function getOAuthPlatformDetails(publicUrl) {
149
  docsUrl: "https://developers.facebook.com/docs/facebook-login/web",
150
  callbackUrl: cb("facebook"),
151
  envVars: [
152
- { name: "FACEBOOK_APP_ID", desc: "App ID", set: !!e.FACEBOOK_APP_ID },
153
- { name: "FACEBOOK_APP_SECRET", desc: "App Secret", set: !!e.FACEBOOK_APP_SECRET },
 
 
 
 
154
  ],
155
  steps: [
156
- { title: "Create a Meta App", body: 'Go to Meta for Developers. Create a new app with use case <strong>Authenticate and request data from users</strong>.' },
157
- { title: "Add Facebook Login product", body: 'In the app dashboard, click <strong>Add Product</strong> → Facebook Login → Web.' },
158
- { title: "Add callback URL", body: 'In Facebook Login settings Valid OAuth Redirect URIs, paste the callback URL below.' },
159
- { title: "Request permissions", body: 'Add <strong>pages_manage_posts</strong>, <strong>pages_read_engagement</strong>, <strong>publish_to_groups</strong> permissions.' },
160
- { title: "Copy credentials", body: 'From <strong>App Settings → Basic</strong>, copy App ID and App Secret.' },
161
- { title: "Add to Space secrets", body: 'Add both env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  ],
163
  },
164
  {
@@ -169,15 +355,38 @@ function getOAuthPlatformDetails(publicUrl) {
169
  docsUrl: "https://developers.facebook.com/docs/instagram-api",
170
  callbackUrl: cb("instagram"),
171
  envVars: [
172
- { name: "FACEBOOK_APP_ID", desc: "App ID (same as Facebook app)", set: !!e.FACEBOOK_APP_ID },
173
- { name: "FACEBOOK_APP_SECRET", desc: "App Secret (same as Facebook app)", set: !!e.FACEBOOK_APP_SECRET },
 
 
 
 
 
 
 
 
174
  ],
175
  steps: [
176
- { title: "Use the Facebook app", body: 'Instagram uses the same Meta app as Facebook — configure Facebook first.' },
177
- { title: "Add Instagram Graph API product", body: 'In your Meta app dashboard, click <strong>Add Product</strong> → Instagram Graph API.' },
178
- { title: "Connect an Instagram Business account", body: 'Your Instagram account must be a <strong>Professional (Business or Creator)</strong> account linked to a Facebook Page.' },
179
- { title: "Add callback URL", body: 'In Instagram Login settings → Valid OAuth Redirect URIs, paste the callback URL below.' },
180
- { title: "No extra env vars needed", body: 'Instagram and Facebook share <code>FACEBOOK_APP_ID</code> and <code>FACEBOOK_APP_SECRET</code>.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  ],
182
  },
183
  {
@@ -188,15 +397,34 @@ function getOAuthPlatformDetails(publicUrl) {
188
  docsUrl: "https://developers.facebook.com/docs/threads",
189
  callbackUrl: cb("threads"),
190
  envVars: [
191
- { name: "THREADS_APP_ID", desc: "App ID", set: !!e.THREADS_APP_ID },
192
- { name: "THREADS_APP_SECRET", desc: "App Secret", set: !!e.THREADS_APP_SECRET },
 
 
 
 
193
  ],
194
  steps: [
195
- { title: "Create a Meta App", body: 'Create a Meta Developer app (separate from Facebook/Instagram if you prefer clean separation).' },
196
- { title: "Add Threads API product", body: 'In the app dashboard, click <strong>Add Product</strong> → Threads API.' },
197
- { title: "Add callback URL", body: 'In Threads API settings Redirect URI, paste the callback URL below.' },
198
- { title: "Copy credentials", body: 'From <strong>App Settings → Basic</strong>, copy App ID and App Secret.' },
199
- { title: "Add to Space secrets", body: 'Add both env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  ],
201
  },
202
  {
@@ -204,20 +432,50 @@ function getOAuthPlatformDetails(publicUrl) {
204
  name: "YouTube",
205
  emoji: "▶️",
206
  setupUrl: "https://console.cloud.google.com/apis/credentials",
207
- docsUrl: "https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps",
 
208
  callbackUrl: cb("youtube"),
209
  envVars: [
210
- { name: "YOUTUBE_CLIENT_ID", desc: "OAuth 2.0 Client ID", set: !!e.YOUTUBE_CLIENT_ID },
211
- { name: "YOUTUBE_CLIENT_SECRET", desc: "OAuth 2.0 Client Secret", set: !!e.YOUTUBE_CLIENT_SECRET },
 
 
 
 
 
 
 
 
212
  ],
213
  steps: [
214
- { title: "Create a Google Cloud project", body: 'Go to Google Cloud Console. Create a new project (or use existing).' },
215
- { title: "Enable YouTube Data API v3", body: 'In APIs & Services → Library, search for <strong>YouTube Data API v3</strong> and enable it.' },
216
- { title: "Create OAuth credentials", body: 'In APIs & Services → Credentials, click <strong>Create Credentials OAuth client ID</strong>. Set type to <strong>Web application</strong>.' },
217
- { title: "Add callback URL", body: 'Under Authorized redirect URIs, paste the callback URL below.' },
218
- { title: "Configure OAuth consent screen", body: 'Set up consent screen with your app name. Add <strong>YouTube</strong> scopes.' },
219
- { title: "Copy credentials", body: 'Download or copy the <strong>Client ID</strong> and <strong>Client Secret</strong>.' },
220
- { title: "Add to Space secrets", body: 'Add both env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  ],
222
  },
223
  {
@@ -228,17 +486,46 @@ function getOAuthPlatformDetails(publicUrl) {
228
  docsUrl: "https://developers.tiktok.com/doc/login-kit-web",
229
  callbackUrl: cb("tiktok"),
230
  envVars: [
231
- { name: "TIKTOK_CLIENT_ID", desc: "Client Key", set: !!e.TIKTOK_CLIENT_ID },
232
- { name: "TIKTOK_CLIENT_SECRET", desc: "Client Secret", set: !!e.TIKTOK_CLIENT_SECRET },
 
 
 
 
 
 
 
 
233
  ],
234
  steps: [
235
- { title: "Apply for TikTok Developer access", body: 'Sign in at developers.tiktok.com. Apply for developer access (may take 1-2 days).' },
236
- { title: "Create an app", body: 'Create a new app. Set <strong>Platform: Web</strong>.' },
237
- { title: "Add Login Kit", body: 'Add <strong>Login Kit</strong> product. This enables OAuth for your app.' },
238
- { title: "Add callback URL", body: 'In Login Kit settings → Redirect domain, add your HF Space hostname. In redirect URI, paste the callback URL below.' },
239
- { title: "Request Content Posting API", body: 'Add <strong>Content Posting API</strong> product for posting videos/photos.' },
240
- { title: "Copy credentials", body: 'From app overview, copy <strong>Client Key</strong> (as CLIENT_ID) and <strong>Client Secret</strong>.' },
241
- { title: "Add to Space secrets", body: 'Add both env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  ],
243
  },
244
  {
@@ -249,15 +536,38 @@ function getOAuthPlatformDetails(publicUrl) {
249
  docsUrl: "https://github.com/reddit-archive/reddit/wiki/OAuth2",
250
  callbackUrl: cb("reddit"),
251
  envVars: [
252
- { name: "REDDIT_CLIENT_ID", desc: "Client ID (under app name)", set: !!e.REDDIT_CLIENT_ID },
253
- { name: "REDDIT_CLIENT_SECRET", desc: "Secret", set: !!e.REDDIT_CLIENT_SECRET },
 
 
 
 
 
 
 
 
254
  ],
255
  steps: [
256
- { title: "Go to Reddit App Preferences", body: 'Visit reddit.com/prefs/apps while logged in.' },
257
- { title: "Create a new app", body: 'Click <strong>create another app…</strong>. Set type to <strong>web app</strong>.' },
258
- { title: "Add callback URL", body: 'In the <strong>redirect uri</strong> field, paste the callback URL below.' },
259
- { title: "Copy credentials", body: 'The Client ID is the string below the app name. Client Secret is labelled "secret".' },
260
- { title: "Add to Space secrets", body: 'Add both env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  ],
262
  },
263
  {
@@ -265,18 +575,42 @@ function getOAuthPlatformDetails(publicUrl) {
265
  name: "Pinterest",
266
  emoji: "📌",
267
  setupUrl: "https://developers.pinterest.com/apps/",
268
- docsUrl: "https://developers.pinterest.com/docs/getting-started/set-up-app/",
 
269
  callbackUrl: cb("pinterest"),
270
  envVars: [
271
- { name: "PINTEREST_CLIENT_ID", desc: "App ID", set: !!e.PINTEREST_CLIENT_ID },
272
- { name: "PINTEREST_CLIENT_SECRET", desc: "App Secret", set: !!e.PINTEREST_CLIENT_SECRET },
 
 
 
 
 
 
 
 
273
  ],
274
  steps: [
275
- { title: "Create a Pinterest App", body: 'Go to Pinterest Developer Portal and create a new app.' },
276
- { title: "Add redirect URI", body: 'In app settings, add the callback URL below as a redirect URI.' },
277
- { title: "Request scopes", body: 'Request <strong>boards:read</strong>, <strong>pins:read</strong>, <strong>pins:write</strong> scopes.' },
278
- { title: "Copy credentials", body: 'Copy App ID and App Secret from the app settings.' },
279
- { title: "Add to Space secrets", body: 'Add both env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  ],
281
  },
282
  {
@@ -287,16 +621,43 @@ function getOAuthPlatformDetails(publicUrl) {
287
  docsUrl: "https://discord.com/developers/docs/topics/oauth2",
288
  callbackUrl: cb("discord"),
289
  envVars: [
290
- { name: "DISCORD_CLIENT_ID", desc: "Application ID", set: !!e.DISCORD_CLIENT_ID },
291
- { name: "DISCORD_CLIENT_SECRET", desc: "Client Secret", set: !!e.DISCORD_CLIENT_SECRET },
292
- { name: "DISCORD_BOT_TOKEN_ID", desc: "Bot Token", set: !!e.DISCORD_BOT_TOKEN_ID },
 
 
 
 
 
 
 
 
 
 
 
 
293
  ],
294
  steps: [
295
- { title: "Create a Discord Application", body: 'Go to Discord Developer Portal → New Application.' },
296
- { title: "Add redirect URL", body: 'In <strong>OAuth2 → Redirects</strong>, paste the callback URL below.' },
297
- { title: "Create a Bot", body: 'In the <strong>Bot</strong> section, create a bot. Enable <strong>Message Content Intent</strong>.' },
298
- { title: "Copy credentials", body: 'Copy Client ID and Client Secret from OAuth2 tab. Copy Bot Token from Bot tab.' },
299
- { title: "Add to Space secrets", body: 'Add all three env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  ],
301
  },
302
  {
@@ -307,17 +668,39 @@ function getOAuthPlatformDetails(publicUrl) {
307
  docsUrl: "https://api.slack.com/authentication/oauth-v2",
308
  callbackUrl: cb("slack"),
309
  envVars: [
310
- { name: "SLACK_ID", desc: "Client ID", set: !!e.SLACK_ID },
311
- { name: "SLACK_SECRET", desc: "Client Secret", set: !!e.SLACK_SECRET },
312
- { name: "SLACK_SIGNING_SECRET", desc: "Signing Secret", set: !!e.SLACK_SIGNING_SECRET },
 
 
 
 
313
  ],
314
  steps: [
315
- { title: "Create a Slack App", body: 'Go to api.slack.com/apps → Create New App → From scratch.' },
316
- { title: "Add OAuth redirect URL", body: 'In <strong>OAuth & Permissions → Redirect URLs</strong>, paste the callback URL below.' },
317
- { title: "Add Bot Token Scopes", body: 'Under Bot Token Scopes, add: <code>channels:join</code>, <code>chat:write</code>, <code>channels:read</code>, <code>groups:read</code>.' },
318
- { title: "Install to workspace", body: 'Click <strong>Install to Workspace</strong> to generate tokens.' },
319
- { title: "Copy credentials", body: 'From <strong>Basic Information</strong>: App Credentials has Client ID, Client Secret, Signing Secret.' },
320
- { title: "Add to Space secrets", body: 'Add all three env vars below to your HF Space settings, then restart.' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  ],
322
  },
323
  ];
@@ -326,36 +709,48 @@ function getOAuthPlatformDetails(publicUrl) {
326
  function renderSetupPage() {
327
  const spaceHost = process.env.SPACE_HOST || null;
328
  const spaceId = process.env.SPACE_ID || null;
329
- const publicUrl = spaceHost ? `https://${spaceHost}` : "http://localhost:7860";
 
 
330
  const settingsUrl = spaceId
331
  ? `https://huggingface.co/spaces/${spaceId}/settings`
332
  : "https://huggingface.co/settings/spaces";
333
 
334
  const platforms = getOAuthPlatformDetails(publicUrl);
335
- const configuredCount = platforms.filter(p => p.envVars.every(v => v.set)).length;
 
 
336
 
337
  // Build sidebar items
338
- const sidebarItems = platforms.map((p, i) => {
339
- const allSet = p.envVars.every(v => v.set);
340
- const anySet = p.envVars.some(v => v.set);
341
- const indicator = allSet ? "✅" : anySet ? "⚠️" : "⚪";
342
- return `<button class="plat-tab${i === 0 ? " active" : ""}" onclick="show(${i})" id="tab-${i}">
 
343
  <span class="tab-emoji">${p.emoji}</span>
344
  <span class="tab-name">${p.name}</span>
345
  <span class="tab-indicator">${indicator}</span>
346
  </button>`;
347
- }).join("");
 
348
 
349
  // Build detail panels
350
- const panels = platforms.map((p, i) => {
351
- const allSet = p.envVars.every(v => v.set);
352
-
353
- const stepsList = p.steps.map((s, si) =>
354
- `<div class="step"><div class="step-num">${si + 1}</div><div><div class="step-title">${s.title}</div><div class="step-body">${s.body}</div></div></div>`
355
- ).join("");
356
-
357
- const envRows = p.envVars.map(v =>
358
- `<div class="env-row">
 
 
 
 
 
 
359
  <div class="env-info">
360
  <code class="env-name">${v.name}</code>
361
  <span class="env-desc">${v.desc}</span>
@@ -364,16 +759,17 @@ function renderSetupPage() {
364
  ${v.set ? '<span class="badge badge-on" style="font-size:.7rem">Set ✓</span>' : '<span class="badge badge-off" style="font-size:.7rem">Not set</span>'}
365
  <button class="copy-btn" onclick="copy('${v.name}', this)">Copy name</button>
366
  </div>
367
- </div>`
368
- ).join("");
 
369
 
370
- const statusBanner = allSet
371
- ? `<div class="status-banner banner-ok">✅ All credentials configured — restart Space if you just added them.</div>`
372
- : p.envVars.some(v => v.set)
373
- ? `<div class="status-banner banner-warn">⚠️ Partially configured — check missing env vars below.</div>`
374
- : `<div class="status-banner banner-info">ℹ️ Not yet configured — follow the steps below.</div>`;
375
 
376
- return `<div class="panel${i === 0 ? " active" : ""}" id="panel-${i}">
377
  <div class="panel-header">
378
  <span class="panel-emoji">${p.emoji}</span>
379
  <div>
@@ -402,7 +798,8 @@ function renderSetupPage() {
402
  <p class="hint">After adding secrets, click <strong>Restart Space</strong> for them to take effect.</p>
403
  </div>
404
  </div>`;
405
- }).join("");
 
406
 
407
  return `<!DOCTYPE html>
408
  <html lang="en">
@@ -510,7 +907,7 @@ body{font-family:'Outfit',sans-serif;background:var(--bg);color:var(--text);heig
510
  </main>
511
  </div>
512
  <script>
513
- const PLATFORM_IDS = ${JSON.stringify(platforms.map(p => p.id))};
514
  function show(i) {
515
  document.querySelectorAll('.plat-tab').forEach((t,j) => t.classList.toggle('active', j===i));
516
  document.querySelectorAll('.panel').forEach((p,j) => p.classList.toggle('active', j===i));
@@ -553,8 +950,11 @@ function copy(text, btn) {
553
  // ============================================================================
554
 
555
  function parseRequestUrl(url) {
556
- try { return new URL(url, "http://localhost"); }
557
- catch { return new URL("http://localhost/"); }
 
 
 
558
  }
559
 
560
  function isLocalRoute(pathname) {
@@ -575,7 +975,9 @@ function isLocalRoute(pathname) {
575
  function getKeepaliveStatus() {
576
  try {
577
  if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) {
578
- return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
 
 
579
  }
580
  } catch {}
581
  return null;
@@ -593,7 +995,13 @@ function toneBadge(label, tone = "neutral") {
593
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
594
  }
595
 
596
- function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
 
 
 
 
 
 
597
  return `<article class="tile ${tone}">
598
  <div class="tile-head">
599
  <span class="tile-title">${escapeHtml(title)}</span>
@@ -617,25 +1025,41 @@ function readSyncStatus() {
617
  } catch {}
618
  if (HF_BACKUP_ENABLED) {
619
  return {
620
- db_status: "unknown", last_sync_time: null, last_error: null, sync_count: 0,
 
 
 
621
  status: "configured",
622
  message: `Backup enabled. Waiting for first sync (every ${SYNC_INTERVAL}s).`,
623
  };
624
  }
625
- return { db_status: "unknown", last_sync_time: null, last_error: null, sync_count: 0 };
 
 
 
 
 
626
  }
627
 
628
  function checkPostizHealth() {
629
  return new Promise((resolve) => {
630
- const timeout = setTimeout(() => resolve({ status: "unreachable", reason: "timeout" }), 5000);
631
- http.get(`http://${POSTIZ_HOST}:${POSTIZ_PORT}/`, (res) => {
632
- clearTimeout(timeout);
633
- resolve({ status: res.statusCode < 500 ? "running" : "error", statusCode: res.statusCode });
634
- res.resume();
635
- }).on("error", (err) => {
636
- clearTimeout(timeout);
637
- resolve({ status: "unreachable", reason: err.message });
638
- });
 
 
 
 
 
 
 
 
639
  });
640
  }
641
 
@@ -651,12 +1075,16 @@ function formatUptime(seconds) {
651
 
652
  function renderDashboard(data) {
653
  const syncStatus = String(data.sync?.status || "unknown");
654
- const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
 
 
655
  ? "ok"
656
  : syncStatus === "disabled"
657
  ? "warn"
658
  : "neutral";
659
- const backupDetail = data.sync?.message ? escapeHtml(data.sync.message) : "No status yet";
 
 
660
 
661
  const keepaliveConfigured = data.keepalive?.configured === true;
662
  const keepaliveStatus = String(
@@ -670,41 +1098,52 @@ function renderDashboard(data) {
670
  : "neutral";
671
  const keepAliveDetail = keepaliveConfigured
672
  ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
673
- : process.env.CLOUDFLARE_WORKERS_TOKEN
674
- ? "Worker pending or failed"
675
- : "Not configured";
 
 
676
 
677
  const platforms = getSocialPlatforms();
678
- const readyNow = platforms.filter(p => p.noOAuth);
679
- const needsSetup = platforms.filter(p => !p.noOAuth);
680
- const configuredCount = needsSetup.filter(p => p.ready).length;
681
-
682
- const needsSetupRows = needsSetup.map(p => {
683
- if (p.ready) {
684
- return `<div class="plat-row ready">
 
685
  <span class="plat-icon">${p.emoji}</span>
686
  <span class="plat-name">${p.name}</span>
687
  <span class="badge ok" style="font-size:0.72rem">Configured</span>
688
  </div>`;
689
- }
690
- return `<div class="plat-row">
691
  <span class="plat-icon" style="filter:grayscale(1);opacity:.5">${p.emoji}</span>
692
  <span class="plat-name" style="color:var(--dim)">${p.name}</span>
693
  <a class="setup-link" href="/setup#${p.id}" style="margin-right:4px">Setup guide →</a>
694
  </div>`;
695
- }).join("");
 
696
 
697
- const readyNowRows = readyNow.map(p => `
 
 
698
  <div class="plat-row ready">
699
  <span class="plat-icon">${p.emoji}</span>
700
  <span class="plat-name">${p.name}</span>
701
  <span style="font-size:0.75rem;color:var(--dim)">${p.note || ""}</span>
702
- </div>`).join("");
 
 
703
 
704
  const tiles = [
705
  renderTile({
706
  title: "Postiz Core",
707
- value: toneBadge(data.postizRunning ? "Online" : "Booting", data.postizRunning ? "ok" : "warn"),
 
 
 
708
  detail: `Backend Port ${POSTIZ_PORT}`,
709
  tone: data.postizRunning ? "ok" : "warn",
710
  }),
@@ -722,7 +1161,10 @@ function renderDashboard(data) {
722
  }),
723
  renderTile({
724
  title: "Keep Awake",
725
- value: toneBadge(keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), keepAliveTone),
 
 
 
726
  detail: keepAliveDetail,
727
  tone: keepAliveTone,
728
  }),
@@ -798,9 +1240,11 @@ function renderDashboard(data) {
798
  <div class="subtitle">Self-hosted Postiz Dashboard</div>
799
  </header>
800
 
801
- ${data.postizRunning
802
- ? `<a href="/app/auth" class="hero-action" target="_blank" rel="noopener">Open Postiz -></a>`
803
- : `<a href="#" class="hero-action booting" onclick="return false">Postiz is starting up (first boot ~5 min)...</a>`}
 
 
804
 
805
  <section class="overview">
806
  ${tiles}
@@ -870,8 +1314,12 @@ function renderDashboard(data) {
870
 
871
  function buildProxyHeaders(headers) {
872
  const f = headers["x-forwarded-for"];
873
- const clientIp = typeof f === "string" ? f.split(",")[0].trim()
874
- : (Array.isArray(f) && f.length ? String(f[0]).split(",")[0].trim() : "");
 
 
 
 
875
  return {
876
  ...headers,
877
  host: `${POSTIZ_HOST}:${POSTIZ_PORT}`,
@@ -927,28 +1375,39 @@ function proxyHttp(req, res, overridePath) {
927
  const targetPath = overridePath !== undefined ? overridePath : req.url;
928
  let upstreamStarted = false;
929
  const proxyReq = http.request(
930
- { hostname: POSTIZ_HOST, port: POSTIZ_PORT, method: req.method,
931
- path: targetPath, headers: buildProxyHeaders(req.headers) },
 
 
 
 
 
932
  (proxyRes) => {
933
  upstreamStarted = true;
934
  // Rewrite Location headers: add /app basePath if missing, convert
935
  // internal-host absolute URLs to relative paths.
936
  const outHeaders = Object.assign({}, proxyRes.headers);
937
  const fixedLoc = rewriteLocation(outHeaders["location"]);
938
- if (fixedLoc !== outHeaders["location"]) outHeaders["location"] = fixedLoc;
 
939
  res.writeHead(proxyRes.statusCode || 502, outHeaders);
940
  proxyRes.pipe(res);
941
  },
942
  );
943
  proxyReq.on("error", (error) => {
944
- if (res.headersSent || upstreamStarted) { res.destroy(); return; }
 
 
 
945
  res.writeHead(502, { "Content-Type": "application/json" });
946
- res.end(JSON.stringify({
947
- status: "error",
948
- message: "Postiz unavailable",
949
- detail: error.message,
950
- hint: "Postiz may still be starting (first boot ~60s after build). Check the Logs tab.",
951
- }));
 
 
952
  });
953
  res.on("close", () => proxyReq.destroy());
954
  req.pipe(proxyReq);
@@ -959,7 +1418,10 @@ function proxyUpgrade(req, socket, head, overridePath) {
959
  const proxySocket = net.connect(POSTIZ_PORT, POSTIZ_HOST);
960
  proxySocket.on("connect", () => {
961
  const f = req.headers["x-forwarded-for"];
962
- const clientIp = typeof f === "string" ? f.split(",")[0].trim() : req.socket.remoteAddress || "";
 
 
 
963
  const headerLines = [];
964
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
965
  const name = req.rawHeaders[i];
@@ -975,14 +1437,16 @@ function proxyUpgrade(req, socket, head, overridePath) {
975
  `X-Forwarded-For: ${clientIp}`,
976
  `X-Forwarded-Host: ${req.headers.host || ""}`,
977
  `X-Forwarded-Proto: ${req.headers["x-forwarded-proto"] || "https"}`,
978
- "", "",
 
979
  ];
980
  proxySocket.write(lines.join("\r\n"));
981
  if (head && head.length > 0) proxySocket.write(head);
982
  socket.pipe(proxySocket).pipe(socket);
983
  });
984
  proxySocket.on("error", () => {
985
- if (socket.writable) socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
 
986
  socket.destroy();
987
  });
988
  socket.on("error", () => proxySocket.destroy());
@@ -1000,10 +1464,15 @@ const server = http.createServer((req, res) => {
1000
  // ── /health ──────────────────────────────────────────────────────────────
1001
  if (pathname === "/health") {
1002
  res.writeHead(200, { "Content-Type": "application/json" });
1003
- res.end(JSON.stringify({
1004
- status: "ok", uptime, uptimeHuman: formatUptime(uptime),
1005
- timestamp: new Date().toISOString(), sync: readSyncStatus(),
1006
- }));
 
 
 
 
 
1007
  return;
1008
  }
1009
 
@@ -1012,12 +1481,14 @@ const server = http.createServer((req, res) => {
1012
  void (async () => {
1013
  const postiz = await checkPostizHealth();
1014
  res.writeHead(200, { "Content-Type": "application/json" });
1015
- res.end(JSON.stringify({
1016
- uptime: formatUptime(uptime),
1017
- postizRunning: postiz.status === "running",
1018
- sync: readSyncStatus(),
1019
- keepalive: getKeepaliveStatus(),
1020
- }));
 
 
1021
  })();
1022
  return;
1023
  }
@@ -1032,13 +1503,13 @@ const server = http.createServer((req, res) => {
1032
  // ── /app/debug-logs — Temporary endpoint for debugging ───────────────────
1033
  if (pathname === "/app/debug-logs") {
1034
  try {
1035
- const errLog = fs.existsSync("/root/.pm2/logs/backend-error.log")
1036
- ? fs.readFileSync("/root/.pm2/logs/backend-error.log", "utf8")
1037
  : "No backend-error.log found";
1038
  const outLog = fs.existsSync("/root/.pm2/logs/backend-out.log")
1039
  ? fs.readFileSync("/root/.pm2/logs/backend-out.log", "utf8")
1040
  : "No backend-out.log found";
1041
-
1042
  const cfProxyLog = fs.existsSync("/tmp/huggingpost-cloudflare-proxy.env")
1043
  ? fs.readFileSync("/tmp/huggingpost-cloudflare-proxy.env", "utf8")
1044
  : "No proxy env";
@@ -1069,11 +1540,13 @@ const server = http.createServer((req, res) => {
1069
 
1070
  // ── /app, /app/ and /app/* → proxy to nginx (Next.js handles routing) ────
1071
  if (pathname === "/app" || pathname === "/app/") {
1072
- // Postiz Next.js root redirect to /launches sometimes fails with basePath
1073
  // + trailingSlash:true, leaving users on a blank /app/ page after signup.
1074
  // Force the redirect here. Next.js middleware will still redirect to
1075
  // /auth/login if they aren't authenticated yet.
1076
- res.writeHead(302, { Location: "/app/launches/" + (parsedUrl.search || "") });
 
 
1077
  res.end();
1078
  return;
1079
  }
@@ -1116,7 +1589,9 @@ const server = http.createServer((req, res) => {
1116
  // basePath. Catch /_next/* and /static/* at root and 301 to /app/* so the
1117
  // browser learns the right prefix.
1118
  if (pathname.startsWith("/_next/") || pathname.startsWith("/static/")) {
1119
- res.writeHead(301, { Location: "/app" + pathname + (parsedUrl.search || "") });
 
 
1120
  res.end();
1121
  return;
1122
  }
@@ -1125,14 +1600,19 @@ const server = http.createServer((req, res) => {
1125
  // After login, Postiz's client-side router may navigate to a path without
1126
  // the /app basePath prefix (e.g. /launches, /analytics, /api/...).
1127
  // Redirect those here rather than 404-ing so the browser lands correctly.
1128
- res.writeHead(302, { Location: "/app" + pathname + (parsedUrl.search || "") });
 
 
1129
  res.end();
1130
  });
1131
 
1132
  server.on("upgrade", (req, socket, head) => {
1133
  const parsedUrl = parseRequestUrl(req.url || "/");
1134
  const pathname = parsedUrl.pathname;
1135
- if (isLocalRoute(pathname)) { socket.destroy(); return; }
 
 
 
1136
  if (pathname === "/app" || pathname.startsWith("/app/")) {
1137
  const stripped = pathname.slice("/app".length) || "/";
1138
  proxyUpgrade(req, socket, head, stripped + (parsedUrl.search || ""));
@@ -1144,5 +1624,7 @@ server.on("upgrade", (req, socket, head) => {
1144
  server.listen(PORT, "0.0.0.0", () => {
1145
  console.log(`✓ Health server listening on port ${PORT}`);
1146
  console.log(`✓ Dashboard : http://localhost:${PORT}/`);
1147
- console.log(`✓ Postiz : http://localhost:${PORT}/app/ → nginx :${POSTIZ_PORT}`);
 
 
1148
  });
 
31
  // is simpler and faster.
32
  const NEXTJS_PUBLIC_DIR = "/app/apps/frontend/public";
33
  const MIME_TYPES = {
34
+ ".jpg": "image/jpeg",
35
+ ".jpeg": "image/jpeg",
36
+ ".png": "image/png",
37
+ ".gif": "image/gif",
38
+ ".webp": "image/webp",
39
+ ".svg": "image/svg+xml",
40
+ ".ico": "image/x-icon",
41
+ ".woff": "font/woff",
42
+ ".woff2": "font/woff2",
43
+ ".ttf": "font/ttf",
44
+ ".eot": "application/vnd.ms-fontobject",
45
+ ".txt": "text/plain; charset=utf-8",
46
+ ".xml": "application/xml",
47
  };
48
  const POSTIZ_HOST = "127.0.0.1";
49
  const POSTIZ_PORT = 5000;
 
51
  const startTime = Date.now();
52
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
53
  const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "300";
54
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
55
+ "/tmp/huggingpost-cloudflare-keepalive-status.json";
56
 
57
  // Social platform env-var presence check (for dashboard status grid).
58
  // Each entry: { name, emoji, ready: bool, setupUrl, envVars, noOAuth }
 
60
  const e = process.env;
61
  return [
62
  // ── Works immediately (connect inside Postiz UI, no env vars needed) ─────
63
+ {
64
+ name: "Bluesky",
65
+ emoji: "🦋",
66
+ noOAuth: true,
67
+ ready: true,
68
+ note: "Username + App Password in Postiz",
69
+ },
70
+ {
71
+ name: "Mastodon",
72
+ emoji: "🐘",
73
+ noOAuth: true,
74
+ ready: true,
75
+ note: "Instance URL + credentials in Postiz",
76
+ },
77
+ {
78
+ name: "Telegram",
79
+ emoji: "✈️",
80
+ noOAuth: true,
81
+ ready: true,
82
+ note: "Bot token from @BotFather in Postiz",
83
+ },
84
+ {
85
+ name: "Nostr",
86
+ emoji: "🔑",
87
+ noOAuth: true,
88
+ ready: true,
89
+ note: "Private key in Postiz",
90
+ },
91
+ {
92
+ name: "Lemmy",
93
+ emoji: "🐾",
94
+ noOAuth: true,
95
+ ready: true,
96
+ note: "Instance + credentials in Postiz",
97
+ },
98
+ {
99
+ name: "Warpcast",
100
+ emoji: "🟣",
101
+ noOAuth: true,
102
+ ready: true,
103
+ note: "FID + private key in Postiz",
104
+ },
105
+ {
106
+ name: "Dev.to",
107
+ emoji: "💻",
108
+ noOAuth: true,
109
+ ready: true,
110
+ note: "API key from dev.to settings",
111
+ },
112
+ {
113
+ name: "Hashnode",
114
+ emoji: "📰",
115
+ noOAuth: true,
116
+ ready: true,
117
+ note: "API token from Hashnode settings",
118
+ },
119
  // ── Needs OAuth app (env vars required) ───────────────────────────────────
120
+ {
121
+ id: "linkedin",
122
+ name: "LinkedIn",
123
+ emoji: "💼",
124
+ ready: !!(e.LINKEDIN_CLIENT_ID && e.LINKEDIN_CLIENT_ID !== "undefined"),
125
  setupUrl: "https://www.linkedin.com/developers/apps/new",
126
+ envVars: ["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"],
127
+ },
128
+ {
129
+ id: "x",
130
+ name: "X / Twitter",
131
+ emoji: "🐦",
132
+ ready: !!e.X_API_KEY,
133
  setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps",
134
+ envVars: ["X_API_KEY", "X_API_SECRET"],
135
+ },
136
+ {
137
+ id: "facebook",
138
+ name: "Facebook",
139
+ emoji: "📘",
140
+ ready: !!e.FACEBOOK_APP_ID,
141
  setupUrl: "https://developers.facebook.com/apps/create/",
142
+ envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"],
143
+ },
144
+ {
145
+ id: "instagram",
146
+ name: "Instagram",
147
+ emoji: "📸",
148
+ ready: !!e.FACEBOOK_APP_ID,
149
  setupUrl: "https://developers.facebook.com/apps/create/",
150
  envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"],
151
+ note: "Uses same app as Facebook",
152
+ },
153
+ {
154
+ id: "threads",
155
+ name: "Threads",
156
+ emoji: "🧵",
157
+ ready: !!e.THREADS_APP_ID,
158
  setupUrl: "https://developers.facebook.com/apps/create/",
159
+ envVars: ["THREADS_APP_ID", "THREADS_APP_SECRET"],
160
+ },
161
+ {
162
+ id: "youtube",
163
+ name: "YouTube",
164
+ emoji: "▶️",
165
+ ready: !!e.YOUTUBE_CLIENT_ID,
166
  setupUrl: "https://console.cloud.google.com/apis/credentials",
167
+ envVars: ["YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET"],
168
+ },
169
+ {
170
+ id: "tiktok",
171
+ name: "TikTok",
172
+ emoji: "🎵",
173
+ ready: !!e.TIKTOK_CLIENT_ID,
174
  setupUrl: "https://developers.tiktok.com/",
175
+ envVars: ["TIKTOK_CLIENT_ID", "TIKTOK_CLIENT_SECRET"],
176
+ },
177
+ {
178
+ id: "reddit",
179
+ name: "Reddit",
180
+ emoji: "🤖",
181
+ ready: !!e.REDDIT_CLIENT_ID,
182
  setupUrl: "https://www.reddit.com/prefs/apps",
183
+ envVars: ["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"],
184
+ },
185
+ {
186
+ id: "pinterest",
187
+ name: "Pinterest",
188
+ emoji: "📌",
189
+ ready: !!e.PINTEREST_CLIENT_ID,
190
  setupUrl: "https://developers.pinterest.com/apps/",
191
+ envVars: ["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"],
192
+ },
193
+ {
194
+ id: "discord",
195
+ name: "Discord",
196
+ emoji: "🎮",
197
+ ready: !!e.DISCORD_CLIENT_ID,
198
  setupUrl: "https://discord.com/developers/applications",
199
+ envVars: [
200
+ "DISCORD_CLIENT_ID",
201
+ "DISCORD_CLIENT_SECRET",
202
+ "DISCORD_BOT_TOKEN_ID",
203
+ ],
204
+ },
205
+ {
206
+ id: "slack",
207
+ name: "Slack",
208
+ emoji: "💬",
209
+ ready: !!e.SLACK_ID,
210
  setupUrl: "https://api.slack.com/apps?new_app=1",
211
+ envVars: ["SLACK_ID", "SLACK_SECRET", "SLACK_SIGNING_SECRET"],
212
+ },
213
  ];
214
  }
215
 
 
224
  name: "LinkedIn",
225
  emoji: "💼",
226
  setupUrl: "https://www.linkedin.com/developers/apps/new",
227
+ docsUrl:
228
+ "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow",
229
  callbackUrl: cb("linkedin"),
230
  envVars: [
231
+ {
232
+ name: "LINKEDIN_CLIENT_ID",
233
+ desc: "Client ID",
234
+ set: !!e.LINKEDIN_CLIENT_ID,
235
+ },
236
+ {
237
+ name: "LINKEDIN_CLIENT_SECRET",
238
+ desc: "Client Secret",
239
+ set: !!e.LINKEDIN_CLIENT_SECRET,
240
+ },
241
  ],
242
  steps: [
243
+ {
244
+ title: "Create a LinkedIn App",
245
+ body: "Visit the developer portal. Create a new app; set <strong>App type = Web</strong>.",
246
+ },
247
+ {
248
+ title: "Add OAuth redirect URL",
249
+ body: "In the <strong>Auth</strong> tab → OAuth 2.0 settings, paste the callback URL below.",
250
+ },
251
+ {
252
+ title: "Enable products",
253
+ body: "Add <strong>Sign In with LinkedIn using OpenID Connect</strong> and <strong>Share on LinkedIn</strong> products.",
254
+ },
255
+ {
256
+ title: "Copy credentials",
257
+ body: "From the Auth tab, copy <strong>Client ID</strong> and <strong>Client Secret</strong>.",
258
+ },
259
+ {
260
+ title: "Add to Space secrets",
261
+ body: "Open your HF Space settings, add both env vars below, then restart the Space.",
262
+ },
263
  ],
264
  },
265
  {
 
267
  name: "X / Twitter",
268
  emoji: "🐦",
269
  setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps",
270
+ docsUrl:
271
+ "https://developer.twitter.com/en/docs/authentication/oauth-1-0a",
272
  callbackUrl: cb("x"),
273
  envVars: [
274
+ {
275
+ name: "X_API_KEY",
276
+ desc: "API Key (Consumer Key)",
277
+ set: !!e.X_API_KEY,
278
+ },
279
+ {
280
+ name: "X_API_SECRET",
281
+ desc: "API Secret (Consumer Secret)",
282
+ set: !!e.X_API_SECRET,
283
+ },
284
  ],
285
  steps: [
286
+ {
287
+ title: "Create an X Developer App",
288
+ body: 'Apply for a developer account at <a href="https://developer.twitter.com" target="_blank" rel="noopener" style="color:#f472b6">developer.twitter.com</a> if you don\'t have one. Create a new project + app.',
289
+ },
290
+ {
291
+ title: "Enable OAuth 1.0a + set permissions",
292
+ body: "On your app page → <strong>User authentication settings → Set up</strong>. Enable <strong>OAuth 1.0a</strong>. Set App permissions to <strong>Read and Write</strong>. Set Type of App to <strong>Native App</strong> (⚠️ must be Native App, not Web App — Web App breaks OAuth 1.0a).",
293
+ },
294
+ {
295
+ title: "Add callback URL",
296
+ body: "In the same setup screen, under <strong>Callback URI / Redirect URL</strong>, paste the Callback URL shown below.",
297
+ },
298
+ {
299
+ title: "Get your Consumer Secret",
300
+ body: "<strong>⚠️ The Consumer Secret (X_API_SECRET) is only shown once</strong> — right after app creation, or after you click <strong>Regenerate</strong> on the Consumer Key row in the Keys &amp; Tokens tab.<br><br>If you don't have it saved: go to <strong>Keys &amp; Tokens → OAuth 1.0 Keys → Regenerate</strong>. Copy <em>both</em> the new Consumer Key and Consumer Secret that appear in the popup.",
301
+ },
302
+ {
303
+ title: "Add to Space secrets",
304
+ body: "Add both env vars below to your HF Space settings → Variables &amp; Secrets, then restart the Space.",
305
+ },
306
  ],
307
  },
308
  {
 
313
  docsUrl: "https://developers.facebook.com/docs/facebook-login/web",
314
  callbackUrl: cb("facebook"),
315
  envVars: [
316
+ { name: "FACEBOOK_APP_ID", desc: "App ID", set: !!e.FACEBOOK_APP_ID },
317
+ {
318
+ name: "FACEBOOK_APP_SECRET",
319
+ desc: "App Secret",
320
+ set: !!e.FACEBOOK_APP_SECRET,
321
+ },
322
  ],
323
  steps: [
324
+ {
325
+ title: "Create a Meta App",
326
+ body: "Go to Meta for Developers. Create a new app with use case <strong>Authenticate and request data from users</strong>.",
327
+ },
328
+ {
329
+ title: "Add Facebook Login product",
330
+ body: "In the app dashboard, click <strong>Add Product</strong> → Facebook Login → Web.",
331
+ },
332
+ {
333
+ title: "Add callback URL",
334
+ body: "In Facebook Login settings → Valid OAuth Redirect URIs, paste the callback URL below.",
335
+ },
336
+ {
337
+ title: "Request permissions",
338
+ body: "Add <strong>pages_manage_posts</strong>, <strong>pages_read_engagement</strong>, <strong>publish_to_groups</strong> permissions.",
339
+ },
340
+ {
341
+ title: "Copy credentials",
342
+ body: "From <strong>App Settings → Basic</strong>, copy App ID and App Secret.",
343
+ },
344
+ {
345
+ title: "Add to Space secrets",
346
+ body: "Add both env vars below to your HF Space settings, then restart.",
347
+ },
348
  ],
349
  },
350
  {
 
355
  docsUrl: "https://developers.facebook.com/docs/instagram-api",
356
  callbackUrl: cb("instagram"),
357
  envVars: [
358
+ {
359
+ name: "FACEBOOK_APP_ID",
360
+ desc: "App ID (same as Facebook app)",
361
+ set: !!e.FACEBOOK_APP_ID,
362
+ },
363
+ {
364
+ name: "FACEBOOK_APP_SECRET",
365
+ desc: "App Secret (same as Facebook app)",
366
+ set: !!e.FACEBOOK_APP_SECRET,
367
+ },
368
  ],
369
  steps: [
370
+ {
371
+ title: "Use the Facebook app",
372
+ body: "Instagram uses the same Meta app as Facebook configure Facebook first.",
373
+ },
374
+ {
375
+ title: "Add Instagram Graph API product",
376
+ body: "In your Meta app dashboard, click <strong>Add Product</strong> → Instagram Graph API.",
377
+ },
378
+ {
379
+ title: "Connect an Instagram Business account",
380
+ body: "Your Instagram account must be a <strong>Professional (Business or Creator)</strong> account linked to a Facebook Page.",
381
+ },
382
+ {
383
+ title: "Add callback URL",
384
+ body: "In Instagram Login settings → Valid OAuth Redirect URIs, paste the callback URL below.",
385
+ },
386
+ {
387
+ title: "No extra env vars needed",
388
+ body: "Instagram and Facebook share <code>FACEBOOK_APP_ID</code> and <code>FACEBOOK_APP_SECRET</code>.",
389
+ },
390
  ],
391
  },
392
  {
 
397
  docsUrl: "https://developers.facebook.com/docs/threads",
398
  callbackUrl: cb("threads"),
399
  envVars: [
400
+ { name: "THREADS_APP_ID", desc: "App ID", set: !!e.THREADS_APP_ID },
401
+ {
402
+ name: "THREADS_APP_SECRET",
403
+ desc: "App Secret",
404
+ set: !!e.THREADS_APP_SECRET,
405
+ },
406
  ],
407
  steps: [
408
+ {
409
+ title: "Create a Meta App",
410
+ body: "Create a Meta Developer app (separate from Facebook/Instagram if you prefer clean separation).",
411
+ },
412
+ {
413
+ title: "Add Threads API product",
414
+ body: "In the app dashboard, click <strong>Add Product</strong> → Threads API.",
415
+ },
416
+ {
417
+ title: "Add callback URL",
418
+ body: "In Threads API settings → Redirect URI, paste the callback URL below.",
419
+ },
420
+ {
421
+ title: "Copy credentials",
422
+ body: "From <strong>App Settings → Basic</strong>, copy App ID and App Secret.",
423
+ },
424
+ {
425
+ title: "Add to Space secrets",
426
+ body: "Add both env vars below to your HF Space settings, then restart.",
427
+ },
428
  ],
429
  },
430
  {
 
432
  name: "YouTube",
433
  emoji: "▶️",
434
  setupUrl: "https://console.cloud.google.com/apis/credentials",
435
+ docsUrl:
436
+ "https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps",
437
  callbackUrl: cb("youtube"),
438
  envVars: [
439
+ {
440
+ name: "YOUTUBE_CLIENT_ID",
441
+ desc: "OAuth 2.0 Client ID",
442
+ set: !!e.YOUTUBE_CLIENT_ID,
443
+ },
444
+ {
445
+ name: "YOUTUBE_CLIENT_SECRET",
446
+ desc: "OAuth 2.0 Client Secret",
447
+ set: !!e.YOUTUBE_CLIENT_SECRET,
448
+ },
449
  ],
450
  steps: [
451
+ {
452
+ title: "Create a Google Cloud project",
453
+ body: "Go to Google Cloud Console. Create a new project (or use existing).",
454
+ },
455
+ {
456
+ title: "Enable YouTube Data API v3",
457
+ body: "In APIs & Services Library, search for <strong>YouTube Data API v3</strong> and enable it.",
458
+ },
459
+ {
460
+ title: "Create OAuth credentials",
461
+ body: "In APIs & Services → Credentials, click <strong>Create Credentials → OAuth client ID</strong>. Set type to <strong>Web application</strong>.",
462
+ },
463
+ {
464
+ title: "Add callback URL",
465
+ body: "Under Authorized redirect URIs, paste the callback URL below.",
466
+ },
467
+ {
468
+ title: "Configure OAuth consent screen",
469
+ body: "Set up consent screen with your app name. Add <strong>YouTube</strong> scopes.",
470
+ },
471
+ {
472
+ title: "Copy credentials",
473
+ body: "Download or copy the <strong>Client ID</strong> and <strong>Client Secret</strong>.",
474
+ },
475
+ {
476
+ title: "Add to Space secrets",
477
+ body: "Add both env vars below to your HF Space settings, then restart.",
478
+ },
479
  ],
480
  },
481
  {
 
486
  docsUrl: "https://developers.tiktok.com/doc/login-kit-web",
487
  callbackUrl: cb("tiktok"),
488
  envVars: [
489
+ {
490
+ name: "TIKTOK_CLIENT_ID",
491
+ desc: "Client Key",
492
+ set: !!e.TIKTOK_CLIENT_ID,
493
+ },
494
+ {
495
+ name: "TIKTOK_CLIENT_SECRET",
496
+ desc: "Client Secret",
497
+ set: !!e.TIKTOK_CLIENT_SECRET,
498
+ },
499
  ],
500
  steps: [
501
+ {
502
+ title: "Apply for TikTok Developer access",
503
+ body: "Sign in at developers.tiktok.com. Apply for developer access (may take 1-2 days).",
504
+ },
505
+ {
506
+ title: "Create an app",
507
+ body: "Create a new app. Set <strong>Platform: Web</strong>.",
508
+ },
509
+ {
510
+ title: "Add Login Kit",
511
+ body: "Add <strong>Login Kit</strong> product. This enables OAuth for your app.",
512
+ },
513
+ {
514
+ title: "Add callback URL",
515
+ body: "In Login Kit settings → Redirect domain, add your HF Space hostname. In redirect URI, paste the callback URL below.",
516
+ },
517
+ {
518
+ title: "Request Content Posting API",
519
+ body: "Add <strong>Content Posting API</strong> product for posting videos/photos.",
520
+ },
521
+ {
522
+ title: "Copy credentials",
523
+ body: "From app overview, copy <strong>Client Key</strong> (as CLIENT_ID) and <strong>Client Secret</strong>.",
524
+ },
525
+ {
526
+ title: "Add to Space secrets",
527
+ body: "Add both env vars below to your HF Space settings, then restart.",
528
+ },
529
  ],
530
  },
531
  {
 
536
  docsUrl: "https://github.com/reddit-archive/reddit/wiki/OAuth2",
537
  callbackUrl: cb("reddit"),
538
  envVars: [
539
+ {
540
+ name: "REDDIT_CLIENT_ID",
541
+ desc: "Client ID (under app name)",
542
+ set: !!e.REDDIT_CLIENT_ID,
543
+ },
544
+ {
545
+ name: "REDDIT_CLIENT_SECRET",
546
+ desc: "Secret",
547
+ set: !!e.REDDIT_CLIENT_SECRET,
548
+ },
549
  ],
550
  steps: [
551
+ {
552
+ title: "Go to Reddit App Preferences",
553
+ body: "Visit reddit.com/prefs/apps while logged in.",
554
+ },
555
+ {
556
+ title: "Create a new app",
557
+ body: "Click <strong>create another app…</strong>. Set type to <strong>web app</strong>.",
558
+ },
559
+ {
560
+ title: "Add callback URL",
561
+ body: "In the <strong>redirect uri</strong> field, paste the callback URL below.",
562
+ },
563
+ {
564
+ title: "Copy credentials",
565
+ body: 'The Client ID is the string below the app name. Client Secret is labelled "secret".',
566
+ },
567
+ {
568
+ title: "Add to Space secrets",
569
+ body: "Add both env vars below to your HF Space settings, then restart.",
570
+ },
571
  ],
572
  },
573
  {
 
575
  name: "Pinterest",
576
  emoji: "📌",
577
  setupUrl: "https://developers.pinterest.com/apps/",
578
+ docsUrl:
579
+ "https://developers.pinterest.com/docs/getting-started/set-up-app/",
580
  callbackUrl: cb("pinterest"),
581
  envVars: [
582
+ {
583
+ name: "PINTEREST_CLIENT_ID",
584
+ desc: "App ID",
585
+ set: !!e.PINTEREST_CLIENT_ID,
586
+ },
587
+ {
588
+ name: "PINTEREST_CLIENT_SECRET",
589
+ desc: "App Secret",
590
+ set: !!e.PINTEREST_CLIENT_SECRET,
591
+ },
592
  ],
593
  steps: [
594
+ {
595
+ title: "Create a Pinterest App",
596
+ body: "Go to Pinterest Developer Portal and create a new app.",
597
+ },
598
+ {
599
+ title: "Add redirect URI",
600
+ body: "In app settings, add the callback URL below as a redirect URI.",
601
+ },
602
+ {
603
+ title: "Request scopes",
604
+ body: "Request <strong>boards:read</strong>, <strong>pins:read</strong>, <strong>pins:write</strong> scopes.",
605
+ },
606
+ {
607
+ title: "Copy credentials",
608
+ body: "Copy App ID and App Secret from the app settings.",
609
+ },
610
+ {
611
+ title: "Add to Space secrets",
612
+ body: "Add both env vars below to your HF Space settings, then restart.",
613
+ },
614
  ],
615
  },
616
  {
 
621
  docsUrl: "https://discord.com/developers/docs/topics/oauth2",
622
  callbackUrl: cb("discord"),
623
  envVars: [
624
+ {
625
+ name: "DISCORD_CLIENT_ID",
626
+ desc: "Application ID",
627
+ set: !!e.DISCORD_CLIENT_ID,
628
+ },
629
+ {
630
+ name: "DISCORD_CLIENT_SECRET",
631
+ desc: "Client Secret",
632
+ set: !!e.DISCORD_CLIENT_SECRET,
633
+ },
634
+ {
635
+ name: "DISCORD_BOT_TOKEN_ID",
636
+ desc: "Bot Token",
637
+ set: !!e.DISCORD_BOT_TOKEN_ID,
638
+ },
639
  ],
640
  steps: [
641
+ {
642
+ title: "Create a Discord Application",
643
+ body: "Go to Discord Developer Portal New Application.",
644
+ },
645
+ {
646
+ title: "Add redirect URL",
647
+ body: "In <strong>OAuth2 → Redirects</strong>, paste the callback URL below.",
648
+ },
649
+ {
650
+ title: "Create a Bot",
651
+ body: "In the <strong>Bot</strong> section, create a bot. Enable <strong>Message Content Intent</strong>.",
652
+ },
653
+ {
654
+ title: "Copy credentials",
655
+ body: "Copy Client ID and Client Secret from OAuth2 tab. Copy Bot Token from Bot tab.",
656
+ },
657
+ {
658
+ title: "Add to Space secrets",
659
+ body: "Add all three env vars below to your HF Space settings, then restart.",
660
+ },
661
  ],
662
  },
663
  {
 
668
  docsUrl: "https://api.slack.com/authentication/oauth-v2",
669
  callbackUrl: cb("slack"),
670
  envVars: [
671
+ { name: "SLACK_ID", desc: "Client ID", set: !!e.SLACK_ID },
672
+ { name: "SLACK_SECRET", desc: "Client Secret", set: !!e.SLACK_SECRET },
673
+ {
674
+ name: "SLACK_SIGNING_SECRET",
675
+ desc: "Signing Secret",
676
+ set: !!e.SLACK_SIGNING_SECRET,
677
+ },
678
  ],
679
  steps: [
680
+ {
681
+ title: "Create a Slack App",
682
+ body: "Go to api.slack.com/apps Create New App From scratch.",
683
+ },
684
+ {
685
+ title: "Add OAuth redirect URL",
686
+ body: "In <strong>OAuth & Permissions → Redirect URLs</strong>, paste the callback URL below.",
687
+ },
688
+ {
689
+ title: "Add Bot Token Scopes",
690
+ body: "Under Bot Token Scopes, add: <code>channels:join</code>, <code>chat:write</code>, <code>channels:read</code>, <code>groups:read</code>.",
691
+ },
692
+ {
693
+ title: "Install to workspace",
694
+ body: "Click <strong>Install to Workspace</strong> to generate tokens.",
695
+ },
696
+ {
697
+ title: "Copy credentials",
698
+ body: "From <strong>Basic Information</strong>: App Credentials has Client ID, Client Secret, Signing Secret.",
699
+ },
700
+ {
701
+ title: "Add to Space secrets",
702
+ body: "Add all three env vars below to your HF Space settings, then restart.",
703
+ },
704
  ],
705
  },
706
  ];
 
709
  function renderSetupPage() {
710
  const spaceHost = process.env.SPACE_HOST || null;
711
  const spaceId = process.env.SPACE_ID || null;
712
+ const publicUrl = spaceHost
713
+ ? `https://${spaceHost}`
714
+ : "http://localhost:7860";
715
  const settingsUrl = spaceId
716
  ? `https://huggingface.co/spaces/${spaceId}/settings`
717
  : "https://huggingface.co/settings/spaces";
718
 
719
  const platforms = getOAuthPlatformDetails(publicUrl);
720
+ const configuredCount = platforms.filter((p) =>
721
+ p.envVars.every((v) => v.set),
722
+ ).length;
723
 
724
  // Build sidebar items
725
+ const sidebarItems = platforms
726
+ .map((p, i) => {
727
+ const allSet = p.envVars.every((v) => v.set);
728
+ const anySet = p.envVars.some((v) => v.set);
729
+ const indicator = allSet ? "" : anySet ? "⚠️" : "";
730
+ return `<button class="plat-tab${i === 0 ? " active" : ""}" onclick="show(${i})" id="tab-${i}">
731
  <span class="tab-emoji">${p.emoji}</span>
732
  <span class="tab-name">${p.name}</span>
733
  <span class="tab-indicator">${indicator}</span>
734
  </button>`;
735
+ })
736
+ .join("");
737
 
738
  // Build detail panels
739
+ const panels = platforms
740
+ .map((p, i) => {
741
+ const allSet = p.envVars.every((v) => v.set);
742
+
743
+ const stepsList = p.steps
744
+ .map(
745
+ (s, si) =>
746
+ `<div class="step"><div class="step-num">${si + 1}</div><div><div class="step-title">${s.title}</div><div class="step-body">${s.body}</div></div></div>`,
747
+ )
748
+ .join("");
749
+
750
+ const envRows = p.envVars
751
+ .map(
752
+ (v) =>
753
+ `<div class="env-row">
754
  <div class="env-info">
755
  <code class="env-name">${v.name}</code>
756
  <span class="env-desc">${v.desc}</span>
 
759
  ${v.set ? '<span class="badge badge-on" style="font-size:.7rem">Set ✓</span>' : '<span class="badge badge-off" style="font-size:.7rem">Not set</span>'}
760
  <button class="copy-btn" onclick="copy('${v.name}', this)">Copy name</button>
761
  </div>
762
+ </div>`,
763
+ )
764
+ .join("");
765
 
766
+ const statusBanner = allSet
767
+ ? `<div class="status-banner banner-ok">✅ All credentials configured — restart Space if you just added them.</div>`
768
+ : p.envVars.some((v) => v.set)
769
+ ? `<div class="status-banner banner-warn">⚠️ Partially configured — check missing env vars below.</div>`
770
+ : `<div class="status-banner banner-info">ℹ️ Not yet configured — follow the steps below.</div>`;
771
 
772
+ return `<div class="panel${i === 0 ? " active" : ""}" id="panel-${i}">
773
  <div class="panel-header">
774
  <span class="panel-emoji">${p.emoji}</span>
775
  <div>
 
798
  <p class="hint">After adding secrets, click <strong>Restart Space</strong> for them to take effect.</p>
799
  </div>
800
  </div>`;
801
+ })
802
+ .join("");
803
 
804
  return `<!DOCTYPE html>
805
  <html lang="en">
 
907
  </main>
908
  </div>
909
  <script>
910
+ const PLATFORM_IDS = ${JSON.stringify(platforms.map((p) => p.id))};
911
  function show(i) {
912
  document.querySelectorAll('.plat-tab').forEach((t,j) => t.classList.toggle('active', j===i));
913
  document.querySelectorAll('.panel').forEach((p,j) => p.classList.toggle('active', j===i));
 
950
  // ============================================================================
951
 
952
  function parseRequestUrl(url) {
953
+ try {
954
+ return new URL(url, "http://localhost");
955
+ } catch {
956
+ return new URL("http://localhost/");
957
+ }
958
  }
959
 
960
  function isLocalRoute(pathname) {
 
975
  function getKeepaliveStatus() {
976
  try {
977
  if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) {
978
+ return JSON.parse(
979
+ fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"),
980
+ );
981
  }
982
  } catch {}
983
  return null;
 
995
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
996
  }
997
 
998
+ function renderTile({
999
+ title,
1000
+ value,
1001
+ detail = "",
1002
+ tone = "neutral",
1003
+ meta = "",
1004
+ }) {
1005
  return `<article class="tile ${tone}">
1006
  <div class="tile-head">
1007
  <span class="tile-title">${escapeHtml(title)}</span>
 
1025
  } catch {}
1026
  if (HF_BACKUP_ENABLED) {
1027
  return {
1028
+ db_status: "unknown",
1029
+ last_sync_time: null,
1030
+ last_error: null,
1031
+ sync_count: 0,
1032
  status: "configured",
1033
  message: `Backup enabled. Waiting for first sync (every ${SYNC_INTERVAL}s).`,
1034
  };
1035
  }
1036
+ return {
1037
+ db_status: "unknown",
1038
+ last_sync_time: null,
1039
+ last_error: null,
1040
+ sync_count: 0,
1041
+ };
1042
  }
1043
 
1044
  function checkPostizHealth() {
1045
  return new Promise((resolve) => {
1046
+ const timeout = setTimeout(
1047
+ () => resolve({ status: "unreachable", reason: "timeout" }),
1048
+ 5000,
1049
+ );
1050
+ http
1051
+ .get(`http://${POSTIZ_HOST}:${POSTIZ_PORT}/`, (res) => {
1052
+ clearTimeout(timeout);
1053
+ resolve({
1054
+ status: res.statusCode < 500 ? "running" : "error",
1055
+ statusCode: res.statusCode,
1056
+ });
1057
+ res.resume();
1058
+ })
1059
+ .on("error", (err) => {
1060
+ clearTimeout(timeout);
1061
+ resolve({ status: "unreachable", reason: err.message });
1062
+ });
1063
  });
1064
  }
1065
 
 
1075
 
1076
  function renderDashboard(data) {
1077
  const syncStatus = String(data.sync?.status || "unknown");
1078
+ const syncTone = ["success", "restored", "synced", "configured"].includes(
1079
+ syncStatus,
1080
+ )
1081
  ? "ok"
1082
  : syncStatus === "disabled"
1083
  ? "warn"
1084
  : "neutral";
1085
+ const backupDetail = data.sync?.message
1086
+ ? escapeHtml(data.sync.message)
1087
+ : "No status yet";
1088
 
1089
  const keepaliveConfigured = data.keepalive?.configured === true;
1090
  const keepaliveStatus = String(
 
1098
  : "neutral";
1099
  const keepAliveDetail = keepaliveConfigured
1100
  ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
1101
+ : keepaliveStatus === "error" && data.keepalive?.message
1102
+ ? escapeHtml(data.keepalive.message)
1103
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
1104
+ ? "Worker pending or failed"
1105
+ : "Not configured";
1106
 
1107
  const platforms = getSocialPlatforms();
1108
+ const readyNow = platforms.filter((p) => p.noOAuth);
1109
+ const needsSetup = platforms.filter((p) => !p.noOAuth);
1110
+ const configuredCount = needsSetup.filter((p) => p.ready).length;
1111
+
1112
+ const needsSetupRows = needsSetup
1113
+ .map((p) => {
1114
+ if (p.ready) {
1115
+ return `<div class="plat-row ready">
1116
  <span class="plat-icon">${p.emoji}</span>
1117
  <span class="plat-name">${p.name}</span>
1118
  <span class="badge ok" style="font-size:0.72rem">Configured</span>
1119
  </div>`;
1120
+ }
1121
+ return `<div class="plat-row">
1122
  <span class="plat-icon" style="filter:grayscale(1);opacity:.5">${p.emoji}</span>
1123
  <span class="plat-name" style="color:var(--dim)">${p.name}</span>
1124
  <a class="setup-link" href="/setup#${p.id}" style="margin-right:4px">Setup guide →</a>
1125
  </div>`;
1126
+ })
1127
+ .join("");
1128
 
1129
+ const readyNowRows = readyNow
1130
+ .map(
1131
+ (p) => `
1132
  <div class="plat-row ready">
1133
  <span class="plat-icon">${p.emoji}</span>
1134
  <span class="plat-name">${p.name}</span>
1135
  <span style="font-size:0.75rem;color:var(--dim)">${p.note || ""}</span>
1136
+ </div>`,
1137
+ )
1138
+ .join("");
1139
 
1140
  const tiles = [
1141
  renderTile({
1142
  title: "Postiz Core",
1143
+ value: toneBadge(
1144
+ data.postizRunning ? "Online" : "Booting",
1145
+ data.postizRunning ? "ok" : "warn",
1146
+ ),
1147
  detail: `Backend Port ${POSTIZ_PORT}`,
1148
  tone: data.postizRunning ? "ok" : "warn",
1149
  }),
 
1161
  }),
1162
  renderTile({
1163
  title: "Keep Awake",
1164
+ value: toneBadge(
1165
+ keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
1166
+ keepAliveTone,
1167
+ ),
1168
  detail: keepAliveDetail,
1169
  tone: keepAliveTone,
1170
  }),
 
1240
  <div class="subtitle">Self-hosted Postiz Dashboard</div>
1241
  </header>
1242
 
1243
+ ${
1244
+ data.postizRunning
1245
+ ? `<a href="/app/auth" class="hero-action" target="_blank" rel="noopener">Open Postiz -></a>`
1246
+ : `<a href="#" class="hero-action booting" onclick="return false">Postiz is starting up (first boot ~5 min)...</a>`
1247
+ }
1248
 
1249
  <section class="overview">
1250
  ${tiles}
 
1314
 
1315
  function buildProxyHeaders(headers) {
1316
  const f = headers["x-forwarded-for"];
1317
+ const clientIp =
1318
+ typeof f === "string"
1319
+ ? f.split(",")[0].trim()
1320
+ : Array.isArray(f) && f.length
1321
+ ? String(f[0]).split(",")[0].trim()
1322
+ : "";
1323
  return {
1324
  ...headers,
1325
  host: `${POSTIZ_HOST}:${POSTIZ_PORT}`,
 
1375
  const targetPath = overridePath !== undefined ? overridePath : req.url;
1376
  let upstreamStarted = false;
1377
  const proxyReq = http.request(
1378
+ {
1379
+ hostname: POSTIZ_HOST,
1380
+ port: POSTIZ_PORT,
1381
+ method: req.method,
1382
+ path: targetPath,
1383
+ headers: buildProxyHeaders(req.headers),
1384
+ },
1385
  (proxyRes) => {
1386
  upstreamStarted = true;
1387
  // Rewrite Location headers: add /app basePath if missing, convert
1388
  // internal-host absolute URLs to relative paths.
1389
  const outHeaders = Object.assign({}, proxyRes.headers);
1390
  const fixedLoc = rewriteLocation(outHeaders["location"]);
1391
+ if (fixedLoc !== outHeaders["location"])
1392
+ outHeaders["location"] = fixedLoc;
1393
  res.writeHead(proxyRes.statusCode || 502, outHeaders);
1394
  proxyRes.pipe(res);
1395
  },
1396
  );
1397
  proxyReq.on("error", (error) => {
1398
+ if (res.headersSent || upstreamStarted) {
1399
+ res.destroy();
1400
+ return;
1401
+ }
1402
  res.writeHead(502, { "Content-Type": "application/json" });
1403
+ res.end(
1404
+ JSON.stringify({
1405
+ status: "error",
1406
+ message: "Postiz unavailable",
1407
+ detail: error.message,
1408
+ hint: "Postiz may still be starting (first boot ~60s after build). Check the Logs tab.",
1409
+ }),
1410
+ );
1411
  });
1412
  res.on("close", () => proxyReq.destroy());
1413
  req.pipe(proxyReq);
 
1418
  const proxySocket = net.connect(POSTIZ_PORT, POSTIZ_HOST);
1419
  proxySocket.on("connect", () => {
1420
  const f = req.headers["x-forwarded-for"];
1421
+ const clientIp =
1422
+ typeof f === "string"
1423
+ ? f.split(",")[0].trim()
1424
+ : req.socket.remoteAddress || "";
1425
  const headerLines = [];
1426
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
1427
  const name = req.rawHeaders[i];
 
1437
  `X-Forwarded-For: ${clientIp}`,
1438
  `X-Forwarded-Host: ${req.headers.host || ""}`,
1439
  `X-Forwarded-Proto: ${req.headers["x-forwarded-proto"] || "https"}`,
1440
+ "",
1441
+ "",
1442
  ];
1443
  proxySocket.write(lines.join("\r\n"));
1444
  if (head && head.length > 0) proxySocket.write(head);
1445
  socket.pipe(proxySocket).pipe(socket);
1446
  });
1447
  proxySocket.on("error", () => {
1448
+ if (socket.writable)
1449
+ socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
1450
  socket.destroy();
1451
  });
1452
  socket.on("error", () => proxySocket.destroy());
 
1464
  // ── /health ──────────────────────────────────────────────────────────────
1465
  if (pathname === "/health") {
1466
  res.writeHead(200, { "Content-Type": "application/json" });
1467
+ res.end(
1468
+ JSON.stringify({
1469
+ status: "ok",
1470
+ uptime,
1471
+ uptimeHuman: formatUptime(uptime),
1472
+ timestamp: new Date().toISOString(),
1473
+ sync: readSyncStatus(),
1474
+ }),
1475
+ );
1476
  return;
1477
  }
1478
 
 
1481
  void (async () => {
1482
  const postiz = await checkPostizHealth();
1483
  res.writeHead(200, { "Content-Type": "application/json" });
1484
+ res.end(
1485
+ JSON.stringify({
1486
+ uptime: formatUptime(uptime),
1487
+ postizRunning: postiz.status === "running",
1488
+ sync: readSyncStatus(),
1489
+ keepalive: getKeepaliveStatus(),
1490
+ }),
1491
+ );
1492
  })();
1493
  return;
1494
  }
 
1503
  // ── /app/debug-logs — Temporary endpoint for debugging ───────────────────
1504
  if (pathname === "/app/debug-logs") {
1505
  try {
1506
+ const errLog = fs.existsSync("/root/.pm2/logs/backend-error.log")
1507
+ ? fs.readFileSync("/root/.pm2/logs/backend-error.log", "utf8")
1508
  : "No backend-error.log found";
1509
  const outLog = fs.existsSync("/root/.pm2/logs/backend-out.log")
1510
  ? fs.readFileSync("/root/.pm2/logs/backend-out.log", "utf8")
1511
  : "No backend-out.log found";
1512
+
1513
  const cfProxyLog = fs.existsSync("/tmp/huggingpost-cloudflare-proxy.env")
1514
  ? fs.readFileSync("/tmp/huggingpost-cloudflare-proxy.env", "utf8")
1515
  : "No proxy env";
 
1540
 
1541
  // ── /app, /app/ and /app/* → proxy to nginx (Next.js handles routing) ────
1542
  if (pathname === "/app" || pathname === "/app/") {
1543
+ // Postiz Next.js root redirect to /launches sometimes fails with basePath
1544
  // + trailingSlash:true, leaving users on a blank /app/ page after signup.
1545
  // Force the redirect here. Next.js middleware will still redirect to
1546
  // /auth/login if they aren't authenticated yet.
1547
+ res.writeHead(302, {
1548
+ Location: "/app/launches/" + (parsedUrl.search || ""),
1549
+ });
1550
  res.end();
1551
  return;
1552
  }
 
1589
  // basePath. Catch /_next/* and /static/* at root and 301 to /app/* so the
1590
  // browser learns the right prefix.
1591
  if (pathname.startsWith("/_next/") || pathname.startsWith("/static/")) {
1592
+ res.writeHead(301, {
1593
+ Location: "/app" + pathname + (parsedUrl.search || ""),
1594
+ });
1595
  res.end();
1596
  return;
1597
  }
 
1600
  // After login, Postiz's client-side router may navigate to a path without
1601
  // the /app basePath prefix (e.g. /launches, /analytics, /api/...).
1602
  // Redirect those here rather than 404-ing so the browser lands correctly.
1603
+ res.writeHead(302, {
1604
+ Location: "/app" + pathname + (parsedUrl.search || ""),
1605
+ });
1606
  res.end();
1607
  });
1608
 
1609
  server.on("upgrade", (req, socket, head) => {
1610
  const parsedUrl = parseRequestUrl(req.url || "/");
1611
  const pathname = parsedUrl.pathname;
1612
+ if (isLocalRoute(pathname)) {
1613
+ socket.destroy();
1614
+ return;
1615
+ }
1616
  if (pathname === "/app" || pathname.startsWith("/app/")) {
1617
  const stripped = pathname.slice("/app".length) || "/";
1618
  proxyUpgrade(req, socket, head, stripped + (parsedUrl.search || ""));
 
1624
  server.listen(PORT, "0.0.0.0", () => {
1625
  console.log(`✓ Health server listening on port ${PORT}`);
1626
  console.log(`✓ Dashboard : http://localhost:${PORT}/`);
1627
+ console.log(
1628
+ `✓ Postiz : http://localhost:${PORT}/app/ → nginx :${POSTIZ_PORT}`,
1629
+ );
1630
  });