diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index d093e9f5b0..3aba732628 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -130,6 +130,86 @@ const parseGoogleDriveVideoLink = ( } }; +const isGoogleMapsURL = (url: string): boolean => { + try { + const { hostname, pathname } = new URL( + url.startsWith("http") ? url : `https://${url}`, + ); + const bareHostname = hostname.replace(/^www\./, ""); + + return ( + (bareHostname === "google.com" || bareHostname === "maps.google.com") && + (pathname === "/maps" || pathname.startsWith("/maps/")) + ); + } catch (error) { + return false; + } +}; + +const getGoogleMapsZoom = (zoomOrDistance: string): string | null => { + const match = zoomOrDistance.match(/^(\d+(?:\.\d+)?)(z|km|m)$/); + if (!match) { + return null; + } + const value = Number(match[1]); + if (match[2] === "z") { + return `${Math.round(value)}`; + } + const meters = value * (match[2] === "km" ? 1000 : 1); + return `${Math.max( + 3, + Math.min(21, Math.round(16 - Math.log2(meters / 500))), + )}`; +}; + +const parseGoogleMapsLink = (url: string): string | null => { + if (!isGoogleMapsURL(url)) { + return null; + } + + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + + if ( + urlObj.pathname.startsWith("/maps/embed") || + urlObj.searchParams.get("output") === "embed" + ) { + return urlObj.toString(); + } + + const [, lat, lng, zoomOrDistance] = + urlObj.pathname.match( + /@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),([^/?#,]+)/, + ) || []; + const place = urlObj.pathname.match(/^\/maps\/place\/([^/]+)/)?.[1]; + const query = + urlObj.searchParams.get("q") || + (place ? decodeURIComponent(place).replace(/\+/g, " ") : null) || + (lat && lng ? `${lat},${lng}` : null); + + if (!query) { + return null; + } + + const embedURL = new URL("https://www.google.com/maps"); + embedURL.searchParams.set("q", query); + embedURL.searchParams.set("output", "embed"); + + if (lat && lng) { + embedURL.searchParams.set("ll", `${lat},${lng}`); + } + + const zoom = zoomOrDistance ? getGoogleMapsZoom(zoomOrDistance) : null; + if (zoom) { + embedURL.searchParams.set("z", zoom); + } + + return embedURL.toString(); + } catch (error) { + return null; + } +}; + const ALLOWED_DOMAINS = new Set([ "youtube.com", "youtu.be", @@ -280,6 +360,27 @@ export const getEmbedLink = ( }; } + if (isGoogleMapsURL(link)) { + const googleMapsLink = parseGoogleMapsLink(link); + if (googleMapsLink) { + link = googleMapsLink; + aspectRatio = { w: 600, h: 450 }; + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; + } + return null; + } + const figmaLink = link.match(RE_FIGMA); if (figmaLink) { type = "generic"; @@ -508,6 +609,7 @@ export const embeddableURLValidator = ( if (!url) { return false; } + if (validateEmbeddable != null) { if (typeof validateEmbeddable === "function") { const ret = validateEmbeddable(url); @@ -533,5 +635,5 @@ export const embeddableURLValidator = ( } } - return !!matchHostname(url, ALLOWED_DOMAINS); + return isGoogleMapsURL(url) || !!matchHostname(url, ALLOWED_DOMAINS); }; diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts index 35870a86f2..0397700a66 100644 --- a/packages/element/tests/embeddable.test.ts +++ b/packages/element/tests/embeddable.test.ts @@ -1,4 +1,8 @@ -import { embeddableURLValidator, getEmbedLink } from "../src/embeddable"; +import { + embeddableURLValidator, + getEmbedLink, + maybeParseEmbedSrc, +} from "../src/embeddable"; describe("YouTube timestamp parsing", () => { it("should parse YouTube URLs with timestamp in seconds", () => { @@ -231,3 +235,62 @@ describe("Google Drive video embedding", () => { ).toBe(true); }); }); + +describe("Google Maps embedding", () => { + const regularUrl = + "https://www.google.com/maps/place/26-432+Jab%C5%82onica,+Poland/@51.356302,20.797168,1921m/data=!3m2!1e3!4b1!4m15!1m8!3m7!1s0x47186c0e0e7578fd:0xe80d19a1ef6ad853!2zMjctMTAwIEnFgsW8YSwgUG9sYW5k!3b1!8m2!3d51.16305!4d21.23991!16zL20vMGM1ZnJ3!3m5!1s0x47184db43a4a5df9:0x6a2b8e648f9dc694!8m2!3d51.3562959!4d20.8023178!16s%2Fm%2F04q6t9r?entry=ttu"; + const officialEmbedSrc = + "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d8363.540754033738!2d20.79716795156659!3d51.356301987021546!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x47184db43a4a5df9%3A0x6a2b8e648f9dc694!2s26-432%20Jab%C5%82onica%2C%20Poland!5e1!3m2!1sen!2scz!4v1778159513974!5m2!1sen!2scz"; + + it("should preserve official Google Maps embed links", () => { + const parsedSrc = maybeParseEmbedSrc( + ``, + ); + const result = getEmbedLink(parsedSrc); + + expect(embeddableURLValidator(parsedSrc, undefined)).toBe(true); + expect(result).toBeTruthy(); + expect(result?.type).toBe("generic"); + if (result?.type === "generic") { + expect(result.link).toBe(officialEmbedSrc); + } + expect(result?.intrinsicSize).toEqual({ w: 600, h: 450 }); + }); + + it("should normalize regular Google Maps place links", () => { + const result = getEmbedLink(regularUrl); + + expect(embeddableURLValidator(regularUrl, undefined)).toBe(true); + expect(result).toBeTruthy(); + expect(result?.type).toBe("generic"); + if (result?.type !== "generic") { + return; + } + + const embedURL = new URL(result.link); + expect(embedURL.origin).toBe("https://www.google.com"); + expect(embedURL.pathname).toBe("/maps"); + expect(embedURL.searchParams.get("q")).toBe( + decodeURIComponent("26-432%20Jab%C5%82onica%2C%20Poland"), + ); + expect(embedURL.searchParams.get("output")).toBe("embed"); + expect(embedURL.searchParams.get("ll")).toBe("51.356302,20.797168"); + expect(embedURL.searchParams.get("z")).toBe("14"); + expect(result.intrinsicSize).toEqual({ w: 600, h: 450 }); + }); + + it("should reject non-Maps Google pages and fail closed for unsupported Maps pages", () => { + expect( + embeddableURLValidator("https://www.google.com/search?q=maps", undefined), + ).toBe(false); + + const unsupportedMapsUrl = "https://www.google.com/maps/about/"; + expect(embeddableURLValidator(unsupportedMapsUrl, undefined)).toBe(true); + expect(getEmbedLink(unsupportedMapsUrl)).toBe(null); + + const malformedMapsUrl = `https://www.google.com/maps/@${"0,0,".repeat( + 1000, + )}`; + expect(embeddableURLValidator(malformedMapsUrl, undefined)).toBe(true); + }); +});