← 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!)`));