[object Object]

← back to Dw Marketing Reels

video-studio hardening (contrarian FIX-FIRST): realpathSync kills symlink escape on /studio-media + public /reels/; https-URL/.eml validation + 20-job cap on queue; runner requires CLAUDE_BIN & fails loud (no silent ENOENT); job chips show created-time + host-not-armed warning

d648cd2142134c55dd936af581e0eabcafedcd14 · 2026-07-17 08:08:28 -0700 · Steve Abrams

Files touched

Diff

commit d648cd2142134c55dd936af581e0eabcafedcd14
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 17 08:08:28 2026 -0700

    video-studio hardening (contrarian FIX-FIRST): realpathSync kills symlink escape on /studio-media + public /reels/; https-URL/.eml validation + 20-job cap on queue; runner requires CLAUDE_BIN & fails loud (no silent ENOENT); job chips show created-time + host-not-armed warning
---
 public/index.html        |  7 +++++--
 scripts/video-runner.mjs |  9 ++++++++-
 server.js                | 34 +++++++++++++++++++++++++---------
 3 files changed, 38 insertions(+), 12 deletions(-)

diff --git a/public/index.html b/public/index.html
index 4633ab3..ee5d423 100644
--- a/public/index.html
+++ b/public/index.html
@@ -228,7 +228,7 @@ document.querySelectorAll('.overlay').forEach(ov => {
 document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.overlay.open').forEach(closeModal); });
 
 // ── instagram accounts ──
-let ACCTS = [], REELS = [];
+let ACCTS = [], REELS = [], VAUTO = false;
 async function loadAccounts() {
   ACCTS = await (await fetch('api/ig-accounts')).json();
   $('#accts').innerHTML = ACCTS.map(a => `
@@ -364,8 +364,10 @@ async function loadJobs() {
     const what = j.kind === 'new-arrivals-reel' ? 'New Arrivals reel'
       : j.kind === 'url-reel' ? ('URL → ' + (j.url || '').slice(0, 44))
       : ('Newsletter → ' + (j.eml || '').slice(0, 44));
-    return `<span class="badge ${cls}">${j.status === 'running' ? '⚙' : '◷'} ${escapeHtml(what)} · ${j.format}</span>`;
+    return `<span class="badge ${cls}" title="queued ${escapeAttr(j.created_at)}">${j.status === 'running' ? '⚙' : '◷'} ${escapeHtml(what)} · ${j.format} · 🕓 ${fmt(j.created_at)}</span>`;
   }).join('');
+  if (active.length && !VAUTO) $('#jobs').insertAdjacentHTML('afterbegin',
+    '<span class="badge err" title="Set VIDEO_AUTORUN=1 on the studio host to auto-render">⚠ host not armed — queued jobs wait for a studio session</span>');
   $('#jobsInfo').textContent = active.length ? `(${active.length} queued / running)` : '';
   if (active.length) setTimeout(() => { loadJobs(); loadVideos(); }, 4000);
 }
@@ -447,6 +449,7 @@ $('#btnBuild').addEventListener('click', ()=>job('api/build','▶ generating ree
 (async () => {
   try {
     const cfg = await (await fetch('api/config')).json();
+    VAUTO = !!cfg.videoAutorun; loadJobs();
     if (!cfg.allowBuild) {
       ['#btnBuild','#btnRefresh','#btnPublish','#btnStudioReel','#btnStudioUrl','#btnStudioEml']
         .forEach(s => { const el = $(s); if (el) el.style.display = 'none'; });
diff --git a/scripts/video-runner.mjs b/scripts/video-runner.mjs
index 92dc920..6ffda24 100644
--- a/scripts/video-runner.mjs
+++ b/scripts/video-runner.mjs
@@ -39,13 +39,20 @@ const prompt = (j) => {
     console.log(`video-runner: ${queued.length} job(s) queued, waiting for a live studio session (VIDEO_AUTORUN not set).`);
     return; // GATE: never auto-runs claude unarmed.
   }
+  // `claude` is a shell function on this host, NOT a binary — spawn() can't run it.
+  // Require an explicit binary path and fail LOUD (not a silent detached ENOENT).
+  const CLAUDE_BIN = process.env.CLAUDE_BIN || '';
+  if (!CLAUDE_BIN || !existsSync(CLAUDE_BIN)) {
+    console.error('video-runner: VIDEO_AUTORUN=1 but CLAUDE_BIN is unset / not a real file. Set CLAUDE_BIN=/abs/path/to/claude to enable autonomous rendering; jobs left queued.');
+    process.exit(1);
+  }
   for (const j of queued) {
     const pr = prompt(j); if (!pr) { j.status = 'error'; j.note = 'unknown kind'; continue; }
     j.status = 'running'; j.started_at = new Date().toISOString();
     await writeFile(JOBS, JSON.stringify(jobs, null, 2));
     try {
       await new Promise((resolve, reject) => {
-        const ch = spawn('claude', ['-p', pr], { cwd: ROOT, stdio: 'inherit' });
+        const ch = spawn(CLAUDE_BIN, ['-p', pr], { cwd: ROOT, stdio: 'inherit' });
         ch.on('close', code => code === 0 ? resolve() : reject(new Error('claude exit ' + code)));
         ch.on('error', reject);
       });
diff --git a/server.js b/server.js
index 524b535..1c0fa03 100644
--- a/server.js
+++ b/server.js
@@ -5,10 +5,10 @@
 // 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 } from 'node:fs';
+import { createReadStream, existsSync, realpathSync } from 'node:fs';
 import { spawn } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
-import { dirname, join, extname, normalize } from 'node:path';
+import { dirname, join, extname, normalize, sep } from 'node:path';
 
 const ROOT = dirname(fileURLToPath(import.meta.url));
 const PORT = Number(process.env.PORT || 9848);
@@ -99,7 +99,7 @@ const server = http.createServer(async (req, res) => {
       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 }));
+      .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') {
@@ -155,10 +155,22 @@ const server = http.createServer(async (req, res) => {
       const kind = String(b.kind || '').trim();
       const KINDS = ['new-arrivals-reel', 'url-reel', 'newsletter-video'];
       if (!KINDS.includes(kind)) return res.writeHead(400).end(JSON.stringify({ error: 'unknown kind' }));
+      // Validate at the door — these strings feed a headless skill prompt when armed.
+      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 res.writeHead(400).end(JSON.stringify({ error: 'url must be a valid https:// URL' })); }
+      }
+      if (kind === 'newsletter-video') {
+        eml = String(b.eml || '').trim();
+        if (eml.includes('..') || !/^(~|\/)[\w .\/-]{1,380}\.eml$/.test(eml))
+          return res.writeHead(400).end(JSON.stringify({ error: 'eml must be an absolute or ~ path ending in .eml' }));
+      }
       const fp = join(ROOT, 'data', 'video-jobs.json');
       const jobs = await readJson(fp, []);
-      const job = { id: 'vj-' + Date.now().toString(36), kind,
-        url: String(b.url || '').slice(0, 400), eml: String(b.eml || '').slice(0, 400),
+      if (jobs.filter(j => j.status === 'queued' || j.status === 'running').length >= 20)
+        return res.writeHead(429).end(JSON.stringify({ error: 'too many pending jobs — clear the queue first' }));
+      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() };
       jobs.unshift(job);
       await writeFile(fp, JSON.stringify(jobs, null, 2));
@@ -192,14 +204,18 @@ const server = http.createServer(async (req, res) => {
     // 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));
-      const fp = normalize(join(ROOT, rel));
-      const okRoot = fp.startsWith(join(ROOT, 'videos')) || fp.startsWith(join(ROOT, 'reels'));
+      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);
       if (okRoot && extname(fp) === '.mp4' && existsSync(fp)) return await serveFile(fp, req, res);
       return res.writeHead(404).end('not found');
     }
     if (p.startsWith('/reels/')) {
-      const fp = normalize(join(ROOT, 'reels', p.slice('/reels/'.length)));
-      if (fp.startsWith(join(ROOT, 'reels')) && existsSync(fp)) return await serveFile(fp, req, res);
+      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));

← f2f4363 New Arrivals reel: 7 curated heroes + local Kokoro VO + Musi  ·  back to Dw Marketing Reels  ·  DW New Arrivals collection-reveal reel (9:16, product-launch f0a2257 →