diff --git a/apps/web/__tests__/unit/share-video-metadata.test.ts b/apps/web/__tests__/unit/share-video-metadata.test.ts index 6fbc7b1ac7..376c57cd6b 100644 --- a/apps/web/__tests__/unit/share-video-metadata.test.ts +++ b/apps/web/__tests__/unit/share-video-metadata.test.ts @@ -22,6 +22,26 @@ describe("share video metadata", () => { ); }); + it("advertises a custom domain but keeps the player and oEmbed canonical", () => { + const urls = getShareVideoUrls({ + videoId: "video123", + sourceType: "desktopMP4", + webUrl: "https://looms.example.com", + canonicalWebUrl: "https://cap.so", + }); + + expect(urls.shareUrl).toBe("https://looms.example.com/s/video123"); + expect(urls.previewImageUrl).toContain("https://looms.example.com/"); + expect(urls.ogImageUrl).toContain("https://looms.example.com/"); + expect(urls.streamUrl).toContain("https://looms.example.com/"); + // `/embed/` redirects off a custom domain and `/api/oembed` rejects a + // custom domain `url`, so both stay on the default origin. + expect(urls.playerUrl).toBe("https://cap.so/embed/video123"); + expect(urls.oEmbedUrl).toBe( + "https://cap.so/api/oembed?url=https%3A%2F%2Fcap.so%2Fs%2Fvideo123&format=json", + ); + }); + it("emits video metadata used by Open Graph and Twitter players", () => { const metadata = buildShareVideoMetadata({ videoId: "video123", diff --git a/apps/web/__tests__/unit/share-web-url.test.ts b/apps/web/__tests__/unit/share-web-url.test.ts new file mode 100644 index 0000000000..4bfb157206 --- /dev/null +++ b/apps/web/__tests__/unit/share-web-url.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + isDefaultShareHostname, + requestShareHostname, +} from "@/lib/share-web-url"; + +const headersFrom = (init: Record) => new Headers(init); + +describe("requestShareHostname", () => { + it("reads the host header", () => { + expect(requestShareHostname(headersFrom({ host: "cap.so" }))).toBe( + "cap.so", + ); + }); + + it("prefers the forwarded host set by the proxy", () => { + const headers = headersFrom({ + host: "cap-web.vercel.app", + "x-forwarded-host": "looms.example.com", + }); + + expect(requestShareHostname(headers)).toBe("looms.example.com"); + }); + + it("takes the first entry of a forwarded host list", () => { + const headers = headersFrom({ + "x-forwarded-host": "looms.example.com, cap.so", + }); + + expect(requestShareHostname(headers)).toBe("looms.example.com"); + }); + + it("lowercases the host and drops the port", () => { + expect(requestShareHostname(headersFrom({ host: "LocalHost:3000" }))).toBe( + "localhost", + ); + }); + + it("returns an empty string when no host header is present", () => { + expect(requestShareHostname(headersFrom({}))).toBe(""); + }); +}); + +describe("isDefaultShareHostname", () => { + it.each(["cap.so", "cap.link", "localhost", "127.0.0.1"])( + "treats %s as a default host", + (hostname) => { + expect(isDefaultShareHostname(hostname, "https://cap.so")).toBe(true); + }, + ); + + it("matches the configured web URL host", () => { + expect( + isDefaultShareHostname("cap.example.com", "https://cap.example.com"), + ).toBe(true); + }); + + it.each([ + ["a bare deployment host", "cap-git-main.vercel.app"], + ["a deployment host given as a URL", "https://cap-git-main.vercel.app"], + ])("treats %s as a default host", (_label, deploymentHost) => { + expect( + isDefaultShareHostname("cap-git-main.vercel.app", "https://cap.so", [ + deploymentHost, + ]), + ).toBe(true); + }); + + it("rejects a custom domain", () => { + expect(isDefaultShareHostname("looms.example.com", "https://cap.so")).toBe( + false, + ); + }); + + it("falls back to the default host when the host is missing", () => { + expect(isDefaultShareHostname("", "https://cap.so")).toBe(true); + }); + + it("falls back to the default host when the web URL is unparsable", () => { + expect(isDefaultShareHostname("looms.example.com", "not-a-url")).toBe(true); + }); +}); diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 3c72513775..f021913bbc 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -56,6 +56,7 @@ import * as EffectRuntime from "@/lib/server"; import { runPromise } from "@/lib/server"; import { getSharePageBranding } from "@/lib/share-branding"; import { buildShareVideoMetadata } from "@/lib/share-video-metadata"; +import { resolveShareWebUrl } from "@/lib/share-web-url"; import { isIframelyCrawlerUserAgent, isSocialCrawlerUserAgent, @@ -235,6 +236,13 @@ export async function generateMetadata( const shouldAdvertiseIframelyPlayer = isIframelyCrawlerUserAgent(requestUserAgent) && (await getPublicShareVideo(videoId).catch(() => null)) !== null; + // Share pages also serve verified custom domains. Metadata has to point at + // the host the visitor used, or Slack drops the preview image. + const webUrl = await resolveShareWebUrl(headersList); + const ogImageUrl = new URL( + `/api/video/og?videoId=${videoId}`, + webUrl, + ).toString(); return Effect.flatMap(Videos, (v) => v.getByIdForViewing(videoId)).pipe( Effect.map( @@ -253,7 +261,8 @@ export async function generateMetadata( videoId, name: video.name, sourceType: video.source.type, - webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, + webUrl, + canonicalWebUrl: buildEnv.NEXT_PUBLIC_WEB_URL, advertiseIframelyPlayer: shouldAdvertiseIframelyPlayer, }), robots: canRenderSocialPreview @@ -269,16 +278,7 @@ export async function generateMetadata( title: "Cap: This video is restricted", description: "This video has restricted access.", openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1200, - height: 630, - }, - ], + images: [{ url: ogImageUrl, width: 1200, height: 630 }], }, robots: "noindex, nofollow", }), @@ -287,27 +287,13 @@ export async function generateMetadata( title: "Cap: Password Protected Video", description: "This video is password protected.", openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1200, - height: 630, - }, - ], + images: [{ url: ogImageUrl, width: 1200, height: 630 }], }, twitter: { card: "summary_large_image", title: "Cap: Password Protected Video", description: "This video is password protected.", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - ], + images: [ogImageUrl], }, robots: "noindex, nofollow", }), diff --git a/apps/web/lib/share-video-metadata.ts b/apps/web/lib/share-video-metadata.ts index 531276f25d..fe832eb215 100644 --- a/apps/web/lib/share-video-metadata.ts +++ b/apps/web/lib/share-video-metadata.ts @@ -15,6 +15,13 @@ export type ShareVideoMetadataInput = { name: string; sourceType: ShareVideoSourceType; webUrl: string; + /** + * Two surfaces only answer on the default Cap origin: `proxy.ts` redirects + * `/embed/` away from a custom domain, and `parseCapShareUrl` accepts share + * URLs on `cap.so` and `cap.link` alone, so `/api/oembed` rejects a custom + * domain `url`. Defaults to `webUrl`. + */ + canonicalWebUrl?: string; advertiseIframelyPlayer?: boolean; }; @@ -22,9 +29,17 @@ export const getShareVideoUrls = ({ videoId, sourceType, webUrl, -}: Pick) => { + canonicalWebUrl = webUrl, +}: Pick< + ShareVideoMetadataInput, + "videoId" | "sourceType" | "webUrl" | "canonicalWebUrl" +>) => { const shareUrl = new URL(`/s/${videoId}`, webUrl).toString(); - const playerUrl = new URL(`/embed/${videoId}`, webUrl).toString(); + const canonicalShareUrl = new URL( + `/s/${videoId}`, + canonicalWebUrl, + ).toString(); + const playerUrl = new URL(`/embed/${videoId}`, canonicalWebUrl).toString(); const streamUrl = new URL("/api/playlist", webUrl); streamUrl.searchParams.set("videoId", videoId); let streamContentType = "application/vnd.apple.mpegurl"; @@ -42,8 +57,8 @@ export const getShareVideoUrls = ({ previewImageUrl.searchParams.set("fallback", "og"); const ogImageUrl = new URL("/api/video/og", webUrl); ogImageUrl.searchParams.set("videoId", videoId); - const oEmbedUrl = new URL("/api/oembed", webUrl); - oEmbedUrl.searchParams.set("url", shareUrl); + const oEmbedUrl = new URL("/api/oembed", canonicalWebUrl); + oEmbedUrl.searchParams.set("url", canonicalShareUrl); oEmbedUrl.searchParams.set("format", "json"); return { @@ -62,9 +77,15 @@ export const buildShareVideoMetadata = ({ name, sourceType, webUrl, + canonicalWebUrl, advertiseIframelyPlayer = false, }: ShareVideoMetadataInput): Metadata => { - const urls = getShareVideoUrls({ videoId, sourceType, webUrl }); + const urls = getShareVideoUrls({ + videoId, + sourceType, + webUrl, + canonicalWebUrl, + }); const title = `${name} | Cap Recording`; const description = "Watch this video on Cap"; diff --git a/apps/web/lib/share-web-url.ts b/apps/web/lib/share-web-url.ts new file mode 100644 index 0000000000..5b03feb969 --- /dev/null +++ b/apps/web/lib/share-web-url.ts @@ -0,0 +1,92 @@ +import { db } from "@cap/database"; +import { organizations } from "@cap/database/schema"; +import { buildEnv, serverEnv } from "@cap/env"; +import { eq } from "drizzle-orm"; + +const DEFAULT_HOSTNAMES = ["cap.so", "cap.link", "localhost", "127.0.0.1"]; + +const normalizeHostname = (value: string | null | undefined) => { + const first = value?.split(",")[0]?.trim().toLowerCase(); + if (!first) return ""; + return first.replace(/:\d+$/, ""); +}; + +// `WEB_URL` is a full URL while the `VERCEL_*_HOST` values are bare hosts. +const toHostname = (value: string) => { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return normalizeHostname(value); + } +}; + +export const requestShareHostname = (headersList: Headers) => + normalizeHostname( + headersList.get("x-forwarded-host") ?? headersList.get("host"), + ); + +export const isDefaultShareHostname = ( + hostname: string, + defaultWebUrl: string, + deploymentHostnames: readonly string[] = [], +) => { + if (!hostname) return true; + if (DEFAULT_HOSTNAMES.includes(hostname)) return true; + if (deploymentHostnames.some((value) => toHostname(value) === hostname)) + return true; + try { + return new URL(defaultWebUrl).hostname.toLowerCase() === hostname; + } catch { + // A misconfigured origin must not promote the request host. + return true; + } +}; + +// Mirrors the `mainOrigins` list in `proxy.ts`, so a preview deployment does +// not pay for the organization lookup below. +const deploymentHostnames = (): string[] => { + try { + const env = serverEnv(); + return [ + env.WEB_URL, + env.VERCEL_URL_HOST, + env.VERCEL_BRANCH_URL_HOST, + env.VERCEL_PROJECT_PRODUCTION_URL_HOST, + ].filter((value): value is string => Boolean(value)); + } catch { + return []; + } +}; + +/** + * Slack drops the preview image when `og:url` names a different host than the + * link it unfurls, so a share page on a verified custom domain has to advertise + * that domain rather than `NEXT_PUBLIC_WEB_URL`. See #2122. + * + * `proxy.ts` already redirects unverified hosts away from `/s/`. This lookup is + * a second check, and it falls back whenever the host is unknown, unverified, + * or the query throws. + */ +export const resolveShareWebUrl = async ( + headersList: Headers, +): Promise => { + const defaultWebUrl = buildEnv.NEXT_PUBLIC_WEB_URL; + const hostname = requestShareHostname(headersList); + + if (isDefaultShareHostname(hostname, defaultWebUrl, deploymentHostnames())) + return defaultWebUrl; + + try { + const [organization] = await db() + .select({ domainVerified: organizations.domainVerified }) + .from(organizations) + .where(eq(organizations.customDomain, hostname)) + .limit(1); + + if (!organization?.domainVerified) return defaultWebUrl; + return `https://${hostname}`; + } catch (error) { + console.error("Failed to resolve custom domain for share metadata", error); + return defaultWebUrl; + } +};