this repo has no description
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: serve 1200×630 JPEG og-banner for link cards (Bluesky composer)

Cardyb could preview full PNG banners while the Bluesky composer still
omitted thumbnails—likely size/format limits on the external thumb
pipeline. Add /api/registry/og-banner/{did} that decodes the PDS banner,
center-crops to 1.91:1, and emits a small JPEG; project pages use it for
og:image while keeping full-resolution /api/registry/banner for <img>.

ImageScript handles PNG/WebP/JPEG decode; on resize failure, serve raw bytes.

Made-with: Cursor

+79 -17
+4 -4
routes/api/registry/banner/[did].ts
··· 1 1 /** 2 - * Proxies a project's banner blob from the owner's PDS. Used as the 3 - * canonical banner URL for both the in-page <img> and the OG/Twitter 4 - * meta image so link-unfurlers (Bluesky, Slack, Twitter, iMessage, etc.) 5 - * can fetch it directly without cross-origin PDS restrictions. 2 + * Proxies a project's banner blob from the owner's PDS for the in-page 3 + * `<img>` (full resolution). Open Graph / Twitter meta images use 4 + * `/api/registry/og-banner/{did}` instead — a small 1200×630 JPEG for embed 5 + * pipelines (e.g. Bluesky composer) that struggle with large PNGs. 6 6 * 7 7 * The response is aggressively cached — the cache key includes the DID 8 8 * (stable) but not the CID, so cache-control is bounded the same way as
+69
routes/api/registry/og-banner/[did].ts
··· 1 + /** 2 + * Social / link-preview sized banner for `og:image` only (~1200×630 JPEG). 3 + * The full-resolution `/api/registry/banner/{did}` stream can be 600KB+ PNG; 4 + * Bluesky’s composer often fails to attach that as an external thumb while 5 + * Cardyb still previews it. This route decodes the same blob, center-crops to 6 + * 1.91:1, and re-encodes as JPEG so embed pipelines get a small same-origin 7 + * image similar to `/og-hero.png`. In-page banners keep using `/banner/`. 8 + */ 9 + import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; 10 + import { define } from "../../../../utils.ts"; 11 + import { getProfileByDid } from "../../../../lib/registry.ts"; 12 + import { fetchBlobPublic } from "../../../../lib/pds.ts"; 13 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 14 + 15 + const OG_W = 1200; 16 + const OG_H = 630; 17 + const JPEG_QUALITY = 85; 18 + 19 + export const handler = define.handlers({ 20 + GET: withRateLimit(async (ctx) => { 21 + const did = decodeURIComponent(ctx.params.did); 22 + const profile = await getProfileByDid(did).catch(() => null); 23 + if (!profile?.bannerCid) { 24 + return new Response("not found", { status: 404 }); 25 + } 26 + try { 27 + const upstream = await fetchBlobPublic( 28 + profile.pdsUrl, 29 + did, 30 + profile.bannerCid, 31 + ); 32 + if (!upstream.ok) { 33 + return new Response("not found", { status: 404 }); 34 + } 35 + const buf = new Uint8Array(await upstream.arrayBuffer()); 36 + try { 37 + const img = await Image.decode(buf); 38 + const cov = img.cover(OG_W, OG_H); 39 + const jpeg = await cov.encodeJPEG(JPEG_QUALITY); 40 + return new Response( 41 + new Blob([new Uint8Array(jpeg)], { type: "image/jpeg" }), 42 + { 43 + status: 200, 44 + headers: { 45 + "cache-control": 46 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 47 + "etag": `${profile.bannerCid}-og`, 48 + }, 49 + }, 50 + ); 51 + } catch (err) { 52 + console.warn("[og-banner] resize failed, serving raw bytes:", err); 53 + const ct = upstream.headers.get("content-type") ?? 54 + profile.bannerMime ?? "application/octet-stream"; 55 + return new Response(new Blob([new Uint8Array(buf)], { type: ct }), { 56 + status: 200, 57 + headers: { 58 + "cache-control": 59 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 60 + "etag": profile.bannerCid, 61 + }, 62 + }); 63 + } 64 + } catch (err) { 65 + console.warn("[og-banner] proxy error:", err); 66 + return new Response("upstream error", { status: 502 }); 67 + } 68 + }), 69 + });
+6 -13
routes/explore/[handle].tsx
··· 79 79 ).href 80 80 : ctx.url.href; 81 81 /** 82 - * Per-page social meta. When the project has a banner, the 83 - * Bluesky CDN URL is used as the OG/Twitter image so the project 84 - * banner becomes the link card preview anywhere the URL is 85 - * shared. Project name + description fill the title and 86 - * description so the card carries enough context on its own. 82 + * Per-page social meta. When the project has a banner, use the 83 + * dedicated OG JPEG route (~1200×630, tens of KB) for og:image so link 84 + * unfurlers and the Bluesky composer get a small asset; full resolution 85 + * stays on `/api/registry/banner/{did}` for the in-page banner <img>. 87 86 */ 88 87 if (profile) { 89 88 const messages = getMessages(ctx.state.locale).explore; 90 89 const pageTitle = `${profile.name} on Atmosphere Account`; 91 90 const pageDescription = profile.description || 92 91 messages.detail.missingProfile; 93 - // Always use our own absolute banner URL for og:image (same origin as 94 - // /og-hero.png on the homepage). Raw cdn.bsky.app banners can be ~650KB+ 95 - // and wide (e.g. 3000×1000); the homepage card uses a smaller static PNG 96 - // and shows a thumbnail in the Bluesky composer, while the CDN URL often 97 - // produced a text-only link card. Cardyb successfully fetches this proxy 98 - // and normalizes dimensions for embeds. 99 92 const ogImageUrl = profile.bannerCid 100 93 ? new URL( 101 - `/api/registry/banner/${encodeURIComponent(profile.did)}`, 94 + `/api/registry/og-banner/${encodeURIComponent(profile.did)}`, 102 95 ctx.url.origin, 103 96 ).href 104 97 : undefined; ··· 113 106 imageAlt: profile.bannerCid 114 107 ? messages.detail.share.bannerAlt(profile.name) 115 108 : undefined, 116 - imageType: profile.bannerMime ?? "image/jpeg", 109 + imageType: profile.bannerCid ? "image/jpeg" : undefined, 117 110 imageWidth: 1200, 118 111 imageHeight: 630, 119 112 };