diff --git a/apps/web/__tests__/unit/share-page-loading.test.ts b/apps/web/__tests__/unit/share-page-loading.test.ts index cd21fa47c8f..da8ee4386e2 100644 --- a/apps/web/__tests__/unit/share-page-loading.test.ts +++ b/apps/web/__tests__/unit/share-page-loading.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ NEXT_PUBLIC_IS_CAP: undefined as string | undefined, }, quota: vi.fn(), + restrictedRedirect: vi.fn(), user: vi.fn(), authenticated: false, ownerIsPro: false, @@ -77,6 +78,9 @@ vi.mock("next/navigation", () => ({ notFound: () => { throw new Error("NOT_FOUND"); }, + redirect: (url: string) => { + throw new Error(`REDIRECT ${url}`); + }, })); vi.mock("@/lib/video-view-count", () => ({ getVideoViewCount: async () => ({ count: 12 }), @@ -102,7 +106,10 @@ vi.mock("@/lib/share-playback", async (importActual) => ({ vi.mock("@/lib/shareable-link-quota", () => ({ isVideoOverShareableLinkLimit: mocks.quota, })); -vi.mock("@/lib/share-web-url", () => ({ resolveShareWebUrl: vi.fn() })); +vi.mock("@/lib/share-web-url", () => ({ + resolveShareWebUrl: vi.fn(), + resolveRestrictedShareRedirect: mocks.restrictedRedirect, +})); vi.mock("@/lib/transcribe", () => ({ transcribeVideo: vi.fn() })); vi.mock("@/lib/video-download-permissions", () => ({ canUserDownloadVideo: async () => false, @@ -198,6 +205,7 @@ describe("share page loading", () => { ); mocks.playbackUrl.mockResolvedValue("https://media.example.com/result.mp4"); mocks.quota.mockResolvedValue(false); + mocks.restrictedRedirect.mockResolvedValue(null); mocks.defaultStorage = true; mocks.userAgent = ""; mocks.buildEnv.NEXT_PUBLIC_IS_CAP = undefined; @@ -208,6 +216,38 @@ describe("share page loading", () => { arrangeRows([createVideo()]); }); + it("renders the private state for a restricted video on the main origin", async () => { + mocks.policy.mockReturnValue(Effect.fail({ _tag: "PolicyDenied" })); + const page = (await renderPage()) as ReactElement<{ videoId: string }>; + expect(isValidElement(page)).toBe(true); + expect(page.props.videoId).toBe(createVideo().id); + expect(mocks.restrictedRedirect).toHaveBeenCalledOnce(); + }); + + it("sends a restricted video on a custom domain to the main origin", async () => { + mocks.policy.mockReturnValue(Effect.fail({ _tag: "PolicyDenied" })); + mocks.restrictedRedirect.mockResolvedValue("https://cap.so/s/video"); + await expect(renderPage()).rejects.toThrow( + "REDIRECT https://cap.so/s/video", + ); + expect(mocks.playbackUrl).not.toHaveBeenCalled(); + }); + + it("keeps password gates on the custom domain", async () => { + mocks.policy.mockReturnValue( + Effect.fail({ _tag: "VerifyVideoPasswordError" }), + ); + mocks.restrictedRedirect.mockResolvedValue("https://cap.so/s/video"); + const page = (await renderPage()) as ReactElement; + expect(page.type).toBe(PasswordGate); + expect(mocks.restrictedRedirect).not.toHaveBeenCalled(); + }); + + it("does not look up the host for authorized videos", async () => { + await renderAuthorizedContent(); + expect(mocks.restrictedRedirect).not.toHaveBeenCalled(); + }); + it.each([false, true])( "serves a saved call to action only when the owner has Pro: %s", async (ownerIsPro) => { diff --git a/apps/web/__tests__/unit/share-restricted-redirect.test.ts b/apps/web/__tests__/unit/share-restricted-redirect.test.ts new file mode 100644 index 00000000000..09a0febc276 --- /dev/null +++ b/apps/web/__tests__/unit/share-restricted-redirect.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + organizations: [] as { domainVerified: boolean }[], + lookups: 0, +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + mocks.lookups += 1; + return mocks.organizations; + }, + }), + }), + }), + }), +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, + serverEnv: () => ({ WEB_URL: "https://cap.so" }), +})); + +import { resolveRestrictedShareRedirect } from "@/lib/share-web-url"; + +const hostHeaders = (host: string) => new Headers({ host }); + +describe("resolveRestrictedShareRedirect", () => { + beforeEach(() => { + mocks.organizations = []; + mocks.lookups = 0; + }); + + it("keeps restricted videos on the main origin without a lookup", async () => { + expect( + await resolveRestrictedShareRedirect(hostHeaders("cap.so"), "abc123", {}), + ).toBeNull(); + expect(mocks.lookups).toBe(0); + }); + + it("sends a verified custom domain to the main origin share page", async () => { + mocks.organizations = [{ domainVerified: true }]; + expect( + await resolveRestrictedShareRedirect( + hostHeaders("recordings.example.com"), + "abc123", + {}, + ), + ).toBe("https://cap.so/s/abc123"); + }); + + it("carries the query string across, including repeated keys", async () => { + mocks.organizations = [{ domainVerified: true }]; + expect( + await resolveRestrictedShareRedirect( + hostHeaders("recordings.example.com"), + "abc123", + { t: "42", comment: ["a", "b"], view: undefined }, + ), + ).toBe("https://cap.so/s/abc123?t=42&comment=a&comment=b"); + }); + + it("encodes the video id into a single path segment", async () => { + mocks.organizations = [{ domainVerified: true }]; + expect( + await resolveRestrictedShareRedirect( + hostHeaders("recordings.example.com"), + "../dashboard?x=1", + {}, + ), + ).toBe("https://cap.so/s/..%2Fdashboard%3Fx%3D1"); + }); + + it.each([ + ["an unknown host", []], + ["an unverified domain", [{ domainVerified: false }]], + ])("does not redirect %s", async (_label, organizations) => { + mocks.organizations = organizations; + expect( + await resolveRestrictedShareRedirect( + hostHeaders("recordings.example.com"), + "abc123", + {}, + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index a0f57a16f9d..cdc36cbc1d9 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -36,8 +36,9 @@ import { and, eq, type InferSelectModel, isNull, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; import type { Metadata } from "next"; import { headers } from "next/headers"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { after } from "next/server"; +import type { JSX } from "react"; import { getDashboardSpacesData, type OrganizationSettings, @@ -67,7 +68,10 @@ import { getSharePlaybackSource, } from "@/lib/share-playback"; import { buildShareVideoMetadata } from "@/lib/share-video-metadata"; -import { resolveShareWebUrl } from "@/lib/share-web-url"; +import { + resolveRestrictedShareRedirect, + resolveShareWebUrl, +} from "@/lib/share-web-url"; import { isVideoOverShareableLinkLimit } from "@/lib/shareable-link-quota"; import { isIframelyCrawlerUserAgent, @@ -244,12 +248,24 @@ const renderNoSuchElement = (awaitRecording: boolean) => ? Effect.succeed() : Effect.sync(() => notFound()); +type RestrictedShareRedirect = { redirectTo: string }; + const getShareVideoPageCatchers = ( videoId: Video.VideoId, awaitRecording: boolean, + searchParams: ShareVideoSearchParams, ) => ({ PolicyDenied: (e: Policy.PolicyDeniedError) => - renderPolicyDenied(videoId, e.reason), + Effect.promise(async () => + resolveRestrictedShareRedirect(await headers(), videoId, searchParams), + ).pipe( + Effect.flatMap( + (redirectTo): Effect.Effect => + redirectTo + ? Effect.succeed({ redirectTo }) + : renderPolicyDenied(videoId, e.reason), + ), + ), NoSuchElementException: () => renderNoSuchElement(awaitRecording), }); @@ -350,7 +366,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { const awaitRecording = isValidVideoIdParam(videoId) && hasRecordingStoppedParam(searchParams); - return Effect.gen(function* () { + const page = await Effect.gen(function* () { const videosPolicy = yield* VideosPolicy; const loadVideo = () => db() @@ -442,10 +458,15 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { ), ), - Effect.catchTags(getShareVideoPageCatchers(videoId, awaitRecording)), + Effect.catchTags( + getShareVideoPageCatchers(videoId, awaitRecording, searchParams), + ), provideOptionalAuth, EffectRuntime.runPromise, ); + + if ("redirectTo" in page) redirect(page.redirectTo); + return page; } async function AuthorizedContent({ diff --git a/apps/web/lib/share-web-url.ts b/apps/web/lib/share-web-url.ts index 5b03feb9698..f57ea437c45 100644 --- a/apps/web/lib/share-web-url.ts +++ b/apps/web/lib/share-web-url.ts @@ -90,3 +90,26 @@ export const resolveShareWebUrl = async ( return defaultWebUrl; } }; + +/** + * The session cookie belongs to the main web origin, so a share page served on + * a custom domain never knows who is watching. A restricted video sends the + * viewer to the same share page on the main origin, where their session + * decides access. Returns null on the main origin. + */ +export const resolveRestrictedShareRedirect = async ( + headersList: Headers, + videoId: string, + searchParams: Record, +): Promise => { + const defaultWebUrl = buildEnv.NEXT_PUBLIC_WEB_URL; + if ((await resolveShareWebUrl(headersList)) === defaultWebUrl) return null; + + const url = new URL(`/s/${encodeURIComponent(videoId)}`, defaultWebUrl); + for (const [key, value] of Object.entries(searchParams)) { + for (const entry of Array.isArray(value) ? value : [value]) { + if (entry !== undefined) url.searchParams.append(key, entry); + } + } + return url.toString(); +};