← back to NationalPaperHangers

lib/social-embed.js

196 lines

// Social-video embed helper.
// Takes a URL and returns { platform, embedUrl, html } usable in an EJS view.
// Platforms supported: YouTube, Instagram (reels/posts), TikTok, Vimeo.
// Returns null if URL is unsupported or malformed — caller should fall through
// to a generic link card.

// Strict-domain check: host must equal the apex OR be a true subdomain.
// `.endsWith('youtube.com')` alone matches `evilyoutube.com` — we explicitly
// reject the no-leading-dot case. (claude-codex r3 finding HIGH#4)
function isHost(host, apex) {
  return host === apex || host.endsWith('.' + apex);
}

function parseYouTube(u) {
  try {
    const url = new URL(u);
    let id = null;
    if (isHost(url.hostname, 'youtube.com')) {
      if (url.pathname === '/watch') id = url.searchParams.get('v');
      else if (url.pathname.startsWith('/embed/')) id = url.pathname.split('/')[2];
      else if (url.pathname.startsWith('/shorts/')) id = url.pathname.split('/')[2];
    } else if (url.hostname === 'youtu.be') {
      id = url.pathname.replace(/^\//, '').split('/')[0];
    }
    if (!id || !/^[A-Za-z0-9_-]{6,20}$/.test(id)) return null;
    return id;
  } catch { return null; }
}

function parseInstagram(u) {
  try {
    const url = new URL(u);
    if (!isHost(url.hostname, 'instagram.com')) return null;
    const m = url.pathname.match(/^\/(p|reel|tv)\/([A-Za-z0-9_-]+)/);
    if (!m) return null;
    return { type: m[1], id: m[2] };
  } catch { return null; }
}

function parseTikTok(u) {
  try {
    const url = new URL(u);
    if (!isHost(url.hostname, 'tiktok.com')) return null;
    let m = url.pathname.match(/\/video\/(\d{10,25})/);
    if (m) return m[1];
    m = url.pathname.match(/\/v\/(\d{10,25})/);
    if (m) return m[1];
    return null;
  } catch { return null; }
}

function parseVimeo(u) {
  try {
    const url = new URL(u);
    if (!isHost(url.hostname, 'vimeo.com')) return null;
    const m = url.pathname.match(/^\/(\d+)/);
    return m ? m[1] : null;
  } catch { return null; }
}

// LinkedIn post URLs come in several flavors. Extract the activity/share/ugcPost
// numeric ID and render an embed iframe pointing at /embed/feed/update/<urn>.
// Patterns supported:
//   https://www.linkedin.com/posts/<user>_<slug>-activity-<19-digit-id>-<hash>/
//   https://www.linkedin.com/feed/update/urn:li:activity:<id>/
//   https://www.linkedin.com/feed/update/urn:li:share:<id>/
//   https://www.linkedin.com/feed/update/urn:li:ugcPost:<id>/
//   https://www.linkedin.com/embed/feed/update/urn:li:share:<id>  (already an embed url)
// Returns { type: 'activity'|'share'|'ugcPost', id: <string-of-digits> } or null.
function parseLinkedIn(u) {
  try {
    const url = new URL(u);
    if (!isHost(url.hostname, 'linkedin.com')) return null;
    const path = url.pathname;

    // /posts/<user>_<slug>-activity-<id>-<hash>/
    let m = path.match(/-activity-(\d{15,25})-/);
    if (m) return { type: 'activity', id: m[1] };

    // /feed/update/urn:li:<type>:<id>/  or  /embed/feed/update/urn:li:<type>:<id>
    m = path.match(/urn:li:(activity|share|ugcPost):(\d{10,25})/);
    if (m) return { type: m[1], id: m[2] };

    // Bare digit-only path component (rare)
    m = path.match(/\/(\d{15,25})(?:\/|$)/);
    if (m) return { type: 'share', id: m[1] };

    return null;
  } catch { return null; }
}

function escapeHtml(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  }[c]));
}

function embed(input) {
  const url = typeof input === 'string' ? input : input?.url;
  if (!url) return null;
  const title = (typeof input === 'object' && input.title) ? input.title : '';

  const yt = parseYouTube(url);
  if (yt) {
    return {
      platform: 'youtube',
      url,
      embedUrl: `https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1`,
      html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1" title="${escapeHtml(title) || 'Installation video'}" width="560" height="315" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
    };
  }

  const ig = parseInstagram(url);
  if (ig) {
    return {
      platform: 'instagram',
      url,
      embedUrl: `https://www.instagram.com/${ig.type}/${ig.id}/embed/`,
      html: `<iframe class="social-embed ig" src="https://www.instagram.com/${ig.type}/${ig.id}/embed/" title="${escapeHtml(title) || 'Instagram reel'}" width="320" height="568" loading="lazy" scrolling="no" allowtransparency="true" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
    };
  }

  const tt = parseTikTok(url);
  if (tt) {
    return {
      platform: 'tiktok',
      url,
      embedUrl: `https://www.tiktok.com/player/v1/${tt}`,
      html: `<iframe class="social-embed tt" src="https://www.tiktok.com/player/v1/${tt}" title="${escapeHtml(title) || 'TikTok'}" width="320" height="568" loading="lazy" allow="encrypted-media" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
    };
  }

  const vimeo = parseVimeo(url);
  if (vimeo) {
    return {
      platform: 'vimeo',
      url,
      embedUrl: `https://player.vimeo.com/video/${vimeo}`,
      html: `<iframe class="social-embed vimeo" src="https://player.vimeo.com/video/${vimeo}" title="${escapeHtml(title) || 'Installation video'}" width="560" height="315" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
    };
  }

  const li = parseLinkedIn(url);
  if (li) {
    const urn = `urn:li:${li.type}:${li.id}`;
    const embedUrl = `https://www.linkedin.com/embed/feed/update/${urn}`;
    return {
      platform: 'linkedin',
      url,
      embedUrl,
      html: `<iframe class="social-embed linkedin" src="${embedUrl}" title="${escapeHtml(title) || 'LinkedIn post'}" width="504" height="546" loading="lazy" frameborder="0" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
    };
  }

  return null;
}

// Helper for the curated_videos table: given a stored row {platform, youtube_id},
// build the iframe HTML directly without needing the original URL. Lets the
// /watch view render LinkedIn rows even though the column is named youtube_id.
function embedFromRow(row) {
  if (!row) return null;
  const id = row.youtube_id;
  if (!id) return null;
  const title = row.title || '';
  if (row.platform === 'linkedin') {
    // Stored ID is the bare numeric URN suffix; default urn type to 'share'
    // since that's what linkedin/embed accepts for both shares + activities.
    const urn = `urn:li:share:${id}`;
    return {
      platform: 'linkedin',
      embedUrl: `https://www.linkedin.com/embed/feed/update/${urn}`,
      html: `<iframe class="social-embed linkedin" src="https://www.linkedin.com/embed/feed/update/${urn}" title="${escapeHtml(title)}" loading="lazy" frameborder="0" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
    };
  }
  // Default: YouTube
  return {
    platform: 'youtube',
    embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1`,
    html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1" title="${escapeHtml(title)}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
  };
}

function embedAll(arrayOrJsonb) {
  let arr = arrayOrJsonb;
  if (typeof arr === 'string') {
    try { arr = JSON.parse(arr); } catch { arr = []; }
  }
  if (!Array.isArray(arr)) return [];
  // Cap to 12 — protects against a hostile/buggy admin payload rendering
  // dozens of iframes inline. (claude-codex r3 finding MEDIUM#8)
  return arr.slice(0, 12).map(embed).filter(Boolean);
}

module.exports = { embed, embedAll, embedFromRow };