← back to Pattern Vault

wpb-uploaders/control-panel.js

116 lines

#!/usr/bin/env node
/*
 * WPB Uploader Control Panel — a local dashboard YOU drive.
 * Buttons launch each platform's login-capture / dry-run / status; live status per platform.
 * Zero-dependency Node. Basic auth admin:DW2024!.  →  http://localhost:9813
 *
 *   node control-panel.js
 */
'use strict';
const http = require('http'), fs = require('fs'), path = require('path'), os = require('os');
const { spawn, execFile } = require('child_process');

const PORT = process.env.PORT || 9813;
const ROOT = __dirname;
const PLATFORMS = ['spoonflower', 'etsy', 'creativemarket', 'redbubble', 'society6', 'patternbank'];
const AUTH_USER = 'admin', AUTH_PASS = 'DW2024!';

// track running capture children so the UI can show "login window open…"
const running = {}; // platform -> {pid, startedAt}

function loadPlatform(key) { try { return require(path.join(ROOT, 'platforms', `${key}.js`)); } catch { return null; } }

// verify a captured session is REAL (load account page in a probe, check auth markers)
function verifySession(key) {
  return new Promise((resolve) => {
    const cfg = loadPlatform(key);
    if (!cfg || cfg.apiBased || !cfg.sessionFile || !fs.existsSync(cfg.sessionFile)) return resolve({ captured: false });
    const code = `
      const { chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright');
      (async () => { try {
        const b = await chromium.launch({ headless: true });
        const ctx = await b.newContext({ storageState: ${JSON.stringify(cfg.sessionFile)} });
        const pg = await ctx.newPage();
        await pg.goto(${JSON.stringify(cfg.healthUrl)}, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(()=>{});
        await pg.waitForTimeout(1500);
        const u = pg.url();
        const bad = /login|signin/i.test(u) || !!(await pg.$('input[type=password]').catch(()=>null));
        const logout = !!(await pg.$('a[href*=logout i], a[href*=sign_out i], button:has-text("Sign out"), button:has-text("Log out")').catch(()=>null));
        const txt = ((await pg.evaluate(()=>document.body.innerText).catch(()=>'')||'')).toLowerCase();
        const words = /sign out|log out|my account|dashboard|purchases|downloads|my shop/.test(txt);
        console.log(JSON.stringify({ captured: !bad && (logout||words) }));
        await b.close();
      } catch(e) { console.log(JSON.stringify({ captured:false, err:String(e).slice(0,80) })); } })();`;
    execFile(process.execPath, ['-e', code], { timeout: 45000 }, (err, stdout) => {
      try { resolve(JSON.parse(stdout.trim().split('\n').pop())); } catch { resolve({ captured: false }); }
    });
  });
}

function statusOf(key) {
  const cfg = loadPlatform(key);
  const armed = key === 'spoonflower'
    ? fs.existsSync(path.join(ROOT, '..', 'whimsical-compare', 'daily', 'ARMED'))
    : fs.existsSync(path.join(ROOT, 'platforms', `${key}.ARMED`));
  const hasSession = key === 'spoonflower'
    ? fs.existsSync(path.join(os.homedir(), '.config/pattern-vault/sf-state.json'))
    : !!(cfg && cfg.sessionFile && fs.existsSync(cfg.sessionFile));
  return {
    key, name: cfg ? cfg.name : key, brand: cfg ? cfg.brand : '', apiBased: !!(cfg && cfg.apiBased),
    armed, hasSession, running: !!running[key],
    blocker: key === 'spoonflower' ? 'none — working + armed'
      : cfg && cfg.apiBased ? (process.env.ETSY_OAUTH_TOKEN ? 'token present' : 'needs Etsy OAuth token')
      : hasSession ? 'session captured — needs DOM calibration'
      : ['society6', 'patternbank'].includes(key) ? 'account pending confirmation'
      : 'needs login (click Log in)',
  };
}

// ---- http ----
function auth(req) { const h = req.headers.authorization || ''; if (!h.startsWith('Basic ')) return false; const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':'); return u === AUTH_USER && p === AUTH_PASS; }
function send(res, code, body, type) { res.writeHead(code, { 'Content-Type': type || 'application/json', 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); }

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost:${PORT}`);
  const p = url.pathname;
  if (p === '/healthz') return send(res, 200, { ok: true });
  if (!auth(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WPB"' }); return res.end('auth'); }

  if (p === '/api/status') {
    const rows = PLATFORMS.map(statusOf);
    return send(res, 200, { rows, generatedAt: new Date().toISOString() });
  }

  // launch a login-capture window for a platform (opens a browser for Steve to log into)
  const capM = p.match(/^\/api\/capture\/(\w+)$/);
  if (capM && req.method === 'POST') {
    const key = capM[1];
    if (!['creativemarket', 'redbubble', 'society6', 'patternbank'].includes(key)) return send(res, 400, { ok: false, error: 'not a web-login platform' });
    if (running[key]) return send(res, 200, { ok: true, already: true });
    const engine = url.searchParams.get('engine') === 'chromium' ? 'chromium' : 'webkit';
    const child = spawn(process.execPath, [path.join(ROOT, 'capture-session.js'), key],
      { cwd: ROOT, detached: true, stdio: 'ignore', env: { ...process.env, WPB_ENGINE: engine } });
    running[key] = { pid: child.pid, startedAt: Date.now() };
    child.on('exit', () => { delete running[key]; });
    child.unref();
    return send(res, 200, { ok: true, launched: key, engine, note: 'a browser window is opening — log in there' });
  }

  // verify a platform's captured session is genuinely authenticated
  const verM = p.match(/^\/api\/verify\/(\w+)$/);
  if (verM && req.method === 'POST') { const r = await verifySession(verM[1]); return send(res, 200, r); }

  // Etsy dry-run (proves the 38-payload mapping, no network)
  if (p === '/api/etsy/dryrun') { const cfg = loadPlatform('etsy'); return send(res, 200, cfg && cfg.dryRun ? cfg.dryRun() : { error: 'no etsy' }); }

  // static
  let f = p === '/' ? '/control-panel.html' : p;
  const abs = path.join(ROOT, 'public', path.normalize(f).replace(/^(\.\.[\/\\])+/, ''));
  if (abs.startsWith(path.join(ROOT, 'public')) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
    const ext = path.extname(abs);
    return send(res, 200, fs.readFileSync(abs), { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain');
  }
  return send(res, 404, { error: 'not found' });
});
server.listen(PORT, () => console.log(`WPB Uploader Control Panel → http://localhost:${PORT} (admin:DW2024!)`));