← back to Dw Marketing Reels
server.js
283 lines
#!/usr/bin/env node
// DW Marketing — New Arrivals Reels console.
// Zero-dependency Node http server behind HTTP Basic Auth. Serves the reel gallery,
// the current New Arrivals grid (sort + density per standing rule), and lets marketing
// refresh the feed + generate a new reel on demand ($0 local render).
import http from 'node:http';
import { readFile, writeFile, stat, readdir } from 'node:fs/promises';
import { createReadStream, existsSync, realpathSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join, extname, normalize, sep } from 'node:path';
const ROOT = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 9848);
const USER = process.env.MKT_USER || 'admin';
const PASS = process.env.MKT_PASS || 'DW2024!';
// Generation needs the local render toolchain (Chrome/ffmpeg). On the public
// Kamatera deploy set ALLOW_BUILD=0 — it serves reels only; Mac3 renders + pushes.
const ALLOW_BUILD = process.env.ALLOW_BUILD !== '0';
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.mp4': 'video/mp4', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
let building = false;
let lastLog = '';
function authed(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 === USER && p === PASS;
}
function run(script, res, extraArgs = []) {
if (!ALLOW_BUILD) { res.writeHead(400, { 'content-type': 'application/json' })
.end(JSON.stringify({ error: 'Generation runs on the render host (Mac3). This deploy serves reels only.' })); return; }
if (building) { res.writeHead(409).end(JSON.stringify({ error: 'a job is already running' })); return; }
building = true; lastLog = '';
const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}`, HYPERFRAMES_SKIP_SKILLS: '1' };
const ch = spawn(process.execPath, [join(ROOT, 'scripts', script), ...extraArgs], { cwd: ROOT, env });
ch.stdout.on('data', d => { lastLog += d; });
ch.stderr.on('data', d => { lastLog += d; });
ch.on('close', code => { building = false; lastLog += `\n[exit ${code}]`; });
res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ started: script }));
}
function readBody(req) {
return new Promise((resolve, reject) => {
let b = ''; req.on('data', d => { b += d; if (b.length > 1e6) req.destroy(); });
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (e) { reject(e); } });
req.on('error', reject);
});
}
async function readJson(fp, fallback) {
return existsSync(fp) ? JSON.parse(await readFile(fp, 'utf8')) : fallback;
}
// Build + validate a Video Studio job from a request body. Shared by the manual
// /api/video/queue door AND the "Use it" action on a daily suggestion, so both paths
// validate identically. Returns { job } on success or { error, status } on rejection.
const VIDEO_KINDS = ['new-arrivals-reel', 'url-reel', 'newsletter-video'];
function buildVideoJob(b) {
const kind = String(b.kind || '').trim();
if (!VIDEO_KINDS.includes(kind)) return { error: 'unknown kind', status: 400 };
let url = '', eml = '';
if (kind === 'url-reel') {
try { const u = new URL(String(b.url || '')); if (u.protocol !== 'https:') throw 0; url = u.href.slice(0, 400); }
catch { return { error: 'url must be a valid https:// URL', status: 400 }; }
}
if (kind === 'newsletter-video') {
eml = String(b.eml || '').trim();
if (eml.includes('..') || !/^(~|\/)[\w .\/-]{1,380}\.eml$/.test(eml))
return { error: 'eml must be an absolute or ~ path ending in .eml', status: 400 };
}
const job = { id: 'vj-' + Date.now().toString(36), kind, url, eml,
format: b.format === '1:1' ? '1:1' : '9:16', status: 'queued', created_at: new Date().toISOString() };
return { job };
}
// Persist a job to the queue (respecting the pending-jobs cap) and best-effort nudge the
// runner (which no-ops unless VIDEO_AUTORUN=1). Returns { job } or { error, status }.
async function enqueueVideoJob(job) {
const fp = join(ROOT, 'data', 'video-jobs.json');
const jobs = await readJson(fp, []);
if (jobs.filter(j => j.status === 'queued' || j.status === 'running').length >= 20)
return { error: 'too many pending jobs — clear the queue first', status: 429 };
jobs.unshift(job);
await writeFile(fp, JSON.stringify(jobs, null, 2));
try {
const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}` };
spawn(process.execPath, [join(ROOT, 'scripts', 'video-runner.mjs')], { cwd: ROOT, env, detached: true, stdio: 'ignore' }).unref();
} catch {}
return { job };
}
async function serveFile(fp, req, res) {
const st = await stat(fp);
const type = MIME[extname(fp)] || 'application/octet-stream';
// range support so <video> can scrub in every browser
const range = req.headers.range;
if (range && type === 'video/mp4') {
const m = /bytes=(\d+)-(\d*)/.exec(range);
const start = Number(m[1]); const end = m[2] ? Number(m[2]) : st.size - 1;
res.writeHead(206, { 'content-type': type, 'accept-ranges': 'bytes',
'content-range': `bytes ${start}-${end}/${st.size}`, 'content-length': end - start + 1 });
createReadStream(fp, { start, end }).pipe(res); return;
}
res.writeHead(200, { 'content-type': type, 'content-length': st.size, 'accept-ranges': 'bytes' });
createReadStream(fp).pipe(res);
}
// Finished reel MP4s are marketing assets meant to be posted publicly, and Meta/TikTok
// must be able to fetch them — so GET /reels/*.mp4 is the ONE unauthenticated path.
// Everything else (console, APIs, data) stays Basic-Auth gated.
const PUBLIC_MEDIA = /^\/reels\/[^/]+\.mp4$/;
// Public legal pages must be readable without auth (Meta app review fetches them).
const PUBLIC_PAGES = /^\/(privacy|terms|showcase)$/;
const isPublicMedia = req => (req.method === 'GET' || req.method === 'HEAD') && (PUBLIC_MEDIA.test(new URL(req.url, 'http://localhost').pathname) || PUBLIC_PAGES.test(new URL(req.url, 'http://localhost').pathname));
const server = http.createServer(async (req, res) => {
if (!isPublicMedia(req) && !authed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Marketing"' }).end('Auth required');
return;
}
const url = new URL(req.url, `http://localhost`);
const p = url.pathname;
try {
if (p === '/' ) return await serveFile(join(ROOT, 'public', 'index.html'), req, res);
if (p === '/privacy') return await serveFile(join(ROOT, 'public', 'privacy.html'), req, res);
if (p === '/terms') return await serveFile(join(ROOT, 'public', 'terms.html'), req, res);
if (p === '/showcase') return await serveFile(join(ROOT, 'public', 'showcase.html'), req, res);
if (p === '/api/reels') {
const fp = join(ROOT, 'data', 'reels.json');
return res.writeHead(200, { 'content-type': 'application/json' }).end(existsSync(fp) ? await readFile(fp) : '[]');
}
if (p === '/api/new-arrivals') {
const fp = join(ROOT, 'data', 'new-arrivals.json');
return res.writeHead(200, { 'content-type': 'application/json' }).end(existsSync(fp) ? await readFile(fp) : '{"items":[]}');
}
if (p === '/api/config') return res.writeHead(200, { 'content-type': 'application/json' })
.end(JSON.stringify({ allowBuild: ALLOW_BUILD, videoAutorun: process.env.VIDEO_AUTORUN === '1' }));
if (p === '/api/ig-accounts') {
const fp = join(ROOT, 'data', 'ig-accounts.json');
if (req.method === 'POST') {
// upsert one account (id required); console-side edit of handle/status/label
const acc = await readBody(req);
if (!acc.id || !/^[a-z0-9-]+$/.test(acc.id)) return res.writeHead(400).end(JSON.stringify({ error: 'bad id' }));
const list = await readJson(fp, []);
const i = list.findIndex(a => a.id === acc.id);
if (i >= 0) list[i] = { ...list[i], ...acc }; else list.push({ status: 'pending-creds', ...acc });
await writeFile(fp, JSON.stringify(list, null, 2));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(list));
}
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await readJson(fp, [])));
}
if (p === '/api/reel-account' && req.method === 'POST') {
// set which IG account a reel will publish to: {file, accountId}
const { file, accountId } = await readBody(req);
const rp = join(ROOT, 'data', 'reels.json');
const reels = await readJson(rp, []);
const r = reels.find(x => x.file === file);
if (!r) return res.writeHead(404).end(JSON.stringify({ error: 'reel not found' }));
const accounts = await readJson(join(ROOT, 'data', 'ig-accounts.json'), []);
if (!accounts.some(a => a.id === accountId)) return res.writeHead(400).end(JSON.stringify({ error: 'unknown account' }));
r.igAccount = accountId;
await writeFile(rp, JSON.stringify(reels, null, 2));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, file, igAccount: accountId }));
}
if (p === '/api/status') return res.writeHead(200, { 'content-type': 'application/json' })
.end(JSON.stringify({ building, log: lastLog.slice(-4000) }));
if (p === '/api/refresh' && req.method === 'POST') return run('fetch-new-arrivals.mjs', res);
if (p === '/api/build' && req.method === 'POST') return run('build-reel.mjs', res);
if (p === '/api/publish' && req.method === 'POST') {
// Publish a reel to Instagram via scripts/publish-ig.mjs. The real post
// only fires if this server process has SOCIAL_LIVE_ARMED=1 — otherwise
// publish-ig.mjs does a safe DRY run. Result lands in reels.json; the
// console re-reads it when the job finishes (same poll as build/refresh).
const { file } = await readBody(req);
if (!file || !/^[\w.\-]+\.mp4$/.test(file)) return res.writeHead(400).end(JSON.stringify({ error: 'bad file' }));
return run('publish-ig.mjs', res, ['--file', file]);
}
// ── Video Studio: enqueue a video-generation job (reel / url→reel / newsletter→video).
// The job is QUEUED here; it is fulfilled by scripts/video-runner.mjs, which only
// AUTO-executes the skill (via headless claude) when the host is armed with
// VIDEO_AUTORUN=1 — otherwise the job waits for a live studio session to run it.
if (p === '/api/video/jobs') {
const fp = join(ROOT, 'data', 'video-jobs.json');
return res.writeHead(200, { 'content-type': 'application/json' }).end(existsSync(fp) ? await readFile(fp) : '[]');
}
if (p === '/api/video/queue' && req.method === 'POST') {
if (!ALLOW_BUILD) return res.writeHead(400, { 'content-type': 'application/json' })
.end(JSON.stringify({ error: 'Video jobs run on the studio host (Mac3).' }));
// Validate at the door — these strings feed a headless skill prompt when armed.
const built = buildVideoJob(await readBody(req));
if (built.error) return res.writeHead(built.status).end(JSON.stringify({ error: built.error }));
const q = await enqueueVideoJob(built.job);
if (q.error) return res.writeHead(q.status).end(JSON.stringify({ error: q.error }));
return res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ queued: q.job }));
}
// ── Suggestions ("hypergen"): the 4-a-day video ideas, dated + kept forever.
// GET → the full dated history (newest day first). Public/read-only deploys see this too.
if (p === '/api/video/suggestions') {
const fp = join(ROOT, 'data', 'video-suggestions.json');
const list = await readJson(fp, []);
list.sort((a, b) => (b.date + '#' + b.slot).localeCompare(a.date + '#' + a.slot));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(list));
}
// POST /api/video/suggest → run the daily generator now (fills today to 4). Gated like build.
if (p === '/api/video/suggest' && req.method === 'POST') {
if (!ALLOW_BUILD) return res.writeHead(400, { 'content-type': 'application/json' })
.end(JSON.stringify({ error: 'Suggestions are generated on the studio host.' }));
const b = await readBody(req).catch(() => ({}));
return run('suggest-videos.mjs', res, b && b.force ? ['--force'] : []);
}
// POST /api/video/suggestions/use → queue the render for a suggestion AND date-stamp it
// (used_at = WHEN, used_where = WHERE: the job it became). Body: { id }.
if (p === '/api/video/suggestions/use' && req.method === 'POST') {
if (!ALLOW_BUILD) return res.writeHead(400, { 'content-type': 'application/json' })
.end(JSON.stringify({ error: 'Video jobs run on the studio host.' }));
const { id } = await readBody(req);
const fp = join(ROOT, 'data', 'video-suggestions.json');
const list = await readJson(fp, []);
const s = list.find(x => x.id === id);
if (!s) return res.writeHead(404).end(JSON.stringify({ error: 'suggestion not found' }));
const built = buildVideoJob({ kind: s.kind, format: s.format, url: s.url });
if (built.error) return res.writeHead(built.status).end(JSON.stringify({ error: built.error }));
const q = await enqueueVideoJob(built.job);
if (q.error) return res.writeHead(q.status).end(JSON.stringify({ error: q.error }));
// stamp the suggestion: when + where it was used. Kept even if regenerated later.
s.used = true;
s.used_at = new Date().toISOString();
s.used_where = { jobId: q.job.id, kind: q.job.kind, format: q.job.format, surface: 'video-studio-queue' };
await writeFile(fp, JSON.stringify(list, null, 2));
return res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ queued: q.job, suggestion: s }));
}
// list every rendered mp4 (reels/ + videos/**/*.mp4 from the HyperFrames pipelines)
if (p === '/api/videos') {
const out = [];
async function walk(dir, rel) {
let ents; try { ents = await readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of ents) {
const full = join(dir, e.name), r = rel ? rel + '/' + e.name : e.name;
if (e.isDirectory()) { if (e.name !== 'node_modules') await walk(full, r); }
else if (e.name.toLowerCase().endsWith('.mp4')) {
const st = await stat(full);
out.push({ path: r, url: 'studio-media/' + r.split('/').map(encodeURIComponent).join('/'),
name: e.name, kb: Math.round(st.size / 1024), mtime: st.mtimeMs });
}
}
}
await walk(join(ROOT, 'videos'), 'videos');
await walk(join(ROOT, 'reels'), 'reels');
await walk(join(ROOT, 'output'), 'output'); // HyperFrames skill renders (url/newsletter) land here
out.sort((a, b) => b.mtime - a.mtime);
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(out));
}
// authed serve of any rendered mp4 under videos/ or reels/ (traversal-guarded)
if (p.startsWith('/studio-media/')) {
const rel = decodeURIComponent(p.slice('/studio-media/'.length));
let fp = normalize(join(ROOT, rel));
// realpathSync dereferences symlinks (normalize does NOT) — a .mp4 symlink
// pointing at server.js/secrets must not escape videos/ or reels/.
try { fp = realpathSync(fp); } catch { return res.writeHead(404).end('not found'); }
const okRoot = fp.startsWith(join(ROOT, 'videos') + sep) || fp.startsWith(join(ROOT, 'reels') + sep) || fp.startsWith(join(ROOT, 'output') + sep);
if (okRoot && extname(fp) === '.mp4' && existsSync(fp)) return await serveFile(fp, req, res);
return res.writeHead(404).end('not found');
}
if (p.startsWith('/reels/')) {
let fp = normalize(join(ROOT, 'reels', p.slice('/reels/'.length)));
try { fp = realpathSync(fp); } catch { fp = null; } // deref symlinks; this path is PUBLIC
if (fp && fp.startsWith(join(ROOT, 'reels') + sep) && existsSync(fp)) return await serveFile(fp, req, res);
}
if (p.startsWith('/public/')) {
const fp = normalize(join(ROOT, p));
if (fp.startsWith(join(ROOT, 'public')) && existsSync(fp)) return await serveFile(fp, req, res);
}
res.writeHead(404).end('not found');
} catch (e) {
res.writeHead(500).end('error: ' + e.message);
}
});
server.listen(PORT, () => console.log(`DW Marketing reels console → http://127.0.0.1:${PORT} (auth ${USER})`));