// Single public entrypoint for HF Spaces: HuggingPost dashboard + reverse // proxy to Postiz (which lives behind the container's internal nginx on // port 5000 — that nginx routes /api → backend, /uploads → file system, // / → frontend). // // Routing rules (in order): // /health, /status, /uptimerobot/setup → handled here // / (exact) → HuggingPost dashboard HTML // /app, /app/ or /app/* → Postiz (nginx :5000), /app prefix stripped // /_next/* or /static/* → 301 redirect to /app/ // (catches asset URLs Next.js may emit // without basePath in edge cases) // anything else → 404 // // Why strip /app: the Postiz frontend is built with basePath="/app" so it // emits asset URLs prefixed with /app. The browser sends /app/_next/foo to // us; we strip /app and forward /_next/foo to nginx :5000, which forwards // to Next.js on :4200. nginx's own routes (/api, /uploads, /) are also // reached after we strip the /app prefix. const http = require("http"); const fs = require("fs"); const net = require("net"); const path = require("path"); const PORT = 7860; // Static files in Next.js public/ directory are served directly from disk. // The nginx proxy chain re-adds the /app basePath prefix when forwarding to // Next.js:4200, making public file paths misalign. Serving from disk here // is simpler and faster. const NEXTJS_PUBLIC_DIR = "/app/apps/frontend/public"; const MIME_TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".eot": "application/vnd.ms-fontobject", ".txt": "text/plain; charset=utf-8", ".xml": "application/xml", }; const POSTIZ_HOST = "127.0.0.1"; const POSTIZ_PORT = 5000; const startTime = Date.now(); const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN; const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "300"; const CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/huggingpost-cloudflare-keepalive-status.json"; // Social platform env-var presence check (for dashboard status grid). // Each entry: { name, emoji, ready: bool, setupUrl, envVars, noOAuth } function getSocialPlatforms() { const e = process.env; return [ // ── Works immediately (connect inside Postiz UI, no env vars needed) ───── { name: "Bluesky", emoji: "🦋", noOAuth: true, ready: true, note: "Username + App Password in Postiz", }, { name: "Mastodon", emoji: "🐘", noOAuth: true, ready: true, note: "Instance URL + credentials in Postiz", }, { name: "Telegram", emoji: "✈️", noOAuth: true, ready: true, note: "Bot token from @BotFather in Postiz", }, { name: "Nostr", emoji: "🔑", noOAuth: true, ready: true, note: "Private key in Postiz", }, { name: "Lemmy", emoji: "🐾", noOAuth: true, ready: true, note: "Instance + credentials in Postiz", }, { name: "Warpcast", emoji: "🟣", noOAuth: true, ready: true, note: "FID + private key in Postiz", }, { name: "Dev.to", emoji: "💻", noOAuth: true, ready: true, note: "API key from dev.to settings", }, { name: "Hashnode", emoji: "📰", noOAuth: true, ready: true, note: "API token from Hashnode settings", }, // ── Needs OAuth app (env vars required) ─────────────────────────────────── { id: "linkedin", name: "LinkedIn", emoji: "💼", ready: !!(e.LINKEDIN_CLIENT_ID && e.LINKEDIN_CLIENT_ID !== "undefined"), setupUrl: "https://www.linkedin.com/developers/apps/new", envVars: ["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"], }, { id: "x", name: "X / Twitter", emoji: "🐦", ready: !!e.X_API_KEY, setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps", envVars: ["X_API_KEY", "X_API_SECRET"], }, { id: "facebook", name: "Facebook", emoji: "📘", ready: !!e.FACEBOOK_APP_ID, setupUrl: "https://developers.facebook.com/apps/create/", envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"], }, { id: "instagram", name: "Instagram", emoji: "📸", ready: !!e.FACEBOOK_APP_ID, setupUrl: "https://developers.facebook.com/apps/create/", envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"], note: "Uses same app as Facebook", }, { id: "threads", name: "Threads", emoji: "🧵", ready: !!e.THREADS_APP_ID, setupUrl: "https://developers.facebook.com/apps/create/", envVars: ["THREADS_APP_ID", "THREADS_APP_SECRET"], }, { id: "youtube", name: "YouTube", emoji: "▶️", ready: !!e.YOUTUBE_CLIENT_ID, setupUrl: "https://console.cloud.google.com/apis/credentials", envVars: ["YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET"], }, { id: "tiktok", name: "TikTok", emoji: "🎵", ready: !!e.TIKTOK_CLIENT_ID, setupUrl: "https://developers.tiktok.com/", envVars: ["TIKTOK_CLIENT_ID", "TIKTOK_CLIENT_SECRET"], }, { id: "reddit", name: "Reddit", emoji: "🤖", ready: !!e.REDDIT_CLIENT_ID, setupUrl: "https://www.reddit.com/prefs/apps", envVars: ["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"], }, { id: "pinterest", name: "Pinterest", emoji: "📌", ready: !!e.PINTEREST_CLIENT_ID, setupUrl: "https://developers.pinterest.com/apps/", envVars: ["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"], }, { id: "discord", name: "Discord", emoji: "🎮", ready: !!e.DISCORD_CLIENT_ID, setupUrl: "https://discord.com/developers/applications", envVars: [ "DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET", "DISCORD_BOT_TOKEN_ID", ], }, { id: "slack", name: "Slack", emoji: "💬", ready: !!e.SLACK_ID, setupUrl: "https://api.slack.com/apps?new_app=1", envVars: ["SLACK_ID", "SLACK_SECRET", "SLACK_SIGNING_SECRET"], }, ]; } // Returns detailed per-platform OAuth setup guide data. // publicUrl: "https://somratpro-huggingpost.hf.space" (no trailing slash) function getOAuthPlatformDetails(publicUrl) { const cb = (provider) => `${publicUrl}/integrations/social/${provider}`; const e = process.env; return [ { id: "linkedin", name: "LinkedIn", emoji: "💼", setupUrl: "https://www.linkedin.com/developers/apps/new", docsUrl: "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow", callbackUrl: cb("linkedin"), envVars: [ { name: "LINKEDIN_CLIENT_ID", desc: "Client ID", set: !!e.LINKEDIN_CLIENT_ID, }, { name: "LINKEDIN_CLIENT_SECRET", desc: "Client Secret", set: !!e.LINKEDIN_CLIENT_SECRET, }, ], steps: [ { title: "Create a LinkedIn App", body: "Visit the developer portal. Create a new app; set App type = Web.", }, { title: "Add OAuth redirect URL", body: "In the Auth tab → OAuth 2.0 settings, paste the callback URL below.", }, { title: "Enable products", body: "Add Sign In with LinkedIn using OpenID Connect and Share on LinkedIn products.", }, { title: "Copy credentials", body: "From the Auth tab, copy Client ID and Client Secret.", }, { title: "Add to Space secrets", body: "Open your HF Space settings, add both env vars below, then restart the Space.", }, ], }, { id: "x", name: "X / Twitter", emoji: "🐦", setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps", docsUrl: "https://developer.twitter.com/en/docs/authentication/oauth-1-0a", callbackUrl: cb("x"), envVars: [ { name: "X_API_KEY", desc: "API Key (Consumer Key)", set: !!e.X_API_KEY, }, { name: "X_API_SECRET", desc: "API Secret (Consumer Secret)", set: !!e.X_API_SECRET, }, ], steps: [ { title: "Create an X Developer App", body: 'Apply for a developer account at developer.twitter.com if you don\'t have one. Create a new project + app.', }, { title: "Enable OAuth 1.0a + set permissions", body: "On your app page → User authentication settings → Set up. Enable OAuth 1.0a. Set App permissions to Read and Write. Set Type of App to Native App (⚠️ must be Native App, not Web App — Web App breaks OAuth 1.0a).", }, { title: "Add callback URL", body: "In the same setup screen, under Callback URI / Redirect URL, paste the Callback URL shown below.", }, { title: "Get your Consumer Secret", body: "⚠️ The Consumer Secret (X_API_SECRET) is only shown once — right after app creation, or after you click Regenerate on the Consumer Key row in the Keys & Tokens tab.

If you don't have it saved: go to Keys & Tokens → OAuth 1.0 Keys → Regenerate. Copy both the new Consumer Key and Consumer Secret that appear in the popup.", }, { title: "Add to Space secrets", body: "Add both env vars below to your HF Space settings → Variables & Secrets, then restart the Space.", }, ], }, { id: "facebook", name: "Facebook", emoji: "📘", setupUrl: "https://developers.facebook.com/apps/create/", docsUrl: "https://developers.facebook.com/docs/facebook-login/web", callbackUrl: cb("facebook"), envVars: [ { name: "FACEBOOK_APP_ID", desc: "App ID", set: !!e.FACEBOOK_APP_ID }, { name: "FACEBOOK_APP_SECRET", desc: "App Secret", set: !!e.FACEBOOK_APP_SECRET, }, ], steps: [ { title: "Create a Meta App", body: "Go to Meta for Developers. Create a new app with use case Authenticate and request data from users.", }, { title: "Add Facebook Login product", body: "In the app dashboard, click Add Product → Facebook Login → Web.", }, { title: "Add callback URL", body: "In Facebook Login settings → Valid OAuth Redirect URIs, paste the callback URL below.", }, { title: "Request permissions", body: "Add pages_manage_posts, pages_read_engagement, publish_to_groups permissions.", }, { title: "Copy credentials", body: "From App Settings → Basic, copy App ID and App Secret.", }, { title: "Add to Space secrets", body: "Add both env vars below to your HF Space settings, then restart.", }, ], }, { id: "instagram", name: "Instagram", emoji: "📸", setupUrl: "https://developers.facebook.com/apps/create/", docsUrl: "https://developers.facebook.com/docs/instagram-api", callbackUrl: cb("instagram"), envVars: [ { name: "FACEBOOK_APP_ID", desc: "App ID (same as Facebook app)", set: !!e.FACEBOOK_APP_ID, }, { name: "FACEBOOK_APP_SECRET", desc: "App Secret (same as Facebook app)", set: !!e.FACEBOOK_APP_SECRET, }, ], steps: [ { title: "Use the Facebook app", body: "Instagram uses the same Meta app as Facebook — configure Facebook first.", }, { title: "Add Instagram Graph API product", body: "In your Meta app dashboard, click Add Product → Instagram Graph API.", }, { title: "Connect an Instagram Business account", body: "Your Instagram account must be a Professional (Business or Creator) account linked to a Facebook Page.", }, { title: "Add callback URL", body: "In Instagram Login settings → Valid OAuth Redirect URIs, paste the callback URL below.", }, { title: "No extra env vars needed", body: "Instagram and Facebook share FACEBOOK_APP_ID and FACEBOOK_APP_SECRET.", }, ], }, { id: "threads", name: "Threads", emoji: "🧵", setupUrl: "https://developers.facebook.com/apps/create/", docsUrl: "https://developers.facebook.com/docs/threads", callbackUrl: cb("threads"), envVars: [ { name: "THREADS_APP_ID", desc: "App ID", set: !!e.THREADS_APP_ID }, { name: "THREADS_APP_SECRET", desc: "App Secret", set: !!e.THREADS_APP_SECRET, }, ], steps: [ { title: "Create a Meta App", body: "Create a Meta Developer app (separate from Facebook/Instagram if you prefer clean separation).", }, { title: "Add Threads API product", body: "In the app dashboard, click Add Product → Threads API.", }, { title: "Add callback URL", body: "In Threads API settings → Redirect URI, paste the callback URL below.", }, { title: "Copy credentials", body: "From App Settings → Basic, copy App ID and App Secret.", }, { title: "Add to Space secrets", body: "Add both env vars below to your HF Space settings, then restart.", }, ], }, { id: "youtube", name: "YouTube", emoji: "▶️", setupUrl: "https://console.cloud.google.com/apis/credentials", docsUrl: "https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps", callbackUrl: cb("youtube"), envVars: [ { name: "YOUTUBE_CLIENT_ID", desc: "OAuth 2.0 Client ID", set: !!e.YOUTUBE_CLIENT_ID, }, { name: "YOUTUBE_CLIENT_SECRET", desc: "OAuth 2.0 Client Secret", set: !!e.YOUTUBE_CLIENT_SECRET, }, ], steps: [ { title: "Create a Google Cloud project", body: "Go to Google Cloud Console. Create a new project (or use existing).", }, { title: "Enable YouTube Data API v3", body: "In APIs & Services → Library, search for YouTube Data API v3 and enable it.", }, { title: "Create OAuth credentials", body: "In APIs & Services → Credentials, click Create Credentials → OAuth client ID. Set type to Web application.", }, { title: "Add callback URL", body: "Under Authorized redirect URIs, paste the callback URL below.", }, { title: "Configure OAuth consent screen", body: "Set up consent screen with your app name. Add YouTube scopes.", }, { title: "Copy credentials", body: "Download or copy the Client ID and Client Secret.", }, { title: "Add to Space secrets", body: "Add both env vars below to your HF Space settings, then restart.", }, ], }, { id: "tiktok", name: "TikTok", emoji: "🎵", setupUrl: "https://developers.tiktok.com/", docsUrl: "https://developers.tiktok.com/doc/login-kit-web", callbackUrl: cb("tiktok"), envVars: [ { name: "TIKTOK_CLIENT_ID", desc: "Client Key", set: !!e.TIKTOK_CLIENT_ID, }, { name: "TIKTOK_CLIENT_SECRET", desc: "Client Secret", set: !!e.TIKTOK_CLIENT_SECRET, }, ], steps: [ { title: "Apply for TikTok Developer access", body: "Sign in at developers.tiktok.com. Apply for developer access (may take 1-2 days).", }, { title: "Create an app", body: "Create a new app. Set Platform: Web.", }, { title: "Add Login Kit", body: "Add Login Kit product. This enables OAuth for your app.", }, { title: "Add callback URL", body: "In Login Kit settings → Redirect domain, add your HF Space hostname. In redirect URI, paste the callback URL below.", }, { title: "Request Content Posting API", body: "Add Content Posting API product for posting videos/photos.", }, { title: "Copy credentials", body: "From app overview, copy Client Key (as CLIENT_ID) and Client Secret.", }, { title: "Add to Space secrets", body: "Add both env vars below to your HF Space settings, then restart.", }, ], }, { id: "reddit", name: "Reddit", emoji: "🤖", setupUrl: "https://www.reddit.com/prefs/apps", docsUrl: "https://github.com/reddit-archive/reddit/wiki/OAuth2", callbackUrl: cb("reddit"), envVars: [ { name: "REDDIT_CLIENT_ID", desc: "Client ID (under app name)", set: !!e.REDDIT_CLIENT_ID, }, { name: "REDDIT_CLIENT_SECRET", desc: "Secret", set: !!e.REDDIT_CLIENT_SECRET, }, ], steps: [ { title: "Go to Reddit App Preferences", body: "Visit reddit.com/prefs/apps while logged in.", }, { title: "Create a new app", body: "Click create another app…. Set type to web app.", }, { title: "Add callback URL", body: "In the redirect uri field, paste the callback URL below.", }, { title: "Copy credentials", body: 'The Client ID is the string below the app name. Client Secret is labelled "secret".', }, { title: "Add to Space secrets", body: "Add both env vars below to your HF Space settings, then restart.", }, ], }, { id: "pinterest", name: "Pinterest", emoji: "📌", setupUrl: "https://developers.pinterest.com/apps/", docsUrl: "https://developers.pinterest.com/docs/getting-started/set-up-app/", callbackUrl: cb("pinterest"), envVars: [ { name: "PINTEREST_CLIENT_ID", desc: "App ID", set: !!e.PINTEREST_CLIENT_ID, }, { name: "PINTEREST_CLIENT_SECRET", desc: "App Secret", set: !!e.PINTEREST_CLIENT_SECRET, }, ], steps: [ { title: "Create a Pinterest App", body: "Go to Pinterest Developer Portal and create a new app.", }, { title: "Add redirect URI", body: "In app settings, add the callback URL below as a redirect URI.", }, { title: "Request scopes", body: "Request boards:read, pins:read, pins:write scopes.", }, { title: "Copy credentials", body: "Copy App ID and App Secret from the app settings.", }, { title: "Add to Space secrets", body: "Add both env vars below to your HF Space settings, then restart.", }, ], }, { id: "discord", name: "Discord", emoji: "🎮", setupUrl: "https://discord.com/developers/applications", docsUrl: "https://discord.com/developers/docs/topics/oauth2", callbackUrl: cb("discord"), envVars: [ { name: "DISCORD_CLIENT_ID", desc: "Application ID", set: !!e.DISCORD_CLIENT_ID, }, { name: "DISCORD_CLIENT_SECRET", desc: "Client Secret", set: !!e.DISCORD_CLIENT_SECRET, }, { name: "DISCORD_BOT_TOKEN_ID", desc: "Bot Token", set: !!e.DISCORD_BOT_TOKEN_ID, }, ], steps: [ { title: "Create a Discord Application", body: "Go to Discord Developer Portal → New Application.", }, { title: "Add redirect URL", body: "In OAuth2 → Redirects, paste the callback URL below.", }, { title: "Create a Bot", body: "In the Bot section, create a bot. Enable Message Content Intent.", }, { title: "Copy credentials", body: "Copy Client ID and Client Secret from OAuth2 tab. Copy Bot Token from Bot tab.", }, { title: "Add to Space secrets", body: "Add all three env vars below to your HF Space settings, then restart.", }, ], }, { id: "slack", name: "Slack", emoji: "💬", setupUrl: "https://api.slack.com/apps?new_app=1", docsUrl: "https://api.slack.com/authentication/oauth-v2", callbackUrl: cb("slack"), envVars: [ { name: "SLACK_ID", desc: "Client ID", set: !!e.SLACK_ID }, { name: "SLACK_SECRET", desc: "Client Secret", set: !!e.SLACK_SECRET }, { name: "SLACK_SIGNING_SECRET", desc: "Signing Secret", set: !!e.SLACK_SIGNING_SECRET, }, ], steps: [ { title: "Create a Slack App", body: "Go to api.slack.com/apps → Create New App → From scratch.", }, { title: "Add OAuth redirect URL", body: "In OAuth & Permissions → Redirect URLs, paste the callback URL below.", }, { title: "Add Bot Token Scopes", body: "Under Bot Token Scopes, add: channels:join, chat:write, channels:read, groups:read.", }, { title: "Install to workspace", body: "Click Install to Workspace to generate tokens.", }, { title: "Copy credentials", body: "From Basic Information: App Credentials has Client ID, Client Secret, Signing Secret.", }, { title: "Add to Space secrets", body: "Add all three env vars below to your HF Space settings, then restart.", }, ], }, ]; } function renderSetupPage() { const spaceHost = process.env.SPACE_HOST || null; const spaceId = process.env.SPACE_ID || null; const publicUrl = spaceHost ? `https://${spaceHost}` : "http://localhost:7860"; const settingsUrl = spaceId ? `https://huggingface.co/spaces/${spaceId}/settings` : "https://huggingface.co/settings/spaces"; const platforms = getOAuthPlatformDetails(publicUrl); const configuredCount = platforms.filter((p) => p.envVars.every((v) => v.set), ).length; // Build sidebar items const sidebarItems = platforms .map((p, i) => { const allSet = p.envVars.every((v) => v.set); const anySet = p.envVars.some((v) => v.set); const indicator = allSet ? "✅" : anySet ? "⚠️" : "⚪"; return ``; }) .join(""); // Build detail panels const panels = platforms .map((p, i) => { const allSet = p.envVars.every((v) => v.set); const stepsList = p.steps .map( (s, si) => `
${si + 1}
${s.title}
${s.body}
`, ) .join(""); const envRows = p.envVars .map( (v) => `
${v.name} ${v.desc}
${v.set ? 'Set ✓' : 'Not set'}
`, ) .join(""); const statusBanner = allSet ? `` : p.envVars.some((v) => v.set) ? `` : ``; return `
${p.emoji}

${p.name}

Open ${p.name} Developer Portal → ${p.docsUrl ? `Docs →` : ""}
${statusBanner}
${stepsList}
${p.callbackUrl}

Paste this URL wherever the developer portal asks for "Redirect URI", "Callback URL", or "OAuth Redirect URL".

${envRows}
Open Space Settings → Variables & Secrets

After adding secrets, click Restart Space for them to take effect.

`; }) .join(""); return ` Platform Setup — HuggingPost
← Dashboard

Platform Setup Guide

${configuredCount}/${platforms.length} configured
${panels}
`; } // ============================================================================ // URL helpers // ============================================================================ function parseRequestUrl(url) { try { return new URL(url, "http://localhost"); } catch { return new URL("http://localhost/"); } } function isLocalRoute(pathname) { return ( pathname === "/health" || pathname === "/status" || pathname === "/" || pathname === "" || pathname === "/setup" || pathname === "/setup/" ); } // ============================================================================ // UptimeRobot helpers // ============================================================================ function getKeepaliveStatus() { try { if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) { return JSON.parse( fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"), ); } } catch {} return null; } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function toneBadge(label, tone = "neutral") { return `${escapeHtml(label)}`; } function renderTile({ title, value, detail = "", tone = "neutral", meta = "", }) { return `
${escapeHtml(title)}
${value}
${detail ? `
${detail}
` : ""} ${meta ? `
${meta}
` : ""}
`; } // ============================================================================ // Status helpers // ============================================================================ function readSyncStatus() { try { if (fs.existsSync("/tmp/sync-status.json")) { return JSON.parse(fs.readFileSync("/tmp/sync-status.json", "utf8")); } } catch {} if (HF_BACKUP_ENABLED) { return { db_status: "unknown", last_sync_time: null, last_error: null, sync_count: 0, status: "configured", message: `Backup enabled. Waiting for first sync (every ${SYNC_INTERVAL}s).`, }; } return { db_status: "unknown", last_sync_time: null, last_error: null, sync_count: 0, }; } function checkPostizHealth() { return new Promise((resolve) => { const timeout = setTimeout( () => resolve({ status: "unreachable", reason: "timeout" }), 5000, ); http .get(`http://${POSTIZ_HOST}:${POSTIZ_PORT}/`, (res) => { clearTimeout(timeout); resolve({ status: res.statusCode < 500 ? "running" : "error", statusCode: res.statusCode, }); res.resume(); }) .on("error", (err) => { clearTimeout(timeout); resolve({ status: "unreachable", reason: err.message }); }); }); } function formatUptime(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); return `${h}h ${m}m`; } // ============================================================================ // Dashboard HTML // ============================================================================ function renderDashboard(data) { const syncStatus = String(data.sync?.status || "unknown"); const syncTone = ["success", "restored", "synced", "configured"].includes( syncStatus, ) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral"; const backupDetail = data.sync?.message ? escapeHtml(data.sync.message) : "No status yet"; const keepaliveConfigured = data.keepalive?.configured === true; const keepaliveStatus = String( data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"), ); const keepAliveTone = keepaliveConfigured ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral"; const keepAliveDetail = keepaliveConfigured ? `Pinging ${escapeHtml(data.keepalive.targetUrl || "/health")}` : keepaliveStatus === "error" && data.keepalive?.message ? escapeHtml(data.keepalive.message) : process.env.CLOUDFLARE_WORKERS_TOKEN ? "Worker pending or failed" : "Not configured"; const platforms = getSocialPlatforms(); const readyNow = platforms.filter((p) => p.noOAuth); const needsSetup = platforms.filter((p) => !p.noOAuth); const configuredCount = needsSetup.filter((p) => p.ready).length; const needsSetupRows = needsSetup .map((p) => { if (p.ready) { return `
${p.emoji} ${p.name} Configured
`; } return `
${p.emoji} ${p.name} Setup guide →
`; }) .join(""); const readyNowRows = readyNow .map( (p) => `
${p.emoji} ${p.name} ${p.note || ""}
`, ) .join(""); const tiles = [ renderTile({ title: "Postiz Core", value: toneBadge( data.postizRunning ? "Online" : "Booting", data.postizRunning ? "ok" : "warn", ), detail: `Backend Port ${POSTIZ_PORT}`, tone: data.postizRunning ? "ok" : "warn", }), renderTile({ title: "Uptime", value: escapeHtml(data.uptimeHuman), detail: `Exposed on port ${PORT}`, tone: "neutral", }), renderTile({ title: "Backup", value: toneBadge(syncStatus.toUpperCase(), syncTone), detail: backupDetail, tone: syncTone, meta: data.sync?.timestamp ? `` : "", }), renderTile({ title: "Keep Awake", value: toneBadge( keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), keepAliveTone, ), detail: keepAliveDetail, tone: keepAliveTone, }), ].join(""); return ` HuggingPost

📮 HuggingPost

Self-hosted Postiz Dashboard
${ data.postizRunning ? `Open Postiz ->` : `Postiz is starting up (first boot ~5 min)...` }
${tiles}

🚀 Getting Started

  1. Create your account
    Click Open Postiz above. The first signup becomes the admin account.
  2. Connect direct channels
    Bluesky, Mastodon, Telegram, Dev.to, and Hashnode connect with just your credentials.
  3. Enable OAuth platforms
    LinkedIn, X, YouTube... require API keys. Use the Setup Guide -> for instructions.

✅ Works immediately (${readyNow.length} platforms)

${readyNowRows}

🔑 Needs API keys (${configuredCount}/${needsSetup.length} configured)

${needsSetupRows}
📖 View Full Setup Guide
`; } // ============================================================================ // Reverse proxy // ============================================================================ function buildProxyHeaders(headers) { const f = headers["x-forwarded-for"]; const clientIp = typeof f === "string" ? f.split(",")[0].trim() : Array.isArray(f) && f.length ? String(f[0]).split(",")[0].trim() : ""; return { ...headers, host: `${POSTIZ_HOST}:${POSTIZ_PORT}`, "x-forwarded-for": clientIp, "x-forwarded-host": headers.host || "", "x-forwarded-proto": headers["x-forwarded-proto"] || "https", }; } function rewriteLocation(loc) { // Postiz's Next.js middleware redirects without the basePath prefix (/app) // and may use an internal hostname (127.0.0.1:NGINX_PORT) or the public // HF Space hostname (SPACE_HOST). The HF Spaces reverse proxy intercepts // absolute redirects to its own hostname — it resolves them server-side // and returns 200 at the original URL (blank white page for the client). // // Normalise every Location header from the Postiz nginx proxy: // 1. If it's an absolute URL to an internal or own-Space host → extract path. // 2. If the resulting path doesn't start with /app → prepend /app. // // This converts absolute redirects to relative ones so the browser // (not HF proxy) navigates and the URL bar updates correctly. // // Examples: // http://127.0.0.1:5000/auth/login → /app/auth/login // https://somratpro-huggingpost.hf.space/auth → /app/auth // /auth/login → /app/auth/login // /app/auth/login → /app/auth/login (unchanged) // https://twitter.com/oauth/... → unchanged (external) if (!loc) return loc; const spaceHost = process.env.SPACE_HOST || null; // e.g. somratpro-huggingpost.hf.space let path = null; if (loc.startsWith("/")) { path = loc; } else { try { const u = new URL(loc); if ( /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(u.host) || (spaceHost && u.hostname === spaceHost) ) { path = u.pathname + u.search + u.hash; } } catch {} } if (path !== null && !path.startsWith("/app/") && path !== "/app") { return "/app" + path; } return loc; } function proxyHttp(req, res, overridePath) { const targetPath = overridePath !== undefined ? overridePath : req.url; let upstreamStarted = false; const proxyReq = http.request( { hostname: POSTIZ_HOST, port: POSTIZ_PORT, method: req.method, path: targetPath, headers: buildProxyHeaders(req.headers), }, (proxyRes) => { upstreamStarted = true; // Rewrite Location headers: add /app basePath if missing, convert // internal-host absolute URLs to relative paths. const outHeaders = Object.assign({}, proxyRes.headers); const fixedLoc = rewriteLocation(outHeaders["location"]); if (fixedLoc !== outHeaders["location"]) outHeaders["location"] = fixedLoc; res.writeHead(proxyRes.statusCode || 502, outHeaders); proxyRes.pipe(res); }, ); proxyReq.on("error", (error) => { if (res.headersSent || upstreamStarted) { res.destroy(); return; } res.writeHead(502, { "Content-Type": "application/json" }); res.end( JSON.stringify({ status: "error", message: "Postiz unavailable", detail: error.message, hint: "Postiz may still be starting (first boot ~60s after build). Check the Logs tab.", }), ); }); res.on("close", () => proxyReq.destroy()); req.pipe(proxyReq); } function proxyUpgrade(req, socket, head, overridePath) { const targetPath = overridePath !== undefined ? overridePath : req.url; const proxySocket = net.connect(POSTIZ_PORT, POSTIZ_HOST); proxySocket.on("connect", () => { const f = req.headers["x-forwarded-for"]; const clientIp = typeof f === "string" ? f.split(",")[0].trim() : req.socket.remoteAddress || ""; const headerLines = []; for (let i = 0; i < req.rawHeaders.length; i += 2) { const name = req.rawHeaders[i]; const value = req.rawHeaders[i + 1]; const lower = String(name).toLowerCase(); if (lower === "host" || lower.startsWith("x-forwarded-")) continue; headerLines.push(`${name}: ${value}`); } const lines = [ `${req.method} ${targetPath} HTTP/${req.httpVersion}`, ...headerLines, `Host: ${POSTIZ_HOST}:${POSTIZ_PORT}`, `X-Forwarded-For: ${clientIp}`, `X-Forwarded-Host: ${req.headers.host || ""}`, `X-Forwarded-Proto: ${req.headers["x-forwarded-proto"] || "https"}`, "", "", ]; proxySocket.write(lines.join("\r\n")); if (head && head.length > 0) proxySocket.write(head); socket.pipe(proxySocket).pipe(socket); }); proxySocket.on("error", () => { if (socket.writable) socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); socket.destroy(); }); socket.on("error", () => proxySocket.destroy()); } // ============================================================================ // HTTP Server // ============================================================================ const server = http.createServer((req, res) => { const parsedUrl = parseRequestUrl(req.url || "/"); const pathname = parsedUrl.pathname; const uptime = Math.floor((Date.now() - startTime) / 1000); // ── /health ────────────────────────────────────────────────────────────── if (pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ status: "ok", uptime, uptimeHuman: formatUptime(uptime), timestamp: new Date().toISOString(), sync: readSyncStatus(), }), ); return; } // ── /status ────────────────────────────────────────────────────────────── if (pathname === "/status") { void (async () => { const postiz = await checkPostizHealth(); res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ uptime: formatUptime(uptime), postizRunning: postiz.status === "running", sync: readSyncStatus(), keepalive: getKeepaliveStatus(), }), ); })(); return; } // ── /setup — OAuth platform setup wizard ───────────────────────────────── if (pathname === "/setup" || pathname === "/setup/") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(renderSetupPage()); return; } // ── /app/debug-logs — Temporary endpoint for debugging ─────────────────── if (pathname === "/app/debug-logs") { try { const errLog = fs.existsSync("/root/.pm2/logs/backend-error.log") ? fs.readFileSync("/root/.pm2/logs/backend-error.log", "utf8") : "No backend-error.log found"; const outLog = fs.existsSync("/root/.pm2/logs/backend-out.log") ? fs.readFileSync("/root/.pm2/logs/backend-out.log", "utf8") : "No backend-out.log found"; const cfProxyLog = fs.existsSync("/tmp/huggingpost-cloudflare-proxy.env") ? fs.readFileSync("/tmp/huggingpost-cloudflare-proxy.env", "utf8") : "No proxy env"; const out = `=== BACKEND ERROR LOG ===\n${errLog}\n\n=== BACKEND OUT LOG ===\n${outLog}\n\n=== PROXY ENV ===\n${cfProxyLog}`; res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); res.end(out); } catch (e) { res.writeHead(500, { "Content-Type": "text/plain" }); res.end("Error reading logs: " + e.message); } return; } // ── Dashboard at exact / ───────────────────────────────────────────────── if (pathname === "/" || pathname === "") { void (async () => { const postiz = await checkPostizHealth(); const initialData = { postizRunning: postiz.status === "running", keepalive: getKeepaliveStatus(), sync: readSyncStatus(), }; res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(renderDashboard(initialData)); })(); return; } // ── /app, /app/ and /app/* → proxy to nginx (Next.js handles routing) ──── if (pathname === "/app" || pathname === "/app/") { // Postiz Next.js root redirect to /launches sometimes fails with basePath // + trailingSlash:true, leaving users on a blank /app/ page after signup. // Force the redirect here. Next.js middleware will still redirect to // /auth/login if they aren't authenticated yet. res.writeHead(302, { Location: "/app/launches/" + (parsedUrl.search || ""), }); res.end(); return; } if (pathname.startsWith("/app/")) { const stripped = pathname.slice("/app".length) || "/"; const query = parsedUrl.search || ""; // Static files in Next.js public/ land here with /app/ prefix (basePath). // Serve them directly from disk instead of proxying through nginx so the // path mismatch introduced by the nginx /app/ re-add patch doesn't matter. // _next/ bundles are NOT in public/ — skip them and proxy normally. const ext = path.extname(stripped).toLowerCase(); if (ext && !stripped.startsWith("/_next/") && MIME_TYPES[ext]) { const absPath = path.resolve(NEXTJS_PUBLIC_DIR, "." + stripped); if (absPath.startsWith(NEXTJS_PUBLIC_DIR + path.sep)) { const stream = fs.createReadStream(absPath); stream.once("open", () => { res.writeHead(200, { "Content-Type": MIME_TYPES[ext], "Cache-Control": "public, max-age=86400", }); stream.pipe(res); }); stream.once("error", () => { // File not in public/ — fall through to nginx proxy. if (!res.headersSent) proxyHttp(req, res, stripped + query); else res.destroy(); }); return; } } proxyHttp(req, res, stripped + query); return; } // ── Stray asset URLs without basePath (Sentry, hardcoded /static) ──────── // Browser-side libs sometimes emit absolute URLs that bypass Next.js // basePath. Catch /_next/* and /static/* at root and 301 to /app/* so the // browser learns the right prefix. if (pathname.startsWith("/_next/") || pathname.startsWith("/static/")) { res.writeHead(301, { Location: "/app" + pathname + (parsedUrl.search || ""), }); res.end(); return; } // ── Anything else → redirect to /app ────────────────────────────── // After login, Postiz's client-side router may navigate to a path without // the /app basePath prefix (e.g. /launches, /analytics, /api/...). // Redirect those here rather than 404-ing so the browser lands correctly. res.writeHead(302, { Location: "/app" + pathname + (parsedUrl.search || ""), }); res.end(); }); server.on("upgrade", (req, socket, head) => { const parsedUrl = parseRequestUrl(req.url || "/"); const pathname = parsedUrl.pathname; if (isLocalRoute(pathname)) { socket.destroy(); return; } if (pathname === "/app" || pathname.startsWith("/app/")) { const stripped = pathname.slice("/app".length) || "/"; proxyUpgrade(req, socket, head, stripped + (parsedUrl.search || "")); return; } socket.destroy(); }); server.listen(PORT, "0.0.0.0", () => { console.log(`✓ Health server listening on port ${PORT}`); console.log(`✓ Dashboard : http://localhost:${PORT}/`); console.log( `✓ Postiz : http://localhost:${PORT}/app/ → nginx :${POSTIZ_PORT}`, ); });