Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion apps/web/__tests__/unit/share-page-loading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }),
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down
90 changes: 90 additions & 0 deletions apps/web/__tests__/unit/share-restricted-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
31 changes: 26 additions & 5 deletions apps/web/app/s/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -244,12 +248,24 @@ const renderNoSuchElement = (awaitRecording: boolean) =>
? Effect.succeed(<PendingRecordingShare />)
: 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<JSX.Element | RestrictedShareRedirect> =>
redirectTo
? Effect.succeed({ redirectTo })
: renderPolicyDenied(videoId, e.reason),
),
),
NoSuchElementException: () => renderNoSuchElement(awaitRecording),
});

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -442,10 +458,15 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) {
</div>
),
),
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({
Expand Down
23 changes: 23 additions & 0 deletions apps/web/lib/share-web-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[] | undefined>,
): Promise<string | null> => {
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();
};
Loading