← 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
M public/index.htmlM scripts/video-runner.mjsM server.js
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 →