-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(web): emit share metadata on the verified custom domain #2123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
richiemcilroy
merged 3 commits into
CapSoftware:main
from
dmdfajardo00:fix/custom-domain-og-metadata
Aug 14, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| }; | ||
|
|
||
| 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; | ||
| } | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new JSDoc comments above
toHostname,requestShareHostname,isDefaultShareHostname, anddeploymentHostnamesrestate 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
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!
There was a problem hiding this comment.
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
requestShareHostnameandisDefaultShareHostnameoutright. Both restated the name.I kept two comments, rewritten to carry the reason rather than the behavior:
toHostname:WEB_URLarrives as a full URL while theVERCEL_*_HOSTvalues arrive as bare hosts. That is the only reason the parse sits behind a try/catch.deploymentHostnames: it mirrors themainOriginslist inproxy.ts, and the point is to skip the organization lookup on preview deployments.I also trimmed the
webUrldoc inshare-video-metadata.tsand left thecanonicalWebUrlone, which records why/embed/and/api/oembedcannot move to a custom domain.Tell me if you would rather have those three gone as well.
There was a problem hiding this comment.
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: theproxy.tsmirror relationship is cross-file state that silently breaks if one list drifts from the other. Worth keeping.resolveShareWebUrlJSDoc: 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.mdand.greptile/config.json.