← back to New Import Viewer
harden: noimage boot-trap defused (persisted diagnostic filter falls back to netnew) + /img local thumbnail proxy with disk cache + SSRF guard — grid no longer depends on 100+ vendor hosts hotlink policy
01963b96b6d9bd83dc86c4ebabcaef3939250f67 · 2026-06-10 07:11:35 -0700 · SteveStudio2
Files touched
M .gitignoreM public/index.htmlM server.js
Diff
commit 01963b96b6d9bd83dc86c4ebabcaef3939250f67
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed Jun 10 07:11:35 2026 -0700
harden: noimage boot-trap defused (persisted diagnostic filter falls back to netnew) + /img local thumbnail proxy with disk cache + SSRF guard — grid no longer depends on 100+ vendor hosts hotlink policy
---
.gitignore | 1 +
public/index.html | 11 +++++++++--
server.js | 39 +++++++++++++++++++++++++++++++++++++++
3 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index 1924158..b3d6efa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ tmp/
dist/
build/
.next/
+data/imgcache/
diff --git a/public/index.html b/public/index.html
index bb6fbf7..c54ead0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -266,7 +266,14 @@ function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+'
function togglePick(id,cb){if(cb.checked)sel.add(id);else sel.delete(id);cb.closest('.card').classList.toggle('sel',cb.checked);syncSel();}
function togglePane(el,which){el.closest('.card').classList.toggle(which==='info'?'info-open':'acts-open');}
// persisted prefs (CLAUDE.md: sort + density persist in localStorage)
-$('#status').value=localStorage.getItem('nip.status')||'netnew';
+// Boot status: restore the saved filter EXCEPT the 🚫 No image diagnostic view —
+// booting into it renders a wall of placeholder tiles that reads as "all images
+// broken" (the 2026-06-10 'why do no images show' trap). noimage stays click-only
+// per visit. Also fall back when the saved value is no longer a real option.
+{const sv=localStorage.getItem('nip.status');
+ const okSv=sv&&sv!=='noimage'&&!!document.querySelector(`#status option[value="${sv}"]`);
+ $('#status').value=okSv?sv:'netnew';
+ if(!okSv)localStorage.setItem('nip.status',$('#status').value);}
$('#sort').value=localStorage.getItem('nip.sort')||'newest';
const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
document.documentElement.style.setProperty('--cols',dens);
@@ -288,7 +295,7 @@ function actionsFor(st){
}
function card(p){
const title=esc(p.pattern||p.sku||'(untitled)')+(p.color?(', '+esc(p.color)):'');
- const img=p.image?`<div class="imgwrap"><img loading="lazy" src="${esc(p.image)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
+ const img=p.image?`<div class="imgwrap"><img loading="lazy" src="/img?u=${encodeURIComponent(p.image)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
:`<div class="imgwrap noimg"></div>`;
const checked=sel.has(p.id)?'checked':'';
const st=p.shopStatus||'NEW';
diff --git a/server.js b/server.js
index 7e443cc..030bbeb 100644
--- a/server.js
+++ b/server.js
@@ -162,12 +162,51 @@ function stageAction({ ids, action, mode }) {
return { ok: true, staged: rows.length, action, mode, batchId, byVendor, live: false };
}
+// ── Local thumbnail proxy + disk cache ─────────────────────────────────────
+// Grid images were direct hotlinks to 100+ vendor hosts — one referrer block,
+// content-blocker, Safari tracking-prevention, or vendor change away from a
+// blank grid. /img?u=<vendor URL> fetches server-side once and caches to disk
+// (data/imgcache/<sha1>), so the browser only ever talks to 127.0.0.1 and each
+// remote image is fetched a single time. Lazy-loading means only viewed images
+// are cached. SSRF-guarded: http(s) only, no localhost / raw-IP / .local hosts.
+const crypto = require('crypto');
+const IMG_CACHE = path.join(__dirname, 'data', 'imgcache');
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
+async function serveImg(u0, res) {
+ let t;
+ try { t = new URL(u0); } catch { return send(res, 400, JSON.stringify({ error: 'bad url' })); }
+ const h = t.hostname;
+ if (!/^https?:$/.test(t.protocol) || h === 'localhost' || h.endsWith('.local')
+ || /^\d+\.\d+\.\d+\.\d+$/.test(h) || h.includes(':'))
+ return send(res, 400, JSON.stringify({ error: 'host not allowed' }));
+ const key = crypto.createHash('sha1').update(u0).digest('hex');
+ const f = path.join(IMG_CACHE, key), fct = f + '.ct';
+ try {
+ const body = fs.readFileSync(f);
+ let ct = 'image/jpeg'; try { ct = fs.readFileSync(fct, 'utf8'); } catch {}
+ res.writeHead(200, { 'content-type': ct, 'cache-control': 'public, max-age=604800', 'x-img-cache': 'hit' });
+ return res.end(body);
+ } catch { /* cache miss → fetch */ }
+ try {
+ const r = await fetch(t, { headers: { 'user-agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(15000) });
+ if (!r.ok) return send(res, 502, JSON.stringify({ error: 'upstream ' + r.status }));
+ const ct = (r.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
+ if (!ct.startsWith('image/')) return send(res, 502, JSON.stringify({ error: 'not an image: ' + ct }));
+ const buf = Buffer.from(await r.arrayBuffer());
+ if (buf.length > (12 << 20)) return send(res, 502, JSON.stringify({ error: 'too large' }));
+ try { fs.mkdirSync(IMG_CACHE, { recursive: true }); fs.writeFileSync(f, buf); fs.writeFileSync(fct, ct); } catch {}
+ res.writeHead(200, { 'content-type': ct, 'cache-control': 'public, max-age=604800', 'x-img-cache': 'miss' });
+ return res.end(buf);
+ } catch (e) { return send(res, 502, JSON.stringify({ error: 'fetch failed: ' + (e && e.message || e) })); }
+}
+
const server = http.createServer((req, res) => {
try {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/' || u.pathname === '/index.html') {
return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
}
+ if (u.pathname === '/img') { serveImg(u.searchParams.get('u') || '', res); return; }
if (u.pathname === '/api/stats') {
// Whole-catalog status breakdown for the header pills (vendor/term ignored here).
const r = q(`SELECT
← cd99729 stage net-new for ALL vendors (101 vendors, 111,234 skus) to
·
back to New Import Viewer
·
HARD GATE: all products must have images (Steve 2026-06-10) 4fb4e88 →