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();
+};