Spaces:
Running
Running
| // 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/<same path> | |
| // (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: "google", | |
| name: "Google Login", | |
| emoji: "🔵", | |
| ready: !!(e.GOOGLE_CLIENT_ID || e.YOUTUBE_CLIENT_ID), | |
| setupUrl: "https://console.cloud.google.com/apis/credentials", | |
| envVars: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], | |
| }, | |
| { | |
| 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.GOOGLE_CLIENT_ID || e.YOUTUBE_CLIENT_ID), | |
| setupUrl: "https://console.cloud.google.com/apis/credentials", | |
| envVars: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], | |
| note: "Uses same app as Google Login", | |
| }, | |
| { | |
| 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 "direct connect" guide data (no OAuth / no env vars). | |
| // These platforms connect entirely inside the Postiz UI — no developer portal needed. | |
| function getDirectPlatformDetails(postizUrl) { | |
| const postizIntegrations = `${postizUrl}/integrations`; | |
| return [ | |
| { | |
| id: "bluesky", | |
| name: "Bluesky", | |
| emoji: "🦋", | |
| docsUrl: "https://bsky.social", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "Service", value: "https://bsky.social", hint: "Default for bsky.social users. Custom instance: use your instance URL." }, | |
| { label: "Identifier", value: "yourname.bsky.social", hint: "Your full Bluesky handle." }, | |
| { label: "Password", value: "App Password", hint: "NOT your login password — generate a dedicated App Password in Bluesky settings." }, | |
| ], | |
| steps: [ | |
| { title: "Create a Bluesky account", body: "Sign up at <a href=\"https://bsky.app\" target=\"_blank\" rel=\"noopener\">bsky.app</a> if you don't have one. Custom PDS users: use your own instance URL." }, | |
| { title: "Generate an App Password", body: "In Bluesky → <strong>Settings → Privacy and Security → App Passwords → Add App Password</strong>. Name it <em>HuggingPost</em>. Copy the generated password — it's shown only once." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below to go to Postiz → click <strong>Connect</strong> next to Bluesky." }, | |
| { title: "Fill in credentials", body: "<strong>Service:</strong> <code>https://bsky.social</code> (or your instance URL)<br><strong>Identifier:</strong> your full handle (e.g. <code>yourname.bsky.social</code>)<br><strong>Password:</strong> the App Password from Step 2 — <em>not</em> your login password." }, | |
| { title: "Click Connect", body: "Postiz authenticates and adds your Bluesky account. You can post immediately." }, | |
| ], | |
| }, | |
| { | |
| id: "mastodon", | |
| name: "Mastodon", | |
| emoji: "🐘", | |
| docsUrl: "https://joinmastodon.org", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "Service", value: "https://mastodon.social", hint: "Your Mastodon instance URL (e.g. https://fosstodon.org)." }, | |
| { label: "Identifier", value: "yourhandle", hint: "Your username without the @instance part." }, | |
| { label: "Password", value: "Your password", hint: "Your Mastodon account password." }, | |
| ], | |
| steps: [ | |
| { title: "Find your Mastodon instance URL", body: "Check your profile URL — it's the domain part (e.g. <code>mastodon.social</code>, <code>fosstodon.org</code>)." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below → click <strong>Connect</strong> next to Mastodon." }, | |
| { title: "Fill in credentials", body: "<strong>Service:</strong> your full instance URL with https (e.g. <code>https://mastodon.social</code>)<br><strong>Identifier:</strong> your username (the part before @instance)<br><strong>Password:</strong> your account password." }, | |
| { title: "Click Connect", body: "Postiz will authenticate via Mastodon's API and add your account." }, | |
| ], | |
| }, | |
| { | |
| id: "telegram", | |
| name: "Telegram", | |
| emoji: "✈️", | |
| docsUrl: "https://core.telegram.org/bots#how-do-i-create-a-bot", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "Bot Token", value: "123456:ABC-DEF...", hint: "From @BotFather. Format: number:string." }, | |
| ], | |
| steps: [ | |
| { title: "Create a Telegram Bot", body: "Open Telegram → message <strong>@BotFather</strong> → send <code>/newbot</code> → follow prompts → copy the <strong>Bot Token</strong> (format: <code>123456789:ABC-...</code>)." }, | |
| { title: "(Optional) Add bot to a channel", body: "To post to a Telegram channel: add your bot as an <strong>Admin</strong> of the channel with post permission." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below → click <strong>Connect</strong> next to Telegram." }, | |
| { title: "Enter Bot Token", body: "Paste your Bot Token from @BotFather. Click Connect." }, | |
| ], | |
| }, | |
| { | |
| id: "devto", | |
| name: "Dev.to", | |
| emoji: "💻", | |
| docsUrl: "https://dev.to/settings/extensions", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "API Key", value: "Your Dev.to API key", hint: "From dev.to → Settings → Extensions → DEV API Keys." }, | |
| ], | |
| steps: [ | |
| { title: "Log in to Dev.to", body: "Go to <a href=\"https://dev.to\" target=\"_blank\" rel=\"noopener\">dev.to</a> and sign in." }, | |
| { title: "Generate an API key", body: "Go to <strong>Settings → Extensions → DEV API Keys</strong>. Enter a description (e.g. <em>HuggingPost</em>) and click <strong>Generate API Key</strong>. Copy the key." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below → click <strong>Connect</strong> next to Dev.to." }, | |
| { title: "Enter API key", body: "Paste your API key. Click Connect." }, | |
| ], | |
| }, | |
| { | |
| id: "hashnode", | |
| name: "Hashnode", | |
| emoji: "📰", | |
| docsUrl: "https://hashnode.com/settings/developer", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "Personal Access Token", value: "Your Hashnode token", hint: "From hashnode.com → Account Settings → Developer → Personal Access Token." }, | |
| ], | |
| steps: [ | |
| { title: "Log in to Hashnode", body: "Go to <a href=\"https://hashnode.com\" target=\"_blank\" rel=\"noopener\">hashnode.com</a> and sign in." }, | |
| { title: "Generate a Personal Access Token", body: "Go to <strong>Account Settings → Developer → Personal Access Token → Generate new token</strong>. Name it <em>HuggingPost</em>. Copy the token." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below → click <strong>Connect</strong> next to Hashnode." }, | |
| { title: "Enter token", body: "Paste your Personal Access Token. Click Connect." }, | |
| ], | |
| }, | |
| { | |
| id: "nostr", | |
| name: "Nostr", | |
| emoji: "🔑", | |
| docsUrl: "https://nostr.how/en/get-started", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "Private Key", value: "nsec1...", hint: "Your Nostr private key in nsec (bech32) format." }, | |
| ], | |
| steps: [ | |
| { title: "Get a Nostr keypair", body: "Use a Nostr client like <a href=\"https://snort.social\" target=\"_blank\" rel=\"noopener\">Snort</a> or <a href=\"https://primal.net\" target=\"_blank\" rel=\"noopener\">Primal</a> to create or export your keys. You need the <strong>private key</strong> in <code>nsec1...</code> format." }, | |
| { title: "⚠️ Security note", body: "Your private key controls your entire Nostr identity. Only enter it in trusted apps. HuggingPost is self-hosted — your key stays in your container." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below → click <strong>Connect</strong> next to Nostr." }, | |
| { title: "Enter private key", body: "Paste your <code>nsec1...</code> private key. Click Connect." }, | |
| ], | |
| }, | |
| { | |
| id: "lemmy", | |
| name: "Lemmy", | |
| emoji: "🐾", | |
| docsUrl: "https://join-lemmy.org", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "Instance", value: "https://lemmy.world", hint: "Your Lemmy instance URL." }, | |
| { label: "Username", value: "yourhandle", hint: "Your Lemmy username." }, | |
| { label: "Password", value: "Your password", hint: "Your Lemmy account password." }, | |
| ], | |
| steps: [ | |
| { title: "Find your Lemmy instance", body: "You need the full URL of your Lemmy instance (e.g. <code>https://lemmy.world</code>, <code>https://lemmy.ml</code>). Find it at <a href=\"https://join-lemmy.org\" target=\"_blank\" rel=\"noopener\">join-lemmy.org</a>." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below → click <strong>Connect</strong> next to Lemmy." }, | |
| { title: "Fill in credentials", body: "<strong>Instance:</strong> your instance URL<br><strong>Username:</strong> your account username<br><strong>Password:</strong> your account password." }, | |
| { title: "Click Connect", body: "Postiz authenticates via Lemmy's API." }, | |
| ], | |
| }, | |
| { | |
| id: "warpcast", | |
| name: "Warpcast", | |
| emoji: "🟣", | |
| docsUrl: "https://docs.farcaster.xyz", | |
| postizUrl: postizIntegrations, | |
| fields: [ | |
| { label: "FID", value: "12345", hint: "Your Farcaster user ID (numeric)." }, | |
| { label: "Private Key", value: "0x...", hint: "Your Farcaster custody private key (hex, 0x-prefixed)." }, | |
| ], | |
| steps: [ | |
| { title: "Find your FID", body: "Your Farcaster ID (FID) is a numeric value. In Warpcast → Profile → the number in your profile URL, or use <a href=\"https://www.farcaster.xyz\" target=\"_blank\" rel=\"noopener\">farcaster.xyz</a> to look it up." }, | |
| { title: "Export your private key", body: "In Warpcast → <strong>Settings → Advanced → Recovery phrase</strong>, or use a Farcaster tool to derive your custody private key. The key is in hex format (<code>0x...</code>)." }, | |
| { title: "Open Postiz Integrations", body: "Click the button below → click <strong>Connect</strong> next to Warpcast." }, | |
| { title: "Enter FID and private key", body: "Paste your FID and private key. Click Connect." }, | |
| ], | |
| }, | |
| ]; | |
| } | |
| // 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 <strong>App type = Web</strong>.", | |
| }, | |
| { | |
| title: "Add OAuth redirect URL", | |
| body: "In the <strong>Auth</strong> tab → OAuth 2.0 settings, paste the callback URL below.", | |
| }, | |
| { | |
| title: "Enable products", | |
| body: "Add <strong>Sign In with LinkedIn using OpenID Connect</strong> and <strong>Share on LinkedIn</strong> products.", | |
| }, | |
| { | |
| title: "Copy credentials", | |
| body: "From the Auth tab, copy <strong>Client ID</strong> and <strong>Client Secret</strong>.", | |
| }, | |
| { | |
| 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: 'Go to <a href="https://developer.twitter.com" target="_blank" rel="noopener">developer.twitter.com</a>. Apply for a developer account if needed. Create a new project + app.', | |
| }, | |
| { | |
| title: "Configure User Authentication Settings", | |
| body: "On your app page → <strong>User authentication settings → Set up</strong>. Set ALL THREE of these, then click Save:<br><br><strong>1. App permissions:</strong> <strong>Read and write</strong> (default is Read-only — must change this)<br><strong>2. Type of App:</strong> <strong>Native App</strong> (Public client) — ⚠️ NOT Web App, Web App causes error code 32<br><strong>3. Callback URI:</strong> paste the Callback URL shown below. Website URL = your HF Space URL.<br><br>Save when done.", | |
| }, | |
| { | |
| title: "Regenerate Consumer Keys (required after saving settings)", | |
| body: "Go to <strong>Keys & Tokens</strong> tab → <strong>OAuth 1.0 Keys</strong> section → click <strong>Regenerate</strong> next to Consumer Key.<br><br><strong>⚠️ You must regenerate after changing User Auth Settings</strong> — existing keys don't pick up new permissions.<br><br>The Regenerate popup shows BOTH values together — copy them immediately:<br>• <strong>API Key</strong> = your <strong>X_API_KEY</strong><br>• <strong>API Key Secret</strong> = your <strong>X_API_SECRET</strong><br><br>⚠️ Do NOT use Bearer Token, Access Token/Secret, or OAuth 2.0 Client ID/Secret.", | |
| }, | |
| { | |
| title: "Add to Space secrets and restart", | |
| body: "Add <strong>X_API_KEY</strong> and <strong>X_API_SECRET</strong> to HF Space → Settings → Variables & Secrets. Then <strong>Restart the Space</strong> to load the new values.", | |
| }, | |
| ], | |
| }, | |
| { | |
| 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 <strong>Authenticate and request data from users</strong>.", | |
| }, | |
| { | |
| title: "Add Facebook Login product", | |
| body: "In the app dashboard, click <strong>Add Product</strong> → 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 <strong>pages_manage_posts</strong>, <strong>pages_read_engagement</strong>, <strong>publish_to_groups</strong> permissions.", | |
| }, | |
| { | |
| title: "Copy credentials", | |
| body: "From <strong>App Settings → Basic</strong>, 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 <strong>Add Product</strong> → Instagram Graph API.", | |
| }, | |
| { | |
| title: "Connect an Instagram Business account", | |
| body: "Your Instagram account must be a <strong>Professional (Business or Creator)</strong> account linked to a Facebook Page.", | |
| }, | |
| { | |
| 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 <code>FACEBOOK_APP_ID</code> and <code>FACEBOOK_APP_SECRET</code>.", | |
| }, | |
| ], | |
| }, | |
| { | |
| 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 <strong>Add Product</strong> → Threads API.", | |
| }, | |
| { | |
| title: "Add callback URL", | |
| body: "In Threads API settings → Redirect URI, paste the callback URL below.", | |
| }, | |
| { | |
| title: "Copy credentials", | |
| body: "From <strong>App Settings → Basic</strong>, 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: "google", | |
| name: "Google Login", | |
| emoji: "🔵", | |
| setupUrl: "https://console.cloud.google.com/apis/credentials", | |
| docsUrl: "https://developers.google.com/identity/protocols/oauth2", | |
| callbackUrl: cb("google"), | |
| envVars: [ | |
| { | |
| name: "GOOGLE_CLIENT_ID", | |
| desc: "OAuth 2.0 Client ID", | |
| set: !!(e.GOOGLE_CLIENT_ID || e.YOUTUBE_CLIENT_ID), | |
| }, | |
| { | |
| name: "GOOGLE_CLIENT_SECRET", | |
| desc: "OAuth 2.0 Client Secret", | |
| set: !!(e.GOOGLE_CLIENT_SECRET || e.YOUTUBE_CLIENT_SECRET), | |
| }, | |
| ], | |
| steps: [ | |
| { | |
| title: "Create a Google Cloud project", | |
| body: 'Go to <a href="https://console.cloud.google.com" target="_blank" rel="noopener">console.cloud.google.com</a>. Create a new project (or use an existing one).', | |
| }, | |
| { | |
| title: "Configure OAuth consent screen", | |
| body: "In <strong>APIs & Services → OAuth consent screen</strong>, set User Type to <strong>External</strong>. Fill app name, support email, developer email. Add scopes: <code>email</code>, <code>profile</code>, <code>openid</code>. Add your own Google account as a test user.", | |
| }, | |
| { | |
| title: "Create OAuth 2.0 credentials", | |
| body: "In <strong>APIs & Services → Credentials</strong>, click <strong>Create Credentials → OAuth client ID</strong>. Set type to <strong>Web application</strong>. Name it <em>HuggingPost</em>.", | |
| }, | |
| { | |
| title: "Add callback URLs", | |
| body: "Under <strong>Authorized redirect URIs</strong>, add the callback URL below. Also add the YouTube callback URL if you plan to connect YouTube — it is shown on the YouTube tab.", | |
| }, | |
| { | |
| title: "Copy credentials", | |
| body: "Copy the <strong>Client ID</strong> and <strong>Client Secret</strong> shown in the dialog.", | |
| }, | |
| { | |
| title: "Add to Space secrets", | |
| body: "Add <code>GOOGLE_CLIENT_ID</code> and <code>GOOGLE_CLIENT_SECRET</code> to HF Space → Settings → Variables & Secrets. Restart the Space. <br><br><strong>💡 These same credentials also power YouTube channel connections</strong> — no separate YouTube OAuth app needed.", | |
| }, | |
| ], | |
| }, | |
| { | |
| 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: "GOOGLE_CLIENT_ID", | |
| desc: "OAuth 2.0 Client ID (same as Google Login)", | |
| set: !!(e.GOOGLE_CLIENT_ID || e.YOUTUBE_CLIENT_ID), | |
| }, | |
| { | |
| name: "GOOGLE_CLIENT_SECRET", | |
| desc: "OAuth 2.0 Client Secret (same as Google Login)", | |
| set: !!(e.GOOGLE_CLIENT_SECRET || e.YOUTUBE_CLIENT_SECRET), | |
| }, | |
| ], | |
| steps: [ | |
| { | |
| title: "Already set up Google Login?", | |
| body: "✅ <strong>Skip to Step 2.</strong> YouTube uses the same <code>GOOGLE_CLIENT_ID</code> and <code>GOOGLE_CLIENT_SECRET</code> — no new OAuth app needed. Just enable the YouTube API and add the callback URL.", | |
| }, | |
| { | |
| title: "Enable YouTube Data API v3", | |
| body: "In <a href=\"https://console.cloud.google.com/apis/library\" target=\"_blank\" rel=\"noopener\">APIs & Services → Library</a>, search for <strong>YouTube Data API v3</strong> and click <strong>Enable</strong>.", | |
| }, | |
| { | |
| title: "Add YouTube callback URL to your OAuth client", | |
| body: "In <strong>Credentials → your OAuth client → Edit</strong>, add the callback URL below to <strong>Authorized redirect URIs</strong>. Save.", | |
| }, | |
| { | |
| title: "Add YouTube scopes to consent screen", | |
| body: "In <strong>OAuth consent screen → Edit App → Scopes</strong>, add <code>https://www.googleapis.com/auth/youtube.upload</code> and <code>https://www.googleapis.com/auth/youtube</code>.", | |
| }, | |
| { | |
| title: "First time setup only: add GOOGLE_* secrets", | |
| body: "If you haven't done Google Login yet — add <code>GOOGLE_CLIENT_ID</code> and <code>GOOGLE_CLIENT_SECRET</code> to HF Space secrets and restart. If Google Login is already working, no new secrets needed.", | |
| }, | |
| ], | |
| }, | |
| { | |
| 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 <strong>Platform: Web</strong>.", | |
| }, | |
| { | |
| title: "Add Login Kit", | |
| body: "Add <strong>Login Kit</strong> 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 <strong>Content Posting API</strong> product for posting videos/photos.", | |
| }, | |
| { | |
| title: "Copy credentials", | |
| body: "From app overview, copy <strong>Client Key</strong> (as CLIENT_ID) and <strong>Client Secret</strong>.", | |
| }, | |
| { | |
| 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 <strong>create another app…</strong>. Set type to <strong>web app</strong>.", | |
| }, | |
| { | |
| title: "Add callback URL", | |
| body: "In the <strong>redirect uri</strong> 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 <strong>boards:read</strong>, <strong>pins:read</strong>, <strong>pins:write</strong> 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 <strong>OAuth2 → Redirects</strong>, paste the callback URL below.", | |
| }, | |
| { | |
| title: "Create a Bot", | |
| body: "In the <strong>Bot</strong> section, create a bot. Enable <strong>Message Content Intent</strong>.", | |
| }, | |
| { | |
| 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 <strong>OAuth & Permissions → Redirect URLs</strong>, paste the callback URL below.", | |
| }, | |
| { | |
| title: "Add Bot Token Scopes", | |
| body: "Under Bot Token Scopes, add: <code>channels:join</code>, <code>chat:write</code>, <code>channels:read</code>, <code>groups:read</code>.", | |
| }, | |
| { | |
| title: "Install to workspace", | |
| body: "Click <strong>Install to Workspace</strong> to generate tokens.", | |
| }, | |
| { | |
| title: "Copy credentials", | |
| body: "From <strong>Basic Information</strong>: 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 postizUrl = publicUrl + "/app"; | |
| const settingsUrl = spaceId | |
| ? `https://huggingface.co/spaces/${spaceId}/settings` | |
| : "https://huggingface.co/settings/spaces"; | |
| const platforms = getOAuthPlatformDetails(publicUrl); | |
| const directPlatforms = getDirectPlatformDetails(postizUrl); | |
| const configuredCount = platforms.filter((p) => | |
| p.envVars.every((v) => v.set), | |
| ).length; | |
| const totalPanels = platforms.length + directPlatforms.length; | |
| // ── Build sidebar ──────────────────────────────────────────────────────────── | |
| const oauthSidebarItems = platforms.map((p, i) => { | |
| const allSet = p.envVars.every((v) => v.set); | |
| const anySet = p.envVars.some((v) => v.set); | |
| const dot = allSet | |
| ? `<span class="dot dot-ok"></span>` | |
| : anySet | |
| ? `<span class="dot dot-warn"></span>` | |
| : `<span class="dot dot-off"></span>`; | |
| return `<button class="plat-tab${i === 0 ? " active" : ""}" onclick="show(${i})" id="tab-${i}"> | |
| <span class="tab-emoji">${p.emoji}</span> | |
| <span class="tab-name">${p.name}</span> | |
| ${dot} | |
| </button>`; | |
| }).join(""); | |
| const directSidebarItems = directPlatforms.map((p, i) => { | |
| const idx = platforms.length + i; | |
| return `<button class="plat-tab" onclick="show(${idx})" id="tab-${idx}"> | |
| <span class="tab-emoji">${p.emoji}</span> | |
| <span class="tab-name">${p.name}</span> | |
| <span class="dot dot-ok"></span> | |
| </button>`; | |
| }).join(""); | |
| // ── Build OAuth panels ─────────────────────────────────────────────────────── | |
| const oauthPanels = platforms.map((p, i) => { | |
| const allSet = p.envVars.every((v) => v.set); | |
| const anySet = p.envVars.some((v) => v.set); | |
| const stepsList = p.steps.map((s, si) => | |
| `<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>` | |
| ).join(""); | |
| const envRows = p.envVars.map((v) => | |
| `<div class="env-row"> | |
| <div class="env-info"> | |
| <code class="env-name">${v.name}</code> | |
| <span class="env-desc">${v.desc}</span> | |
| </div> | |
| <div class="env-actions"> | |
| ${v.set | |
| ? `<span class="badge ok">SET</span>` | |
| : `<span class="badge off">NOT SET</span>`} | |
| <button class="btn-copy" onclick="copy('${v.name}',this)">Copy name</button> | |
| </div> | |
| </div>` | |
| ).join(""); | |
| const banner = allSet | |
| ? `<div class="banner banner-ok">All credentials configured — restart Space if you just added them.</div>` | |
| : anySet | |
| ? `<div class="banner banner-warn">Partially configured — add the missing env vars below.</div>` | |
| : `<div class="banner banner-info">Not yet configured — follow the steps below.</div>`; | |
| return `<div class="panel${i === 0 ? " active" : ""}" id="panel-${i}"> | |
| <div class="panel-head"> | |
| <span class="panel-emoji">${p.emoji}</span> | |
| <div> | |
| <div class="panel-title">${p.name}</div> | |
| <div class="panel-links"> | |
| <a href="${p.setupUrl}" target="_blank" rel="noopener">Open Developer Portal →</a> | |
| ${p.docsUrl ? `<a href="${p.docsUrl}" target="_blank" rel="noopener">Official Docs →</a>` : ""} | |
| </div> | |
| </div> | |
| </div> | |
| ${banner} | |
| <div class="section-label">Setup Steps</div> | |
| <div class="steps-list">${stepsList}</div> | |
| <div class="section-label">Callback URL | |
| <span class="section-hint">Paste this in the developer portal when asked for "Redirect URI" or "Callback URL"</span> | |
| </div> | |
| <div class="copy-block"> | |
| <span class="copy-block-text">${p.callbackUrl}</span> | |
| <button class="btn-copy btn-copy-white" onclick="copy('${p.callbackUrl}',this)">Copy</button> | |
| </div> | |
| <div class="section-label">Space Secrets to Add | |
| <span class="section-hint">HF Space → Settings → Variables & Secrets</span> | |
| </div> | |
| <div class="env-list">${envRows}</div> | |
| <a href="${settingsUrl}" target="_blank" rel="noopener" class="cta-btn"> | |
| Open Space Settings → Variables & Secrets | |
| </a> | |
| <p class="hint-final">After adding all secrets, click <strong>Restart Space</strong> for them to take effect.</p> | |
| </div>`; | |
| }).join(""); | |
| // ── Build direct-connect panels ────────────────────────────────────────────── | |
| const directPanels = directPlatforms.map((p, i) => { | |
| const idx = platforms.length + i; | |
| const stepsList = p.steps.map((s, si) => | |
| `<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>` | |
| ).join(""); | |
| const fieldRows = (p.fields || []).map((f) => | |
| `<div class="env-row"> | |
| <div class="env-info"> | |
| <code class="env-name">${f.label}</code> | |
| <span class="env-desc">${f.hint}</span> | |
| </div> | |
| <div class="env-actions"> | |
| <span class="badge ok">READY</span> | |
| </div> | |
| </div>` | |
| ).join(""); | |
| return `<div class="panel" id="panel-${idx}"> | |
| <div class="panel-head"> | |
| <span class="panel-emoji">${p.emoji}</span> | |
| <div> | |
| <div class="panel-title">${p.name}</div> | |
| <div class="panel-links"> | |
| ${p.docsUrl ? `<a href="${p.docsUrl}" target="_blank" rel="noopener">Official Docs →</a>` : ""} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="banner banner-ok">No developer portal needed — connects directly inside Postiz.</div> | |
| <div class="section-label">Setup Steps</div> | |
| <div class="steps-list">${stepsList}</div> | |
| ${fieldRows ? `<div class="section-label">Fields Required in Postiz</div> | |
| <div class="env-list">${fieldRows}</div>` : ""} | |
| <a href="${p.postizUrl}" target="_blank" rel="noopener" class="cta-btn"> | |
| Open Postiz Integrations → | |
| </a> | |
| <p class="hint-final">Click <strong>Connect</strong> next to ${p.name} in Postiz, fill in the credentials above.</p> | |
| </div>`; | |
| }).join(""); | |
| const allPlatformIds = [ | |
| ...platforms.map((p) => p.id), | |
| ...directPlatforms.map((p) => p.id), | |
| ]; | |
| return `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>Platform Setup — HuggingPost</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| color-scheme: dark; | |
| --bg: #08080f; | |
| --panel: #12111b; | |
| --panel2: #151421; | |
| --line: #26243a; | |
| --text: #f6f4ff; | |
| --muted: #7f7a9e; | |
| --soft: #b8b3d7; | |
| --good: #22c55e; | |
| --warn: #f5c542; | |
| --bad: #fb7185; | |
| --accent: #3b82f6; | |
| } | |
| body { | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-size: 13px; | |
| height: 100dvh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* ── Top bar ─────────────────────────────────────────── */ | |
| .topbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| padding: 0 20px; | |
| height: 48px; | |
| border-bottom: 1px solid var(--line); | |
| background: var(--panel); | |
| flex-shrink: 0; | |
| } | |
| .topbar-back { | |
| color: var(--muted); | |
| text-decoration: none; | |
| font-size: .78rem; | |
| font-weight: 700; | |
| letter-spacing: .04em; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: color .15s; | |
| } | |
| .topbar-back:hover { color: var(--text); } | |
| .topbar-title { | |
| font-size: .78rem; | |
| font-weight: 850; | |
| letter-spacing: .12em; | |
| text-transform: uppercase; | |
| color: var(--soft); | |
| } | |
| .topbar-count { | |
| margin-left: auto; | |
| font-size: .72rem; | |
| font-weight: 850; | |
| color: var(--muted); | |
| letter-spacing: .08em; | |
| text-transform: uppercase; | |
| border: 1px solid var(--line); | |
| border-radius: 999px; | |
| padding: 3px 10px; | |
| } | |
| /* ── Layout ──────────────────────────────────────────── */ | |
| .layout { display: flex; flex: 1; overflow: hidden; } | |
| /* ── Sidebar ─────────────────────────────────────────── */ | |
| .sidebar { | |
| width: 210px; | |
| flex-shrink: 0; | |
| background: var(--panel); | |
| border-right: 1px solid var(--line); | |
| overflow-y: auto; | |
| padding: 10px 8px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| } | |
| .sidebar-label { | |
| font-size: .62rem; | |
| font-weight: 850; | |
| letter-spacing: .14em; | |
| text-transform: uppercase; | |
| color: var(--muted); | |
| padding: 6px 10px 10px; | |
| } | |
| .sidebar-divider { | |
| height: 1px; | |
| background: var(--line); | |
| margin: 6px 4px 8px; | |
| } | |
| .plat-tab { | |
| width: 100%; | |
| background: none; | |
| border: 1px solid transparent; | |
| color: var(--soft); | |
| font: inherit; | |
| font-size: .82rem; | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| gap: 9px; | |
| padding: 8px 10px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| text-align: left; | |
| transition: background .12s, color .12s; | |
| } | |
| .plat-tab:hover { background: var(--panel2); color: var(--text); } | |
| .plat-tab.active { | |
| background: var(--panel2); | |
| color: var(--text); | |
| border-color: var(--line); | |
| } | |
| .tab-emoji { font-size: .95rem; width: 20px; text-align: center; flex-shrink: 0; } | |
| .tab-name { flex: 1; } | |
| .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } | |
| .dot-ok { background: var(--good); } | |
| .dot-warn { background: var(--warn); } | |
| .dot-off { background: var(--line); } | |
| /* ── Main panel ──────────────────────────────────────── */ | |
| .main { flex: 1; overflow-y: auto; padding: 28px 32px; max-width: 780px; } | |
| .panel { display: none; animation: fadein .18s ease; } | |
| .panel.active { display: block; } | |
| @keyframes fadein { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } } | |
| .panel-head { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 14px; | |
| margin-bottom: 18px; | |
| } | |
| .panel-emoji { font-size: 2.2rem; flex-shrink: 0; line-height: 1; } | |
| .panel-title { font-size: 1.4rem; font-weight: 850; margin-bottom: 6px; } | |
| .panel-links { display: flex; gap: 14px; flex-wrap: wrap; } | |
| .panel-links a { | |
| color: var(--accent); | |
| font-size: .76rem; | |
| font-weight: 700; | |
| text-decoration: none; | |
| letter-spacing: .02em; | |
| } | |
| .panel-links a:hover { text-decoration: underline; } | |
| /* Banner */ | |
| .banner { | |
| padding: 10px 14px; | |
| border-radius: 8px; | |
| font-size: .8rem; | |
| font-weight: 600; | |
| margin-bottom: 20px; | |
| border: 1px solid transparent; | |
| } | |
| .banner-ok { background: rgba(34,197,94,.08); border-color: rgba(34,197,94,.22); color: #86efac; } | |
| .banner-warn { background: rgba(245,197,66,.08); border-color: rgba(245,197,66,.22); color: #fde68a; } | |
| .banner-info { background: rgba(59,130,246,.08); border-color: rgba(59,130,246,.22); color: #93c5fd; } | |
| /* Section labels */ | |
| .section-label { | |
| font-size: .62rem; | |
| font-weight: 850; | |
| letter-spacing: .14em; | |
| text-transform: uppercase; | |
| color: var(--muted); | |
| margin: 22px 0 10px; | |
| display: flex; | |
| align-items: baseline; | |
| gap: 10px; | |
| } | |
| .section-hint { | |
| font-size: .7rem; | |
| font-weight: 500; | |
| letter-spacing: 0; | |
| text-transform: none; | |
| color: var(--muted); | |
| opacity: .7; | |
| } | |
| /* Steps */ | |
| .steps-list { display: flex; flex-direction: column; gap: 4px; } | |
| .step { | |
| display: flex; | |
| gap: 12px; | |
| padding: 11px 14px; | |
| border-radius: 8px; | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| } | |
| .step-num { | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| color: #fff; | |
| font-size: .68rem; | |
| font-weight: 850; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| margin-top: 1px; | |
| } | |
| .step-title { font-size: .82rem; font-weight: 800; margin-bottom: 3px; color: var(--text); } | |
| .step-body { font-size: .78rem; color: var(--soft); line-height: 1.6; } | |
| .step-body strong { color: var(--text); font-weight: 800; } | |
| .step-body em { color: var(--soft); font-style: italic; } | |
| .step-body code { | |
| background: var(--panel2); | |
| border: 1px solid var(--line); | |
| padding: 1px 5px; | |
| border-radius: 4px; | |
| font-size: .85em; | |
| color: var(--text); | |
| } | |
| .step-body a { color: var(--accent); text-decoration: none; } | |
| .step-body a:hover { text-decoration: underline; } | |
| /* Callback copy block */ | |
| .copy-block { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| background: var(--panel2); | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| padding: 11px 14px; | |
| } | |
| .copy-block-text { | |
| flex: 1; | |
| font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace; | |
| font-size: .78rem; | |
| color: var(--accent); | |
| word-break: break-all; | |
| opacity: .9; | |
| } | |
| /* Env var / field rows */ | |
| .env-list { display: flex; flex-direction: column; gap: 5px; } | |
| .env-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 10px 14px; | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| } | |
| .env-info { flex: 1; display: flex; flex-direction: column; gap: 3px; } | |
| .env-name { | |
| font-family: ui-monospace, "Cascadia Code", monospace; | |
| font-size: .78rem; | |
| color: var(--accent); | |
| background: rgba(59,130,246,.1); | |
| border: 1px solid rgba(59,130,246,.2); | |
| padding: 2px 7px; | |
| border-radius: 5px; | |
| width: fit-content; | |
| } | |
| .env-desc { font-size: .72rem; color: var(--muted); } | |
| .env-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } | |
| /* Badges */ | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| border: 1px solid var(--line); | |
| border-radius: 999px; | |
| padding: 2px 8px; | |
| font-size: .66rem; | |
| font-weight: 850; | |
| letter-spacing: .06em; | |
| text-transform: uppercase; | |
| white-space: nowrap; | |
| } | |
| .badge.ok { color: var(--good); border-color: rgba(34,197,94,.3); background: rgba(34,197,94,.1); } | |
| .badge.off { color: var(--bad); border-color: rgba(251,113,133,.3); background: rgba(251,113,133,.1); } | |
| /* Buttons — secondary (small copy buttons) */ | |
| .btn-copy { | |
| background: var(--panel2); | |
| border: 1px solid var(--line); | |
| color: var(--soft); | |
| font: inherit; | |
| font-size: .72rem; | |
| font-weight: 700; | |
| padding: 5px 10px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: background .12s, color .12s; | |
| flex-shrink: 0; | |
| white-space: nowrap; | |
| } | |
| .btn-copy:hover { background: var(--line); color: var(--text); } | |
| .btn-copy.copied { background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.3); color: var(--good); } | |
| /* White copy button (accent copy block) */ | |
| .btn-copy-white { | |
| background: #ffffff; | |
| border: 1px solid #ffffff; | |
| color: #0d0c1a; | |
| font: inherit; | |
| font-size: .76rem; | |
| font-weight: 800; | |
| padding: 6px 14px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: background .12s; | |
| flex-shrink: 0; | |
| white-space: nowrap; | |
| } | |
| .btn-copy-white:hover { background: #e8e8f0; border-color: #e8e8f0; } | |
| .btn-copy-white.copied { background: rgba(34,197,94,.15); border-color: rgba(34,197,94,.4); color: var(--good); } | |
| /* Primary CTA button — white with dark text */ | |
| .cta-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| margin-top: 18px; | |
| background: #ffffff; | |
| color: #0d0c1a; | |
| text-decoration: none; | |
| padding: 10px 22px; | |
| border-radius: 8px; | |
| font-size: .82rem; | |
| font-weight: 850; | |
| border: none; | |
| transition: background .15s; | |
| letter-spacing: .01em; | |
| } | |
| .cta-btn:hover { background: #e8e8f0; } | |
| .hint-final { | |
| margin-top: 10px; | |
| font-size: .74rem; | |
| color: var(--muted); | |
| line-height: 1.5; | |
| padding-bottom: 32px; | |
| } | |
| .hint-final strong { color: var(--soft); } | |
| /* Mobile */ | |
| @media (max-width: 680px) { | |
| body { overflow: auto; height: auto; } | |
| .layout { flex-direction: column; overflow: visible; } | |
| .sidebar { | |
| width: 100%; | |
| border-right: none; | |
| border-bottom: 1px solid var(--line); | |
| flex-direction: row; | |
| flex-wrap: wrap; | |
| overflow-x: auto; | |
| padding: 8px; | |
| gap: 4px; | |
| } | |
| .sidebar-label { display: none; } | |
| .sidebar-divider { display: none; } | |
| .plat-tab { width: auto; flex: 0 0 auto; } | |
| .tab-name { display: none; } | |
| .main { padding: 16px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="topbar"> | |
| <a class="topbar-back" href="/">← Dashboard</a> | |
| <span class="topbar-title">Platform Setup Guide</span> | |
| <span class="topbar-count">${configuredCount} / ${platforms.length} OAuth configured</span> | |
| </div> | |
| <div class="layout"> | |
| <nav class="sidebar"> | |
| <div class="sidebar-label">OAuth Platforms</div> | |
| ${oauthSidebarItems} | |
| <div class="sidebar-divider"></div> | |
| <div class="sidebar-label">Direct Connect</div> | |
| ${directSidebarItems} | |
| </nav> | |
| <main class="main"> | |
| ${oauthPanels} | |
| ${directPanels} | |
| </main> | |
| </div> | |
| <script> | |
| const PLATFORM_IDS = ${JSON.stringify(allPlatformIds)}; | |
| function show(i) { | |
| document.querySelectorAll('.plat-tab').forEach((t,j)=>t.classList.toggle('active',j===i)); | |
| document.querySelectorAll('.panel').forEach((p,j)=>p.classList.toggle('active',j===i)); | |
| if(PLATFORM_IDS[i]) history.replaceState(null,'','#'+PLATFORM_IDS[i]); | |
| } | |
| function copy(text,btn) { | |
| const finish = () => { | |
| const orig = btn.textContent; | |
| btn.textContent = 'Copied!'; | |
| btn.classList.add('copied'); | |
| setTimeout(()=>{ btn.textContent=orig; btn.classList.remove('copied'); },1800); | |
| }; | |
| if(navigator.clipboard) { navigator.clipboard.writeText(text).then(finish).catch(fallback); } | |
| else fallback(); | |
| function fallback() { | |
| const ta=document.createElement('textarea'); | |
| ta.value=text; ta.style.cssText='position:fixed;opacity:0'; | |
| document.body.appendChild(ta); ta.select(); | |
| try{document.execCommand('copy');}catch{} | |
| document.body.removeChild(ta); finish(); | |
| } | |
| } | |
| (function(){ | |
| const hash=location.hash.replace('#','').toLowerCase(); | |
| if(hash){const idx=PLATFORM_IDS.indexOf(hash);if(idx!==-1)show(idx);} | |
| })(); | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| // ============================================================================ | |
| // 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, ">") | |
| .replace(/"/g, """); | |
| } | |
| function toneBadge(label, tone = "neutral") { | |
| return `<span class="badge ${tone}">${escapeHtml(label)}</span>`; | |
| } | |
| function renderTile({ | |
| title, | |
| value, | |
| detail = "", | |
| tone = "neutral", | |
| meta = "", | |
| }) { | |
| return `<article class="tile ${tone}"> | |
| <div class="tile-head"> | |
| <span class="tile-title">${escapeHtml(title)}</span> | |
| <span class="tile-dot"></span> | |
| </div> | |
| <div class="tile-value">${value}</div> | |
| ${detail ? `<div class="tile-detail">${detail}</div>` : ""} | |
| ${meta ? `<div class="tile-meta">${meta}</div>` : ""} | |
| </article>`; | |
| } | |
| // ============================================================================ | |
| // 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 <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>` | |
| : 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 `<div class="plat-row ready"> | |
| <span class="plat-icon">${p.emoji}</span> | |
| <span class="plat-name">${p.name}</span> | |
| <span class="badge ok" style="font-size:0.72rem">Configured</span> | |
| </div>`; | |
| } | |
| return `<div class="plat-row"> | |
| <span class="plat-icon" style="filter:grayscale(1);opacity:.5">${p.emoji}</span> | |
| <span class="plat-name" style="color:var(--dim)">${p.name}</span> | |
| <a class="setup-link" href="/setup#${p.id}" style="margin-right:4px">Setup guide →</a> | |
| </div>`; | |
| }) | |
| .join(""); | |
| const readyNowRows = readyNow | |
| .map( | |
| (p) => ` | |
| <div class="plat-row ready"> | |
| <span class="plat-icon">${p.emoji}</span> | |
| <span class="plat-name">${p.name}</span> | |
| <span style="font-size:0.75rem;color:var(--dim)">${p.note || ""}</span> | |
| </div>`, | |
| ) | |
| .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 | |
| ? `<span class="local-time" data-iso="${data.sync.timestamp}"></span>` | |
| : "", | |
| }), | |
| renderTile({ | |
| title: "Keep Awake", | |
| value: toneBadge( | |
| keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), | |
| keepAliveTone, | |
| ), | |
| detail: keepAliveDetail, | |
| tone: keepAliveTone, | |
| }), | |
| ].join(""); | |
| return `<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>HuggingPost</title> | |
| <style> | |
| :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --panel2:#151421; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#3b82f6; --accent2:#8b5cf6; --dim:#94a3b8; } | |
| * { box-sizing:border-box; } | |
| body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; } | |
| main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; } | |
| header { text-align:center; margin-bottom:22px; } | |
| h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; } | |
| .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; } | |
| .hero-action { display:flex; width:100%; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:linear-gradient(135deg, var(--accent), var(--accent2)); color:#ffffff; text-decoration:none; font-weight:850; font-size:.98rem; margin:24px 0 20px; transition: opacity 0.15s ease; } | |
| .hero-action:hover { opacity: 0.9; } | |
| .hero-action.booting { background:var(--panel2); color:var(--muted); cursor:wait; border:1px solid var(--line); } | |
| .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:24px; } | |
| .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; } | |
| .tile.ok { border-color:rgba(34,197,94,.22); } | |
| .tile.warn { border-color:rgba(245,197,66,.24); } | |
| .tile.off { border-color:rgba(251,113,133,.28); } | |
| .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; } | |
| .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; } | |
| .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); } | |
| .tile.ok .tile-dot { background:var(--good); } | |
| .tile.warn .tile-dot { background:var(--warn); } | |
| .tile.off .tile-dot { background:var(--bad); } | |
| .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; } | |
| .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; } | |
| .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; } | |
| .card { background:var(--panel); border:1px solid var(--line); border-radius:11px; padding:20px; margin-bottom:12px; } | |
| .card h2 { font-size:.7rem; text-transform:uppercase; color:var(--muted); letter-spacing:.1em; margin:0 0 16px; } | |
| .steps { list-style:none; padding:0; margin:0; } | |
| .steps li { display:flex; gap:12px; padding:12px 0; border-top:1px solid var(--line); } | |
| .steps li:first-child { border-top:none; padding-top:0; } | |
| .steps li::before { content: counter(step-counter); counter-increment: step-counter; width:22px; height:22px; border-radius:50%; background:var(--panel2); border:1px solid var(--line); color:var(--text); font-size:10px; font-weight:800; display:flex; align-items:center; justify-content:center; flex-shrink:0; } | |
| .steps { counter-reset: step-counter; } | |
| .s-title { font-weight:800; margin-bottom:4px; font-size:14px; } | |
| .s-note { color:var(--muted); line-height:1.5; font-size:12px; } | |
| .plat-row { display:flex; align-items:center; gap:10px; padding:10px 0; border-top:1px solid var(--line); font-size:13px; } | |
| .plat-row:first-child { border-top:none; } | |
| .plat-icon { font-size:16px; width:22px; text-align:center; flex-shrink:0; } | |
| .plat-name { flex:1; font-weight:700; } | |
| .setup-link { color:var(--accent2); font-size:11px; text-decoration:none; font-weight:800; } | |
| .setup-link:hover { text-decoration:underline; } | |
| .badge { display:inline-flex; align-items:center; width:max-content; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; } | |
| .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); } | |
| .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); } | |
| .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); } | |
| .badge.neutral { color:var(--soft); } | |
| code { background:var(--panel2); border:1px solid var(--line); border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; } | |
| footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; } | |
| footer .live { color:var(--good); } | |
| @media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } } | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| <header> | |
| <h1>📝 HuggingPost</h1> | |
| <div class="subtitle">Self-hosted Postiz Dashboard</div> | |
| </header> | |
| ${ | |
| data.postizRunning | |
| ? `<a href="/app/auth" class="hero-action" target="_blank" rel="noopener">Open Postiz -></a>` | |
| : `<a href="#" class="hero-action booting" onclick="return false">Postiz is starting up (first boot ~5 min)...</a>` | |
| } | |
| <section class="overview"> | |
| ${tiles} | |
| </section> | |
| <div class="card"> | |
| <h2>🚀 Getting Started</h2> | |
| <ol class="steps"> | |
| <li> | |
| <div> | |
| <div class="s-title">Create your account</div> | |
| <div class="s-note">Click <strong>Open Postiz</strong> above. The first signup becomes the admin account.</div> | |
| </div> | |
| </li> | |
| <li> | |
| <div> | |
| <div class="s-title">Connect direct channels</div> | |
| <div class="s-note">Bluesky, Mastodon, Telegram, Dev.to, and Hashnode connect with just your credentials.</div> | |
| </div> | |
| </li> | |
| <li> | |
| <div> | |
| <div class="s-title">Enable OAuth platforms</div> | |
| <div class="s-note">LinkedIn, X, YouTube... require API keys. Use the <a href="/setup" class="setup-link">Setup Guide -></a> for instructions.</div> | |
| </div> | |
| </li> | |
| </ol> | |
| </div> | |
| <div class="card"> | |
| <h2>✅ Works immediately (${readyNow.length} platforms)</h2> | |
| <div class="plat-list"> | |
| ${readyNowRows} | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>🔑 Needs API keys (${configuredCount}/${needsSetup.length} configured)</h2> | |
| <div class="plat-list"> | |
| ${needsSetupRows} | |
| </div> | |
| <div style="margin-top:16px"> | |
| <a href="/setup" class="hero-action" style="background:var(--panel2); border:1px solid var(--line); margin:0;">📖 View Full Setup Guide</a> | |
| </div> | |
| </div> | |
| <footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: none;">@somratpro</a></footer> | |
| </main> | |
| <script> | |
| async function refresh() { | |
| try { | |
| const d = await fetch('/status').then(r => r.json()); | |
| if (d.postizRunning && document.querySelector('.hero-action.booting')) { | |
| location.reload(); | |
| } | |
| } catch(e) {} | |
| } | |
| setInterval(refresh, 15000); | |
| document.querySelectorAll('.local-time').forEach(el => { | |
| const date = new Date(el.getAttribute('data-iso')); | |
| if (!isNaN(date)) { | |
| el.textContent = 'At ' + date.toLocaleTimeString(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| // ============================================================================ | |
| // 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/redeploy-cf-worker — force Cloudflare Worker redeploy ─────────── | |
| // Strips CLOUDFLARE_PROXY_URL from subprocess env so the setup script | |
| // bypasses the short-circuit and deploys the updated Worker template. | |
| if (pathname === "/app/redeploy-cf-worker") { | |
| const { execFile } = require("child_process"); | |
| const subEnv = { ...process.env }; | |
| delete subEnv.CLOUDFLARE_PROXY_URL; | |
| delete subEnv.CLOUDFLARE_PROXY_SECRET; | |
| execFile("python3", ["/opt/postiz-sync.py", "--version"], { env: subEnv }, () => {}); // no-op warm-up | |
| execFile("python3", ["/opt/cloudflare-proxy-setup.py"], { env: subEnv, timeout: 60000 }, | |
| (err, stdout, stderr) => { | |
| const lines = [ | |
| "Redeploying Cloudflare Worker with updated template...", | |
| "", | |
| "=== stdout ===", | |
| stdout || "(empty)", | |
| "=== stderr ===", | |
| stderr || "(empty)", | |
| err ? "=== exec error ===\n" + err.message : "=== done ===", | |
| "", | |
| "Worker updated. Try connecting X now.", | |
| ]; | |
| res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); | |
| res.end(lines.join("\n")); | |
| } | |
| ); | |
| return; | |
| } | |
| // ── Dashboard at exact / ───────────────────────────────────────────────── | |
| if (pathname === "/" || pathname === "") { | |
| void (async () => { | |
| const postiz = await checkPostizHealth(); | |
| const uptime = Math.floor((Date.now() - startTime) / 1000); | |
| const initialData = { | |
| postizRunning: postiz.status === "running", | |
| uptimeHuman: formatUptime(uptime), | |
| 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<path> ────────────────────────────── | |
| // 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}`, | |
| ); | |
| }); | |