← 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 };