Spaces:
Running
Running
fix: improve keep-alive status message handling in renderDashboard function
Browse files- 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",
|
| 35 |
-
".
|
| 36 |
-
".
|
| 37 |
-
".
|
| 38 |
-
".
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
| 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 |
-
{
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
// ── Needs OAuth app (env vars required) ───────────────────────────────────
|
| 63 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
setupUrl: "https://www.linkedin.com/developers/apps/new",
|
| 65 |
-
envVars: ["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"]
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps",
|
| 68 |
-
envVars: ["X_API_KEY", "X_API_SECRET"]
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
setupUrl: "https://developers.facebook.com/apps/create/",
|
| 71 |
-
envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"]
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
setupUrl: "https://developers.facebook.com/apps/create/",
|
| 74 |
envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"],
|
| 75 |
-
note: "Uses same app as Facebook"
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
setupUrl: "https://developers.facebook.com/apps/create/",
|
| 78 |
-
envVars: ["THREADS_APP_ID", "THREADS_APP_SECRET"]
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
setupUrl: "https://console.cloud.google.com/apis/credentials",
|
| 81 |
-
envVars: ["YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET"]
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
setupUrl: "https://developers.tiktok.com/",
|
| 84 |
-
envVars: ["TIKTOK_CLIENT_ID", "TIKTOK_CLIENT_SECRET"]
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
setupUrl: "https://www.reddit.com/prefs/apps",
|
| 87 |
-
envVars: ["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"]
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
setupUrl: "https://developers.pinterest.com/apps/",
|
| 90 |
-
envVars: ["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"]
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
setupUrl: "https://discord.com/developers/applications",
|
| 93 |
-
envVars: [
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 112 |
callbackUrl: cb("linkedin"),
|
| 113 |
envVars: [
|
| 114 |
-
{
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
],
|
| 117 |
steps: [
|
| 118 |
-
{
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 131 |
callbackUrl: cb("x"),
|
| 132 |
envVars: [
|
| 133 |
-
{
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
],
|
| 136 |
steps: [
|
| 137 |
-
{
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 153 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
],
|
| 155 |
steps: [
|
| 156 |
-
{
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
{
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
],
|
| 175 |
steps: [
|
| 176 |
-
{
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 192 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
],
|
| 194 |
steps: [
|
| 195 |
-
{
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 208 |
callbackUrl: cb("youtube"),
|
| 209 |
envVars: [
|
| 210 |
-
{
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
],
|
| 213 |
steps: [
|
| 214 |
-
{
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
{
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
],
|
| 234 |
steps: [
|
| 235 |
-
{
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
{
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
],
|
| 255 |
steps: [
|
| 256 |
-
{
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
],
|
| 262 |
},
|
| 263 |
{
|
|
@@ -265,18 +575,42 @@ function getOAuthPlatformDetails(publicUrl) {
|
|
| 265 |
name: "Pinterest",
|
| 266 |
emoji: "📌",
|
| 267 |
setupUrl: "https://developers.pinterest.com/apps/",
|
| 268 |
-
docsUrl:
|
|
|
|
| 269 |
callbackUrl: cb("pinterest"),
|
| 270 |
envVars: [
|
| 271 |
-
{
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
],
|
| 274 |
steps: [
|
| 275 |
-
{
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
],
|
| 294 |
steps: [
|
| 295 |
-
{
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 311 |
-
{ name: "SLACK_SECRET",
|
| 312 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
],
|
| 314 |
steps: [
|
| 315 |
-
{
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
{
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 =>
|
|
|
|
|
|
|
| 336 |
|
| 337 |
// Build sidebar items
|
| 338 |
-
const sidebarItems = platforms
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 348 |
|
| 349 |
// Build detail panels
|
| 350 |
-
const panels = platforms
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 369 |
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
|
| 376 |
-
|
| 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 |
-
|
|
|
|
| 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 {
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
|
|
|
|
|
|
|
|
| 621 |
status: "configured",
|
| 622 |
message: `Backup enabled. Waiting for first sync (every ${SYNC_INTERVAL}s).`,
|
| 623 |
};
|
| 624 |
}
|
| 625 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
}
|
| 627 |
|
| 628 |
function checkPostizHealth() {
|
| 629 |
return new Promise((resolve) => {
|
| 630 |
-
const timeout = setTimeout(
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 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(
|
|
|
|
|
|
|
| 655 |
? "ok"
|
| 656 |
: syncStatus === "disabled"
|
| 657 |
? "warn"
|
| 658 |
: "neutral";
|
| 659 |
-
const backupDetail = data.sync?.message
|
|
|
|
|
|
|
| 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 |
-
:
|
| 674 |
-
?
|
| 675 |
-
:
|
|
|
|
|
|
|
| 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
|
| 683 |
-
|
| 684 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 696 |
|
| 697 |
-
const readyNowRows = readyNow
|
|
|
|
|
|
|
| 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>`
|
|
|
|
|
|
|
| 703 |
|
| 704 |
const tiles = [
|
| 705 |
renderTile({
|
| 706 |
title: "Postiz Core",
|
| 707 |
-
value: toneBadge(
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
${
|
| 802 |
-
|
| 803 |
-
|
|
|
|
|
|
|
| 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 =
|
| 874 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 931 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"])
|
|
|
|
| 939 |
res.writeHead(proxyRes.statusCode || 502, outHeaders);
|
| 940 |
proxyRes.pipe(res);
|
| 941 |
},
|
| 942 |
);
|
| 943 |
proxyReq.on("error", (error) => {
|
| 944 |
-
if (res.headersSent || upstreamStarted) {
|
|
|
|
|
|
|
|
|
|
| 945 |
res.writeHead(502, { "Content-Type": "application/json" });
|
| 946 |
-
res.end(
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
| 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(
|
| 1004 |
-
|
| 1005 |
-
|
| 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(
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 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, {
|
|
|
|
|
|
|
| 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, {
|
|
|
|
|
|
|
| 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, {
|
|
|
|
|
|
|
| 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)) {
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 & Tokens tab.<br><br>If you don't have it saved: go to <strong>Keys & 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 & 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 |
});
|