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