← 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 => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[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 };