import { Plugin } from 'prosemirror-state'; export interface EmbedTarget { src: string; provider: 'youtube' | 'vimeo' | 'loom' | 'generic'; } /** * Sandbox tokens applied to every embed iframe. Notably this OMITS * `allow-top-navigation`, so a framed page cannot redirect the whole tab * (the main clickjacking/phishing vector); scripts and same-origin are * allowed so third-party players still run. Kept in sync with the sanitizer. */ export const EMBED_SANDBOX = 'allow-scripts allow-same-origin allow-presentation allow-popups'; /** Limits the referrer leaked to the embedded third-party host. */ export const EMBED_REFERRER_POLICY = 'strict-origin-when-cross-origin'; /** * Converts a URL into an embed target. Known providers (YouTube/Vimeo/Loom) * are canonicalized to their dedicated embed URLs; any other `https://` URL * is accepted as a `generic` embed. The iframe is always sandboxed on render * (see EMBED_SANDBOX), so untrusted hosts cannot navigate the top window. * Returns null only for non-https or unparseable URLs. */ export function toEmbedUrl(url: string): EmbedTarget | null { let parsed: URL; try { parsed = new URL(url.trim()); } catch { return null; } if (parsed.protocol !== 'https:') return null; const host = parsed.hostname.replace(/^www\./, ''); // YouTube: watch?v=ID, youtu.be/ID, shorts/ID, embed/ID if (host === 'youtube.com' || host === 'youtube-nocookie.com' || host === 'youtu.be') { let id = ''; if (host === 'youtu.be') id = parsed.pathname.slice(1).split('/')[0]; else if (parsed.pathname === '/watch') id = parsed.searchParams.get('v') ?? ''; else { const match = /^\/(embed|shorts)\/([\w-]+)/.exec(parsed.pathname); if (match) id = match[2]; } if (/^[\w-]{6,20}$/.test(id)) { return { src: `https://www.youtube-nocookie.com/embed/${id}`, provider: 'youtube' }; } } // Vimeo: vimeo.com/123456789, player.vimeo.com/video/123456789 else if (host === 'vimeo.com' || host === 'player.vimeo.com') { const match = /\/(?:video\/)?(\d{6,12})(?:$|[/?])/.exec(parsed.pathname); if (match) { return { src: `https://player.vimeo.com/video/${match[1]}`, provider: 'vimeo' }; } } // Loom: loom.com/share/ID or loom.com/embed/ID else if (host === 'loom.com') { const match = /^\/(share|embed)\/([0-9a-f]{16,40})/.exec(parsed.pathname); if (match) { return { src: `https://www.loom.com/embed/${match[2]}`, provider: 'loom' }; } } // Any other https URL: embed as-is, sandboxed. return { src: parsed.href, provider: 'generic' }; } /** * True when a URL is permitted as an embed src (sanitizer check). Any * `https://` URL is allowed; the iframe is sandboxed at render time. */ export function isAllowedEmbedSrc(src: string): boolean { try { return new URL(src.trim()).protocol === 'https:'; } catch { return false; } } /** * Pasting a bare provider URL onto an empty selection inserts an embed * instead of plain text. (Pasting over selected text still creates a link.) */ export function embedPastePlugin(): Plugin { return new Plugin({ props: { handlePaste(view, event) { const { state } = view; if (!state.selection.empty) return false; const text = event.clipboardData?.getData('text/plain')?.trim(); if (!text || /\s/.test(text)) return false; const target = toEmbedUrl(text); if (!target) return false; const embed = state.schema.nodes.embed.create({ src: target.src, provider: target.provider, }); view.dispatch(state.tr.replaceSelectionWith(embed).scrollIntoView()); return true; }, }, }); }