← back to Marketing Command Center
lib/media-cache.js
107 lines
// 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; }
}
// Fetch + validate an image URL. Returns a Buffer or null (expired/blocked/non-image).
async function fetchImageBuffer(u) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 8000);
try {
const r = await fetch(u, { signal: ctrl.signal, headers: { 'User-Agent': 'Mozilla/5.0 MCP-media-cache' } });
if (!r.ok) return null; // expired/blocked — nothing we can do
const ct = r.headers.get('content-type') || '';
if (!/^image\//i.test(ct)) return null;
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length < 64) return null; // guard against IG's 21-byte 403 body slipping through
return buf;
} catch { return null; } finally { clearTimeout(t); }
}
function writeAtomic(key, buf) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
fs.writeFileSync(localPathFor(key) + '.tmp', buf);
fs.renameSync(localPathFor(key) + '.tmp', localPathFor(key)); // atomic publish
}
async function download(u, key) {
if (inflight.has(key)) return;
inflight.add(key);
try { const buf = await fetchImageBuffer(u); if (buf) writeAtomic(key, buf); }
finally { inflight.delete(key); }
}
// Explicit-key cache: download `u` and store under `<keyBase>.<ext>` (ext inferred
// from the URL, default jpg). Used by the IG Graph re-pull, which keys by the
// STABLE IG media id so a durable copy survives future URL-signature rotation.
// Returns the public /cache/media path on success, or null (URL dead/blocked).
async function cacheAs(u, keyBase) {
if (!isRemote(u)) return isLocalCacheUrl(u) ? u : null;
const ext = ((() => { try { return new URL(u).pathname; } catch { return u; } })().match(/\.(jpg|jpeg|png|webp|gif)$/i) || [, 'jpg'])[1].toLowerCase();
const key = String(keyBase).replace(/[^a-z0-9_-]/gi, '') + '.' + ext;
const buf = await fetchImageBuffer(u);
if (!buf) return null;
writeAtomic(key, buf);
return `${PUBLIC_PREFIX}/${key}`;
}
function isLocalCacheUrl(u) { return typeof u === 'string' && u.startsWith(PUBLIC_PREFIX + '/'); }
// 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, cacheAs, CACHE_DIR, PUBLIC_PREFIX };