← back to Ig Token Wizard

server.js

470 lines

// ─────────────────────────────────────────────────────────────────────────
// ig-token-wizard — local click-through wizard that produces the
// IG_USER_ID + IG_ACCESS_TOKEN that wallco.ai's reel maker needs to publish
// to Instagram.
//
// TWO paths:
//   • "Continue with Facebook" — the wizard hosts the OAuth login itself.
//     You paste only App ID + App Secret, click one blue button, approve in
//     Meta's dialog, and the wizard captures + exchanges the token for you.
//   • Manual paste — paste App ID + Secret + a short-lived Explorer token.
//
// Either way the wizard runs: code/short-token → long-lived exchange →
// Page lookup → IG-account resolution → verification.
//
// Zero dependencies. Binds loopback only (127.0.0.1 + ::1). Tokens are never
// written to disk; App ID/Secret live in memory only for the OAuth round-trip.
//   node server.js   →   http://localhost:9939
// ─────────────────────────────────────────────────────────────────────────
'use strict';
const http = require('http');
const crypto = require('crypto');

const PORT = process.env.PORT || 9939;
const API_VER = 'v19.0';
const GRAPH = 'https://graph.facebook.com/' + API_VER;
const OAUTH_DIALOG = 'https://www.facebook.com/' + API_VER + '/dialog/oauth';
const PERMS = 'instagram_basic,instagram_content_publish,pages_show_list,pages_read_engagement,business_management';

// In-memory session store for the OAuth round-trip. Keyed by a cookie value.
// Entries expire after 15 minutes. Never persisted.
const sessions = new Map();
function newSession(data) {
  const id = crypto.randomBytes(18).toString('hex');
  sessions.set(id, Object.assign({ ts: Date.now() }, data));
  return id;
}
function getSession(id) {
  const s = id && sessions.get(id);
  if (!s) return null;
  if (Date.now() - s.ts > 15 * 60 * 1000) { sessions.delete(id); return null; }
  return s;
}
setInterval(() => {
  const cutoff = Date.now() - 15 * 60 * 1000;
  for (const [k, v] of sessions) if (v.ts < cutoff) sessions.delete(k);
}, 5 * 60 * 1000).unref();

// ── Graph API helpers ──────────────────────────────────────────────────────
async function graph(pathQuery) {
  const r = await fetch(GRAPH + pathQuery);
  const j = await r.json().catch(() => ({}));
  if (j && j.error) {
    const e = new Error(j.error.message || 'Graph API error');
    e.graph = j.error;
    throw e;
  }
  return j;
}

// Exchange an OAuth `code` (from the login dialog) for a short-lived token.
async function codeToToken({ appId, appSecret, redirectUri, code }) {
  const j = await graph('/oauth/access_token'
    + '?client_id=' + encodeURIComponent(appId)
    + '&client_secret=' + encodeURIComponent(appSecret)
    + '&redirect_uri=' + encodeURIComponent(redirectUri)
    + '&code=' + encodeURIComponent(code));
  if (!j.access_token) throw new Error('Meta did not return an access token from the login code.');
  return j.access_token;
}

// Run the whole short-token → IG-credentials chain.
async function resolveCredentials({ appId, appSecret, shortToken }) {
  // Exchange short-lived user token for a long-lived one.
  const ex = await graph('/oauth/access_token?grant_type=fb_exchange_token'
    + '&client_id=' + encodeURIComponent(appId)
    + '&client_secret=' + encodeURIComponent(appSecret)
    + '&fb_exchange_token=' + encodeURIComponent(shortToken));
  const longUserToken = ex.access_token;
  if (!longUserToken) throw new Error('No long-lived token returned — check App ID / Secret / token.');

  // List Facebook Pages. A page token derived from a long-lived user token is
  // itself long-lived / effectively non-expiring.
  const pagesRes = await graph('/me/accounts?fields=name,access_token,id&access_token='
    + encodeURIComponent(longUserToken));
  const pages = pagesRes.data || [];
  if (!pages.length) {
    throw new Error('No Facebook Pages found on this account. The Instagram account '
      + 'must be a Business/Creator account linked to a Facebook Page.');
  }

  // For each page, resolve the linked IG account + verify it.
  const accounts = [];
  for (const pg of pages) {
    try {
      const det = await graph('/' + pg.id + '?fields=name,instagram_business_account'
        + '&access_token=' + encodeURIComponent(pg.access_token));
      const ig = det.instagram_business_account;
      if (!ig || !ig.id) {
        accounts.push({ pageName: pg.name, pageId: pg.id, igLinked: false });
        continue;
      }
      let username = null, followers = null;
      try {
        const v = await graph('/' + ig.id + '?fields=username,followers_count'
          + '&access_token=' + encodeURIComponent(pg.access_token));
        username = v.username || null;
        followers = (typeof v.followers_count === 'number') ? v.followers_count : null;
      } catch (_) { /* verification is best-effort */ }
      accounts.push({
        pageName: pg.name, pageId: pg.id, igLinked: true,
        igUserId: ig.id, igUsername: username, followers,
        igAccessToken: pg.access_token,
      });
    } catch (e) {
      accounts.push({ pageName: pg.name, pageId: pg.id, igLinked: false, error: e.message });
    }
  }
  return { accounts };
}

// ── tiny helpers ───────────────────────────────────────────────────────────
function send(res, code, type, body, extraHeaders) {
  res.writeHead(code, Object.assign(
    { 'Content-Type': type, 'Cache-Control': 'no-store' }, extraHeaders || {}));
  res.end(body);
}
function esc(s) {
  return String(s == null ? '' : s).replace(/[&<>"]/g,
    c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
function parseCookies(req) {
  const out = {};
  (req.headers.cookie || '').split(';').forEach(p => {
    const i = p.indexOf('='); if (i < 0) return;
    out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
  });
  return out;
}
// The redirect URI Meta must have whitelisted — derived from the Host the
// browser actually used, so it always matches.
function redirectUri(req) {
  const host = (req.headers.host || ('localhost:' + PORT)).trim();
  return 'http://' + host + '/oauth/callback';
}

// ── HTTP server ────────────────────────────────────────────────────────────
const requestHandler = async (req, res) => {
  const u = new URL(req.url, 'http://x');
  const path = u.pathname;

  // Home page.
  if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
    return send(res, 200, 'text/html; charset=utf-8', renderPage(redirectUri(req)));
  }

  // Step A of OAuth: stash App ID/Secret in a session, hand back a cookie.
  if (req.method === 'POST' && path === '/api/config') {
    let body = '';
    req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
    req.on('end', () => {
      try {
        const { appId, appSecret } = JSON.parse(body || '{}');
        if (!appId || !appSecret) {
          return send(res, 400, 'application/json',
            JSON.stringify({ ok: false, error: 'Enter both App ID and App Secret.' }));
        }
        const state = crypto.randomBytes(12).toString('hex');
        const sid = newSession({
          appId: String(appId).trim(), appSecret: String(appSecret).trim(), state,
        });
        send(res, 200, 'application/json', JSON.stringify({ ok: true }),
          { 'Set-Cookie': 'igw_sess=' + sid + '; Path=/; HttpOnly; SameSite=Lax' });
      } catch (e) {
        send(res, 200, 'application/json', JSON.stringify({ ok: false, error: e.message }));
      }
    });
    return;
  }

  // Step B: bounce the browser to Meta's login dialog.
  if (req.method === 'GET' && path === '/oauth/start') {
    const s = getSession(parseCookies(req).igw_sess);
    if (!s) return send(res, 200, 'text/html; charset=utf-8',
      resultPage('Session expired', '<p>Your session timed out. Go back and click “Continue with Facebook” again.</p>'));
    const url = OAUTH_DIALOG
      + '?client_id=' + encodeURIComponent(s.appId)
      + '&redirect_uri=' + encodeURIComponent(redirectUri(req))
      + '&scope=' + encodeURIComponent(PERMS)
      + '&state=' + encodeURIComponent(s.state)
      + '&response_type=code';
    return send(res, 302, 'text/plain', 'redirecting', { 'Location': url });
  }

  // Step C: Meta redirects back here with ?code=… — exchange + resolve.
  if (req.method === 'GET' && path === '/oauth/callback') {
    const s = getSession(parseCookies(req).igw_sess);
    const code = u.searchParams.get('code');
    const state = u.searchParams.get('state');
    const err = u.searchParams.get('error_description') || u.searchParams.get('error');
    if (err) return send(res, 200, 'text/html; charset=utf-8',
      resultPage('Login cancelled', '<p>' + esc(err) + '</p>'));
    if (!s) return send(res, 200, 'text/html; charset=utf-8',
      resultPage('Session expired', '<p>Start over — click “Continue with Facebook” again.</p>'));
    if (!code || state !== s.state) return send(res, 200, 'text/html; charset=utf-8',
      resultPage('Could not verify the login', '<p>The login response did not match. Start over.</p>'));
    try {
      const shortToken = await codeToToken({
        appId: s.appId, appSecret: s.appSecret,
        redirectUri: redirectUri(req), code,
      });
      const { accounts } = await resolveCredentials({
        appId: s.appId, appSecret: s.appSecret, shortToken,
      });
      sessions.delete(parseCookies(req).igw_sess);
      return send(res, 200, 'text/html; charset=utf-8',
        resultPage('Choose your Instagram account', accountsHtml(accounts)));
    } catch (e) {
      return send(res, 200, 'text/html; charset=utf-8',
        resultPage('Something went wrong', '<div class="err">' + esc(e.message) + '</div>'
          + '<p class="note">Most common cause: the redirect URI is not whitelisted. '
          + 'In your Meta app → Facebook Login → Settings, add exactly:<br><code>'
          + esc(redirectUri(req)) + '</code></p>'));
    }
  }

  // Manual-paste fallback.
  if (req.method === 'POST' && path === '/api/run') {
    let body = '';
    req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
    req.on('end', async () => {
      try {
        const { appId, appSecret, shortToken } = JSON.parse(body || '{}');
        if (!appId || !appSecret || !shortToken) {
          return send(res, 400, 'application/json',
            JSON.stringify({ ok: false, error: 'Fill in App ID, App Secret, and the short-lived token.' }));
        }
        const out = await resolveCredentials({
          appId: String(appId).trim(),
          appSecret: String(appSecret).trim(),
          shortToken: String(shortToken).trim(),
        });
        send(res, 200, 'application/json', JSON.stringify({ ok: true, ...out }));
      } catch (e) {
        send(res, 200, 'application/json', JSON.stringify({ ok: false, error: e.message }));
      }
    });
    return;
  }

  send(res, 404, 'text/plain', 'not found');
};

// Listen on BOTH loopback addresses — IPv4 127.0.0.1 and IPv6 ::1 — so the
// wizard is reachable whether the browser resolves "localhost" to v4 or v6.
for (const host of ['127.0.0.1', '::1']) {
  http.createServer(requestHandler).listen(PORT, host, () => {
    const shown = host.includes(':') ? '[' + host + ']' : host;
    console.log('ig-token-wizard → http://' + shown + ':' + PORT);
  });
}

// ── shared CSS ─────────────────────────────────────────────────────────────
const CSS = `
  :root{--bg:#faf8f3;--ink:#2a2014;--soft:#6b5e4a;--faint:#9b8f78;--line:#ddd3bf;
        --gold:#c9a14b;--card:#fff;--sans:'Helvetica Neue',Arial,sans-serif;--serif:Georgia,serif}
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans)}
  .wrap{max-width:680px;margin:0 auto;padding:36px 22px 90px}
  h1{font:300 32px/1.15 var(--serif);margin:0 0 6px}
  .sub{color:var(--soft);font-size:14px;margin:0 0 26px}
  .step{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:20px 22px;margin:16px 0}
  .step h2{font:400 18px var(--serif);margin:0 0 4px;display:flex;align-items:center;gap:9px}
  .n{display:inline-flex;width:24px;height:24px;border-radius:50%;background:var(--ink);color:var(--bg);
     font:600 12px var(--sans);align-items:center;justify-content:center;flex:none}
  .hint{color:var(--faint);font-size:12.5px;margin:2px 0 12px}
  ol{margin:8px 0 12px;padding-left:20px;font-size:13.5px;color:var(--soft)}
  ol li{margin:5px 0}
  code{background:#f0ebdf;padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
  .perms{display:flex;flex-wrap:wrap;gap:5px;margin:8px 0}
  .perms span{background:#f0ebdf;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11.5px}
  label{display:block;font-size:12px;color:var(--soft);margin:11px 0 4px;font-weight:600}
  input,textarea{width:100%;padding:9px 12px;border:1px solid var(--line);border-radius:7px;
     font:13px var(--sans);background:var(--bg)}
  textarea{resize:vertical;min-height:62px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px}
  .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:8px;cursor:pointer;
       font:600 14px var(--sans);padding:12px 24px;margin-top:14px}
  .btn.primary{background:var(--ink);color:var(--bg)}
  .btn:disabled{opacity:.45;cursor:not-allowed}
  .btn.fb{background:#1877f2;color:#fff;font-size:15px;padding:13px 26px}
  .btn.gold{background:var(--gold);color:#1a1408}
  details{margin-top:10px}
  summary{cursor:pointer;font-size:13px;color:var(--soft)}
  .acct{border:1px solid var(--line);border-radius:9px;padding:12px 14px;margin:8px 0;background:var(--bg)}
  .acct.no{opacity:.65}
  .acct b{font-size:14px}
  .acct .meta{font-size:12px;color:var(--faint)}
  .err{color:#b4453a;font-size:13px;background:#fbeae8;border:1px solid #e8c4bf;border-radius:8px;
       padding:10px 13px;margin-top:10px}
  .result{background:#1f1808;color:#f4ece0;border-radius:10px;padding:16px 18px;margin-top:12px}
  .result h3{margin:0 0 10px;font:400 16px var(--serif);color:#f4ece0}
  .cred{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#000;border-radius:6px;
        padding:11px 13px;word-break:break-all;line-height:1.7;margin:8px 0}
  .ok{color:#7fd99a}
  .spin{display:none;font-size:13px;color:var(--soft);margin-top:10px}
  .note{font-size:11.5px;color:var(--faint);line-height:1.55;margin-top:10px}
  a{color:#1877f2}
`;

// ── result / callback page ─────────────────────────────────────────────────
function accountsHtml(accounts) {
  if (!accounts || !accounts.length) return '<div class="err">No Facebook Pages found on this account.</div>';
  let html = '<p class="hint">Copy the two lines for the account wallco posts from, and paste them to Claude.</p>';
  accounts.forEach((a, i) => {
    if (a.igLinked) {
      const lines = 'IG_USER_ID=' + a.igUserId + '\nIG_ACCESS_TOKEN=' + a.igAccessToken;
      html += '<div class="acct"><b>@' + esc(a.igUsername || 'instagram') + '</b>'
        + (a.followers != null ? ' &middot; ' + a.followers.toLocaleString() + ' followers' : '')
        + '<div class="meta">via Page: ' + esc(a.pageName) + '</div>'
        + '<div class="cred">IG_USER_ID=<span class="ok">' + esc(a.igUserId) + '</span><br>'
        + 'IG_ACCESS_TOKEN=<span class="ok">' + esc(a.igAccessToken) + '</span></div>'
        + '<button class="btn gold" data-copy="' + esc(lines) + '">Copy both lines</button></div>';
    } else {
      html += '<div class="acct no"><b>' + esc(a.pageName) + '</b>'
        + '<div class="meta">No Instagram account linked'
        + (a.error ? ' — ' + esc(a.error) : '') + '</div></div>';
    }
  });
  html += '<p class="note">These run entirely on your machine. The Page token is long-lived '
    + '(no 60-day clock). Don’t paste them anywhere public.</p>';
  return html;
}
function resultPage(title, inner) {
  return '<!doctype html><html lang="en"><head><meta charset="utf-8">'
    + '<meta name="viewport" content="width=device-width, initial-scale=1">'
    + '<title>' + esc(title) + ' — IG Token Wizard</title><style>' + CSS + '</style></head><body>'
    + '<div class="wrap"><h1>' + esc(title) + '</h1>' + inner
    + '<p style="margin-top:22px"><a href="/">&larr; Back to the wizard</a></p></div>'
    + '<script>document.addEventListener("click",function(e){'
    + 'var b=e.target.closest("[data-copy]");if(!b)return;'
    + 'navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function(){'
    + 'var t=b.textContent;b.textContent="Copied \\u2713";setTimeout(function(){b.textContent=t;},1800);});'
    + '});</script></body></html>';
}

// ── home page ──────────────────────────────────────────────────────────────
function renderPage(redirUri) {
  return `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Instagram Token Wizard — wallco.ai</title>
<style>${CSS}</style></head><body>
<div class="wrap">
  <h1>Instagram Token Wizard</h1>
  <p class="sub">Hooks wallco.ai up to publish reels &amp; stories to Instagram. Paste two values, click one blue button, approve in Meta&rsquo;s pop-up &mdash; the wizard does the rest. Runs entirely on your machine.</p>

  <div class="step">
    <h2><span class="n">1</span> One-time Meta setup</h2>
    <ol>
      <li>wallco&rsquo;s Instagram must be a <b>Business/Creator</b> account <b>linked to a Facebook Page</b>.</li>
      <li>At <a href="https://developers.facebook.com/apps" target="_blank" rel="noopener noreferrer">developers.facebook.com/apps</a> open (or create, type <b>Business</b>) your app. Add two products: <b>Instagram Graph API</b> and <b>Facebook Login</b>.</li>
      <li>In <b>Facebook Login &rarr; Settings</b>, under <b>Valid OAuth Redirect URIs</b>, paste exactly this and save:
        <br><code>${esc(redirUri)}</code></li>
      <li>From <b>Settings &rarr; Basic</b>, copy your <b>App ID</b> and <b>App Secret</b> (click &ldquo;Show&rdquo;).</li>
    </ol>
    <p class="hint">You only do this once. After it&rsquo;s saved, every future token is just the blue button below.</p>
  </div>

  <div class="step">
    <h2><span class="n">2</span> Connect</h2>
    <label for="appId">App ID</label>
    <input id="appId" autocomplete="off" placeholder="e.g. 1234567890123456">
    <label for="appSecret">App Secret</label>
    <input id="appSecret" autocomplete="off" placeholder="32-character secret from Settings &rarr; Basic">
    <button class="btn fb" id="fb">Continue with Facebook</button>
    <div class="spin" id="spin">Saving&hellip;</div>
    <div id="err"></div>
    <p class="note">Clicking the button sends you to Meta&rsquo;s own login page. Approve the requested permissions and pick wallco&rsquo;s Page &mdash; you land back here with the credentials.</p>

    <details>
      <summary>Or paste a short-lived token manually (advanced)</summary>
      <label for="mAppId">App ID</label>
      <input id="mAppId" autocomplete="off">
      <label for="mAppSecret">App Secret</label>
      <input id="mAppSecret" autocomplete="off">
      <label for="mToken">Short-lived token (from the <a href="https://developers.facebook.com/tools/explorer/" target="_blank" rel="noopener noreferrer">Graph API Explorer</a>)</label>
      <textarea id="mToken" autocomplete="off" placeholder="EAAG..."></textarea>
      <div class="perms">
        <span>instagram_basic</span><span>instagram_content_publish</span><span>pages_show_list</span>
        <span>pages_read_engagement</span><span>business_management</span>
      </div>
      <button class="btn primary" id="run">Get credentials from token</button>
      <div class="spin" id="mspin">Talking to Meta&hellip;</div>
      <div id="merr"></div>
      <div id="mout"></div>
    </details>
  </div>
</div>
<script>
  var $=function(id){return document.getElementById(id);};

  // OAuth path.
  $('fb').addEventListener('click',function(){
    var appId=$('appId').value.trim(), appSecret=$('appSecret').value.trim();
    $('err').innerHTML='';
    if(!appId||!appSecret){ $('err').innerHTML='<div class="err">Enter both App ID and App Secret.</div>'; return; }
    $('fb').disabled=true; $('spin').style.display='block';
    fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({appId:appId,appSecret:appSecret})})
    .then(function(r){return r.json();})
    .then(function(j){
      if(!j.ok){ $('fb').disabled=false; $('spin').style.display='none';
        $('err').innerHTML='<div class="err">'+esc(j.error||'error')+'</div>'; return; }
      window.location='/oauth/start';
    })
    .catch(function(e){ $('fb').disabled=false; $('spin').style.display='none';
      $('err').innerHTML='<div class="err">'+esc(e.message)+'</div>'; });
  });

  // Manual paste path.
  $('run').addEventListener('click',function(){
    var appId=$('mAppId').value.trim(), appSecret=$('mAppSecret').value.trim(), token=$('mToken').value.trim();
    $('merr').innerHTML=''; $('mout').innerHTML='';
    if(!appId||!appSecret||!token){ $('merr').innerHTML='<div class="err">Fill in all three fields.</div>'; return; }
    $('run').disabled=true; $('mspin').style.display='block';
    fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({appId:appId,appSecret:appSecret,shortToken:token})})
    .then(function(r){return r.json();})
    .then(function(j){
      $('run').disabled=false; $('mspin').style.display='none';
      if(!j.ok){ $('merr').innerHTML='<div class="err">'+esc(j.error||'error')+'</div>'; return; }
      renderAccounts(j.accounts||[]);
    })
    .catch(function(e){ $('run').disabled=false; $('mspin').style.display='none';
      $('merr').innerHTML='<div class="err">'+esc(e.message)+'</div>'; });
  });

  function renderAccounts(list){
    var out=$('mout'); out.innerHTML='';
    if(!list.length){ out.innerHTML='<div class="err">No Facebook Pages found.</div>'; return; }
    list.forEach(function(a){
      var d=document.createElement('div'); d.className='acct'+(a.igLinked?'':' no');
      if(a.igLinked){
        var lines='IG_USER_ID='+a.igUserId+'\\nIG_ACCESS_TOKEN='+a.igAccessToken;
        d.innerHTML='<b>@'+esc(a.igUsername||'instagram')+'</b>'+
          (a.followers!=null?' &middot; '+a.followers.toLocaleString()+' followers':'')+
          '<div class="meta">via Page: '+esc(a.pageName)+'</div>'+
          '<div class="cred">IG_USER_ID=<span class="ok">'+esc(a.igUserId)+'</span><br>'+
          'IG_ACCESS_TOKEN=<span class="ok">'+esc(a.igAccessToken)+'</span></div>'+
          '<button class="btn gold" data-copy="'+esc(lines)+'">Copy both lines</button>';
      } else {
        d.innerHTML='<b>'+esc(a.pageName)+'</b><div class="meta">No Instagram account linked'+
          (a.error?' — '+esc(a.error):'')+'</div>';
      }
      out.appendChild(d);
    });
  }

  document.addEventListener('click',function(e){
    var b=e.target.closest('[data-copy]'); if(!b) return;
    navigator.clipboard.writeText(b.getAttribute('data-copy')).then(function(){
      var t=b.textContent; b.textContent='Copied \\u2713';
      setTimeout(function(){ b.textContent=t; },1800);
    });
  });

  function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){
    return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
</script>
</body></html>`;
}