···11/**
22- * Compatibility endpoint for registry profile banners. Mirrors the
33- * avatar route — primary UI paths can hit Bluesky's CDN directly via
44- * `bskyCdnBannerUrl`; this route exists so external link unfurlers,
55- * old embeds, or any consumer that just knows the DID can resolve the
66- * banner without first looking up the cid.
22+ * Proxies a project's banner blob from the owner's PDS. Used as the
33+ * canonical banner URL for both the in-page <img> and the OG/Twitter
44+ * meta image so link-unfurlers (Bluesky, Slack, Twitter, iMessage, etc.)
55+ * can fetch it directly without cross-origin PDS restrictions.
66+ *
77+ * The response is aggressively cached — the cache key includes the DID
88+ * (stable) but not the CID, so cache-control is bounded the same way as
99+ * the screenshot proxy: long enough to be useful, short enough that a
1010+ * banner replacement shows up within a day.
711 */
812import { define } from "../../../../utils.ts";
99-import { bskyCdnBannerUrl } from "../../../../lib/avatar.ts";
1013import { getProfileByDid } from "../../../../lib/registry.ts";
1414+import { fetchBlobPublic } from "../../../../lib/pds.ts";
1115import { withRateLimit } from "../../../../lib/rate-limit.ts";
12161317export const handler = define.handlers({
1418 GET: withRateLimit(async (ctx) => {
1519 const did = decodeURIComponent(ctx.params.did);
1620 const profile = await getProfileByDid(did).catch(() => null);
1717- const bannerCid = profile?.bannerCid;
1818- if (!bannerCid) {
2121+ if (!profile?.bannerCid) {
1922 return new Response("not found", { status: 404 });
2023 }
2121- return new Response(null, {
2222- status: 302,
2323- headers: {
2424- location: bskyCdnBannerUrl(did, bannerCid),
2525- "cache-control":
2626- "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600",
2727- },
2828- });
2424+ try {
2525+ const upstream = await fetchBlobPublic(
2626+ profile.pdsUrl,
2727+ did,
2828+ profile.bannerCid,
2929+ );
3030+ if (!upstream.ok) {
3131+ return new Response("not found", { status: 404 });
3232+ }
3333+ const headers = new Headers();
3434+ headers.set(
3535+ "content-type",
3636+ upstream.headers.get("content-type") ??
3737+ profile.bannerMime ??
3838+ "image/jpeg",
3939+ );
4040+ headers.set(
4141+ "cache-control",
4242+ "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
4343+ );
4444+ headers.set("etag", profile.bannerCid);
4545+ return new Response(upstream.body, { status: 200, headers });
4646+ } catch (err) {
4747+ console.warn("[banner] proxy error:", err);
4848+ return new Response("upstream error", { status: 502 });
4949+ }
2950 }),
3051});
+6-4
routes/explore/[handle].tsx
···2828import { accountProviderName } from "../../lib/account-providers.ts";
2929import { buildAccountMenuProps } from "../../lib/account-menu-props.ts";
3030import { getAppUser } from "../../lib/account-types.ts";
3131-import { bskyCdnAvatarUrl, bskyCdnBannerUrl } from "../../lib/avatar.ts";
3131+import { bskyCdnAvatarUrl } from "../../lib/avatar.ts";
3232import {
3333 listProfileUpdates,
3434 type ProfileUpdateRow,
···8585 const pageDescription = profile.description ||
8686 messages.detail.missingProfile;
8787 const ogImageUrl = profile.bannerCid
8888- ? bskyCdnBannerUrl(profile.did, profile.bannerCid)
8888+ ? new URL(
8989+ `/api/registry/banner/${encodeURIComponent(profile.did)}`,
9090+ ctx.url.origin,
9191+ ).toString()
8992 : undefined;
9093 ctx.state.pageMeta = {
9194 title: pageTitle,
···174177 * brand name (Bluesky, etc.) and fall back to the bare host. */
175178 const providerName = accountProviderName(profile.pdsUrl);
176179 const bannerUrl = profile.bannerCid
177177- ? bskyCdnBannerUrl(profile.did, profile.bannerCid)
180180+ ? `/api/registry/banner/${encodeURIComponent(profile.did)}`
178181 : null;
179182 const shareCopy = t.detail.share;
180183 return (
···190193 <ShareButton
191194 url={shareUrl}
192195 title={shareCopy.shareTitle(profile.name)}
193193- text={profile.description}
194196 copy={{
195197 button: shareCopy.button,
196198 copyLink: shareCopy.copyLink,