[object Object]

← 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

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 →