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
20 changes: 20 additions & 0 deletions apps/web/__tests__/unit/share-video-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions apps/web/__tests__/unit/share-web-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
isDefaultShareHostname,
requestShareHostname,
} from "@/lib/share-web-url";

const headersFrom = (init: Record<string, string>) => 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);
});
});
40 changes: 13 additions & 27 deletions apps/web/app/s/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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",
}),
Expand All @@ -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",
}),
Expand Down
31 changes: 26 additions & 5 deletions apps/web/lib/share-video-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,31 @@ 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;
};

export const getShareVideoUrls = ({
videoId,
sourceType,
webUrl,
}: Pick<ShareVideoMetadataInput, "videoId" | "sourceType" | "webUrl">) => {
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";
Expand All @@ -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 {
Expand All @@ -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";

Expand Down
92 changes: 92 additions & 0 deletions apps/web/lib/share-web-url.ts
Original file line number Diff line number Diff line change
@@ -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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Remove narrative helper comments

The new JSDoc comments above toHostname, requestShareHostname, isDefaultShareHostname, and deploymentHostnames restate their names, signatures, or immediately visible behavior. This conflicts with the repository’s comments policy and adds documentation that can drift without preserving non-obvious context.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/share-web-url.ts
Line: 25

Comment:
**Remove narrative helper comments**

The new JSDoc comments above `toHostname`, `requestShareHostname`, `isDefaultShareHostname`, and `deploymentHostnames` restate their names, signatures, or immediately visible behavior. This conflicts with the repository’s comments policy and adds documentation that can drift without preserving non-obvious context.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 916f1d5.

I removed the JSDoc above requestShareHostname and isDefaultShareHostname outright. Both restated the name.

I kept two comments, rewritten to carry the reason rather than the behavior:

  • toHostname: WEB_URL arrives as a full URL while the VERCEL_*_HOST values arrive as bare hosts. That is the only reason the parse sits behind a try/catch.
  • deploymentHostnames: it mirrors the mainOrigins list in proxy.ts, and the point is to skip the organization lookup on preview deployments.

I also trimmed the webUrl doc in share-video-metadata.ts and left the canonicalWebUrl one, which records why /embed/ and /api/oembed cannot move to a custom domain.

Tell me if you would rather have those three gone as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remaining comments all carry information that isn't recoverable from the names or types alone:

  • toHostname: explains the dual-format input that makes the try/catch necessary — without it the catch looks defensive rather than load-bearing.
  • deploymentHostnames: the proxy.ts mirror relationship is cross-file state that silently breaks if one list drifts from the other. Worth keeping.
  • resolveShareWebUrl JSDoc: records the Slack unfurl constraint (Custom domain share pages emit cap.so Open Graph metadata, so Slack shows no thumbnail #2122), the proxy pre-check, and the fallback policy. That's exactly the non-obvious intent the policy is asking for.

The three you kept all satisfy the "why, not what" bar. The approach looks good to me.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

};

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<string> => {
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;
}
};
Loading