diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index 71c75cc23a..917ca0a7af 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -56,7 +56,7 @@ const RE_REDDIT = const RE_REDDIT_EMBED = /^
{ +const parseYouTubeLikeTimestamp = (url: string): number => { let timeParam: string | null | undefined; try { @@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => { return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); }; +const parseGoogleDriveVideoLink = ( + url: string, +): { fileId: string; resourceKey?: string; timestamp?: number } | null => { + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + const hostname = urlObj.hostname.replace(/^www\./, ""); + if (hostname !== "drive.google.com") { + return null; + } + + let fileId: string | null = null; + const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/); + if (pathMatch?.[1]) { + fileId = pathMatch[1]; + } else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") { + // Shared Drive links can be emitted as: + // - /open?id=(common "open in Drive" format) + // - /uc?...&id= (download/export endpoint often seen in copied links) + fileId = urlObj.searchParams.get("id"); + } + + if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) { + return null; + } + + // Some Drive share links include `resourcekey` for access to link-shared + // files; preserve it in the preview URL so embeds keep working. + const resourceKey = urlObj.searchParams.get("resourcekey"); + const timestamp = parseYouTubeLikeTimestamp(urlObj.toString()); + + return { + fileId, + resourceKey: + resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey) + ? resourceKey + : undefined, + // Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`); + // normalize to seconds for a stable preview URL. + timestamp: timestamp > 0 ? timestamp : undefined, + }; + } catch (error) { + return null; + } +}; + const ALLOWED_DOMAINS = new Set([ "youtube.com", "youtu.be", "vimeo.com", "player.vimeo.com", + "drive.google.com", "figma.com", "link.excalidraw.com", "gist.github.com", @@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([ "youtu.be", "vimeo.com", "player.vimeo.com", + "drive.google.com", "figma.com", "twitter.com", "x.com", @@ -142,7 +189,7 @@ export const getEmbedLink = ( let aspectRatio = { w: 560, h: 840 }; const ytLink = link.match(RE_YOUTUBE); if (ytLink?.[2]) { - const startTime = parseYouTubeTimestamp(originalLink); + const startTime = parseYouTubeLikeTimestamp(originalLink); const time = startTime > 0 ? `&start=${startTime}` : ``; const isPortrait = link.includes("shorts"); type = "video"; @@ -201,6 +248,36 @@ export const getEmbedLink = ( }; } + const googleDriveVideo = parseGoogleDriveVideoLink(link); + if (googleDriveVideo) { + type = "video"; + const searchParams = new URLSearchParams(); + if (googleDriveVideo.resourceKey) { + searchParams.set("resourcekey", googleDriveVideo.resourceKey); + } + if (googleDriveVideo.timestamp) { + searchParams.set("t", `${googleDriveVideo.timestamp}`); + } + + const search = searchParams.toString(); + link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${ + search ? `?${search}` : "" + }`; + aspectRatio = { w: 560, h: 315 }; + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; + } + const figmaLink = link.match(RE_FIGMA); if (figmaLink) { type = "generic"; diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts index 7f585e866f..35870a86f2 100644 --- a/packages/element/tests/embeddable.test.ts +++ b/packages/element/tests/embeddable.test.ts @@ -1,4 +1,4 @@ -import { getEmbedLink } from "../src/embeddable"; +import { embeddableURLValidator, getEmbedLink } from "../src/embeddable"; describe("YouTube timestamp parsing", () => { it("should parse YouTube URLs with timestamp in seconds", () => { @@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => { } }); }); + +describe("Google Drive video embedding", () => { + it.each([ + { + url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + { + url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + { + url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + ])("should normalize Google Drive link: $url", ({ url, expectedLink }) => { + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe(expectedLink); + } + expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 }); + }); + + it("should preserve resourcekey when available", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456", + ); + } + }); + + it("should preserve timestamp when available", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9", + ); + } + }); + + it("should preserve resourcekey and timestamp together", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9", + ); + } + }); + + it("should validate Google Drive domain by default", () => { + expect( + embeddableURLValidator( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view", + undefined, + ), + ).toBe(true); + }); +});