← back to Marketing Command Center
fix(mcc): durable local cache for expiring IG/fbcdn post images + graceful placeholder
2ffb36fbd42dc7595067219bc25b65d53cd28378 · 2026-07-22 10:42:05 -0700 · Steve Abrams
Board post thumbnails embed raw Instagram/fbcdn CDN URLs whose _nc_ohc/oh/oe
signature is time-boxed; once it expires the URL 403s for everyone (verified:
even a bare server-side fetch 403s), so the <img> failed and the old
onerror hid it -> blank tile ("posts all blank").
- lib/media-cache.js: localize() maps an IG/fbcdn URL to /cache/media/<sha1(path)>.<ext>,
serving a cached local copy if present and background-downloading (best-effort,
6s timeout, SSRF host-allowlist, atomic write) any missing one so the next load
self-heals. Cache key is the media PATH (not the rotating signed query) so one
media = one file. Never blocks the response.
- channels /api/outbox: localizeItems() the outbox before serving.
- board.js: on img error, swap to an inline branded "image unavailable" SVG tile
instead of hiding — a dead/uncached image now reads as intentional, not blank.
- .gitignore: public/cache/ (locally-cached media, not source).
Verified end-to-end: outbox GET returns the localized /cache/media path and the
file serves 200; download pipeline writes valid image bytes. Deploy held (gated).
Note: images whose URL already expired before caching can't be recovered here —
fully restoring those needs a re-pull of fresh media from the IG Graph API (follow-up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA lib/media-cache.jsM modules/channels/index.jsM public/panels/board.js
Diff
commit 2ffb36fbd42dc7595067219bc25b65d53cd28378
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 10:42:05 2026 -0700
fix(mcc): durable local cache for expiring IG/fbcdn post images + graceful placeholder
Board post thumbnails embed raw Instagram/fbcdn CDN URLs whose _nc_ohc/oh/oe
signature is time-boxed; once it expires the URL 403s for everyone (verified:
even a bare server-side fetch 403s), so the <img> failed and the old
onerror hid it -> blank tile ("posts all blank").
- lib/media-cache.js: localize() maps an IG/fbcdn URL to /cache/media/<sha1(path)>.<ext>,
serving a cached local copy if present and background-downloading (best-effort,
6s timeout, SSRF host-allowlist, atomic write) any missing one so the next load
self-heals. Cache key is the media PATH (not the rotating signed query) so one
media = one file. Never blocks the response.
- channels /api/outbox: localizeItems() the outbox before serving.
- board.js: on img error, swap to an inline branded "image unavailable" SVG tile
instead of hiding — a dead/uncached image now reads as intentional, not blank.
- .gitignore: public/cache/ (locally-cached media, not source).
Verified end-to-end: outbox GET returns the localized /cache/media path and the
file serves 200; download pipeline writes valid image bytes. Deploy held (gated).
Note: images whose URL already expired before caching can't be recovered here —
fully restoring those needs a re-pull of fresh media from the IG Graph API (follow-up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 3 ++
lib/media-cache.js | 81 +++++++++++++++++++++++++++++++++++++++++++++++
modules/channels/index.js | 6 +++-
public/panels/board.js | 15 ++++++++-
4 files changed, 103 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index 3bd15f3..f75b75b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,6 @@ data/engine-config.json
# screen-record debug artifacts (videos are large binaries; keep log+report only)
screenrecord/rec/
*.webm
+
+# locally-cached remote post media (durable copies of expiring IG/fbcdn urls)
+public/cache/
diff --git a/lib/media-cache.js b/lib/media-cache.js
new file mode 100644
index 0000000..3660fbd
--- /dev/null
+++ b/lib/media-cache.js
@@ -0,0 +1,81 @@
+// media-cache — durable local copies of expiring remote post images.
+//
+// WHY: Instagram / Facebook CDN media URLs (scontent-*.cdninstagram.com,
+// *.fbcdn.net) are SIGNED and short-lived — the _nc_ohc / oh / oe query params
+// are a time-boxed signature. Once they expire the URL returns 403 for EVERYONE
+// (verified: even a bare server-side fetch 403s), so a live-refetch proxy can't
+// help. The only durable fix is to capture the bytes while the URL is still
+// fresh and serve a local copy forever after. Board/social cards store the raw
+// CDN URL, so as posts age their thumbnails 403 and render blank ("posts all
+// blank"). This module localizes those URLs to /cache/media/<key>.<ext>.
+//
+// Design: swap to the local path SYNCHRONOUSLY when already cached (fast, no
+// network on the request path); for a not-yet-cached URL, kick off a background
+// best-effort download and leave the original URL for this response — the next
+// load serves the cached copy. Self-healing, never blocks the endpoint.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const CACHE_DIR = path.join(__dirname, '..', 'public', 'cache', 'media');
+const PUBLIC_PREFIX = '/cache/media';
+// Only these hosts are ever fetched server-side (SSRF guard — mediaUrl is
+// DW-authored but we still refuse to fetch arbitrary/internal hosts).
+const ALLOW_HOST = /(^|\.)(cdninstagram\.com|fbcdn\.net)$/i;
+const inflight = new Set(); // keys currently downloading, so we don't double-fetch
+
+function keyFor(u) {
+ let pathname;
+ try { pathname = new URL(u).pathname; } catch { pathname = u; }
+ const ext = (pathname.match(/\.(jpg|jpeg|png|webp|gif)$/i) || [, 'jpg'])[1].toLowerCase();
+ // Key off the PATH only (not the signed query) so the same media maps to one
+ // cache file even as IG rotates the signature params.
+ return crypto.createHash('sha1').update(pathname).digest('hex') + '.' + ext;
+}
+
+function isRemote(u) { return typeof u === 'string' && /^https?:\/\//i.test(u); }
+function localPathFor(key) { return path.join(CACHE_DIR, key); }
+function cachedUrlIfPresent(key) {
+ try { return fs.existsSync(localPathFor(key)) ? `${PUBLIC_PREFIX}/${key}` : null; } catch { return null; }
+}
+
+async function download(u, key) {
+ if (inflight.has(key)) return;
+ inflight.add(key);
+ try {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 6000);
+ const r = await fetch(u, { signal: ctrl.signal, headers: { 'User-Agent': 'Mozilla/5.0 MCP-media-cache' } }).finally(() => clearTimeout(t));
+ if (!r.ok) return; // expired/blocked — nothing we can do; leave original URL
+ const ct = r.headers.get('content-type') || '';
+ if (!/^image\//i.test(ct)) return;
+ const buf = Buffer.from(await r.arrayBuffer());
+ if (buf.length < 64) return; // guard against IG's 21-byte 403 body slipping through
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
+ fs.writeFileSync(localPathFor(key) + '.tmp', buf);
+ fs.renameSync(localPathFor(key) + '.tmp', localPathFor(key)); // atomic publish
+ } catch { /* best-effort */ } finally { inflight.delete(key); }
+}
+
+// Return a local URL if cached; else trigger a background fetch and return the
+// original (so this response is instant and later ones self-heal).
+function localize(u) {
+ if (!isRemote(u)) return u;
+ let host; try { host = new URL(u).host; } catch { return u; }
+ if (!ALLOW_HOST.test(host)) return u;
+ const key = keyFor(u);
+ const cached = cachedUrlIfPresent(key);
+ if (cached) return cached;
+ download(u, key); // fire-and-forget
+ return u;
+}
+
+// Mutate an array of card items in place, localizing a media field on each.
+function localizeItems(items, field = 'mediaUrl') {
+ if (!Array.isArray(items)) return items;
+ for (const it of items) { if (it && it[field]) it[field] = localize(it[field]); }
+ return items;
+}
+
+module.exports = { localize, localizeItems, CACHE_DIR, PUBLIC_PREFIX };
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 097c969..cbbebdd 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -9,6 +9,7 @@
// is a Steve-authorized, per-platform step done on each developer portal.
const fs = require('fs');
const path = require('path');
+const mediaCache = require('../../lib/media-cache');
const OUTBOX = path.join(__dirname, '..', '..', 'data', 'channels-outbox.json');
const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8')); } catch { return []; } };
@@ -688,7 +689,10 @@ module.exports = {
const connected = Object.values(st).filter(p => p.connected).length;
res.json({ platforms: st, connectedCount: connected, total: Object.keys(st).length, outbox: readOutbox().length });
});
- router.get('/outbox', (_req, res) => res.json({ items: readOutbox().slice(-100).reverse() }));
+ // Localize expiring IG/fbcdn media to durable local copies before serving
+ // (see lib/media-cache): cached→local path synchronously, background-fetch
+ // any missing so the next load self-heals. Never blocks the response.
+ router.get('/outbox', (_req, res) => res.json({ items: mediaCache.localizeItems(readOutbox().slice(-100).reverse()) }));
// Clear outbox cards. Body: { scope } — 'staged' (default) drops everything that
// never went live (staged / pending-connection / failed), keeping real 'posted'
// history; 'all' wipes everything; { channel } limits it to one channel. Lets the
diff --git a/public/panels/board.js b/public/panels/board.js
index 90f03ae..e578bb9 100644
--- a/public/panels/board.js
+++ b/public/panels/board.js
@@ -1,4 +1,17 @@
window.MCC_PANELS = window.MCC_PANELS || {};
+// Branded "media unavailable" tile shown when a post image fails to load (e.g. an
+// expired IG/fbcdn signed URL that 403s). Inline data-URI so it needs no request;
+// URL-encoded so it can drop straight into an <img src>. Reads as intentional,
+// not blank. The durable fix is lib/media-cache (localizes fresh URLs); this is
+// the graceful fallback for images that were already dead before caching.
+const PLACEHOLDER_SVG = encodeURIComponent(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="220" viewBox="0 0 400 220">` +
+ `<rect width="400" height="220" fill="#f4f1ea"/>` +
+ `<rect x="0" y="0" width="400" height="220" fill="url(#g)" opacity="0.5"/>` +
+ `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#efe9dc"/><stop offset="1" stop-color="#e2dccb"/></linearGradient></defs>` +
+ `<g fill="none" stroke="#b8ad93" stroke-width="2"><rect x="163" y="86" width="74" height="52" rx="6"/><circle cx="200" cy="112" r="13"/><path d="M175 86l7-10h36l7 10"/></g>` +
+ `<text x="200" y="170" font-family="Georgia,serif" font-size="13" fill="#9a8f76" text-anchor="middle">image unavailable</text></svg>`
+);
window.MCC_PANELS['board'] = {
async init(root) {
const ORIGIN = location.origin;
@@ -63,7 +76,7 @@ window.MCC_PANELS['board'] = {
return `<div class="bd-card">
${w ? `<div class="when" title="${esc(when)}">🕓 ${esc(w)}</div>` : ''}
<div class="txt">${esc((text || '').slice(0, 220)) || '<span class="bd-metric">(no text)</span>'}</div>
- ${media ? `<img class="media" src="${esc(media)}" loading="lazy" onerror="this.style.display='none'">` : ''}
+ ${media ? `<img class="media" src="${esc(media)}" loading="lazy" onerror="this.onerror=null;this.classList.add('media-missing');this.src='data:image/svg+xml;utf8,${PLACEHOLDER_SVG}'">` : ''}
<div class="foot">
${metric ? `<span class="bd-metric">${esc(metric)}</span>` : '<span></span>'}
${status ? `<span class="bd-status ${statusClass(status)}">${esc(status)}</span>` : ''}
← 243d9c5 fix(mcc): guard panel-router race that blanked panels + untr
·
back to Marketing Command Center
·
feat(mcc): IG Graph API re-pull to restore durable thumbnail 971082a →