From b563e37ea45057c99445ecf4ab937ffd47d14ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 19:54:02 +0000 Subject: [PATCH 1/3] Serve right-sized, CDN-cached image variants across publication routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the image-serving paths found the /api/atproto_images and /api/resized_images proxies working well (storage-cached variants served by 302 off Supabase's CDN), but many call sites and a few routes bypassing them: - Post opengraph-image routes (lish + p) streamed the full-resolution cover blob from the PDS through Vercel with only a 1-hour cache. They now 302 to the cached proxy with a 1200px variant, cached a day at the edge (a republish can swap the cover at the same URL). - /api/pub_icon and the per-publication favicon route re-fetched the blob from the PDS and re-ran sharp on every cache miss, streaming bytes through Vercel. Both now store the resized variant in storage keyed by CID (immutable) and 302 to it, falling back to streaming only if storage fails. The favicon also actually encodes to PNG now, matching its Content-Type. - /api/quote_screenshot returned a multi-second remote-browser render with no Cache-Control at all, so the share modal's warm-up fetch saved nothing; it's now edge-cached for a day. - ogScreenshotResponse gains a CDN-Cache-Control to match its s-maxage. - link_previews thumbnails were stored with storage-js's default 1-hour cacheControl; now a day (not a year — the key is a url hash that gets upsert-overwritten on re-preview). Call sites that shipped full-resolution blobs for small displays now request downscaled variants via blobRefToSrc's transform: publication icons everywhere (12-96px displays), wordmarks, website-block link previews (120px), post/gallery/email body images, theme background images (capped at the ladder max), and the dashboard's icon preview (which also hand-built the proxy URL; it uses blobRefToSrc now). Gallery lightboxes keep the full-resolution source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TG1Z2RXovEdV9MZVqc5yDM --- actions/publications/subscribeEmail.tsx | 5 +- .../p/[didOrHandle]/ProfileHeader.tsx | 4 +- .../p/[didOrHandle]/PubListing.tsx | 9 ++- .../[leaflet_id]/actions/PublishButton.tsx | 2 + .../[leaflet_id]/publish/PublishPost.tsx | 9 ++- .../[leaflet_id]/publish/ShareOptions.tsx | 9 ++- .../DefaultPublicationHomepage.tsx | 10 ++- .../[rkey]/Blocks/PublishedImageGallery.tsx | 29 +++++--- .../[publication]/[rkey]/PostContent.tsx | 6 +- .../[rkey]/PublicationPageRenderer.tsx | 14 ++-- .../[rkey]/StaticPostContent.tsx | 10 +-- .../[publication]/[rkey]/opengraph-image.ts | 29 ++------ .../lish/[did]/[publication]/archive/page.tsx | 6 +- .../dashboard/settings/SettingsContent.tsx | 5 +- .../edit/PublicationDraftEditor.tsx | 2 +- .../lish/[did]/[publication]/icon/route.ts | 64 +++++++++++++---- .../[publication]/subscribe/SubscribeCard.tsx | 4 +- .../p/[didOrHandle]/[rkey]/opengraph-image.ts | 29 ++------ app/api/auth/email-login/route.ts | 7 +- app/api/link_previews/route.ts | 4 ++ app/api/pub_icon/route.ts | 70 ++++++++++++------- app/api/quote_screenshot/route.ts | 9 ++- app/login/confirm/page.tsx | 7 +- components/ActionBar/Publications.tsx | 7 +- .../StandardSitePostItem.tsx | 2 + .../StandardSitePublicationItem.tsx | 4 +- components/PostListing.tsx | 9 ++- .../ThemeManager/PublicationThemeProvider.tsx | 2 + emails/post.tsx | 18 +++-- emails/standardSiteBlocks.tsx | 26 +++---- src/utils/bskyPostEmbed.ts | 4 +- src/utils/ogCoverImageRedirect.ts | 17 +++++ src/utils/screenshotPage.ts | 1 + src/utils/wordmark.ts | 6 +- 34 files changed, 290 insertions(+), 149 deletions(-) create mode 100644 src/utils/ogCoverImageRedirect.ts diff --git a/actions/publications/subscribeEmail.tsx b/actions/publications/subscribeEmail.tsx index e35c6a3a..f40fe9ba 100644 --- a/actions/publications/subscribeEmail.tsx +++ b/actions/publications/subscribeEmail.tsx @@ -65,12 +65,14 @@ export async function requestPublicationEmailSubscription( const normalizedPub = normalizePublicationRecord(publication?.record); const pubName = normalizedPub?.name; const pubUrl = normalizedPub?.url; - const assetsBaseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; + const assetsBaseUrl = + process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; const pubIcon = normalizedPub?.icon ? blobRefToSrc( normalizedPub.icon.ref, new AtUri(publicationUri).host, assetsBaseUrl, + { width: 360 }, ) : undefined; @@ -356,4 +358,3 @@ async function linkEmailToCurrentIdentity( if (!merged.ok) return Err("database_error"); return Ok(current.id); } - diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(app)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx index 9e3293a5..28f4243b 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(app)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -129,7 +129,9 @@ const PublicationCard = (props: { { ? blobRefToSrc( record?.theme?.backgroundImage?.image?.ref, new AtUri(props.uri).host, + undefined, + { width: 2000 }, ) : null; @@ -58,7 +60,12 @@ export const PubListing = (props: PubListingProps) => { ( - () => - block.images.map((i) => ({ - src: blobRefToSrc(i.image.ref, did), - alt: i.alt || "", - width: i.aspectRatio.width, - height: i.aspectRatio.height, - })), - [block.images, did], - ); + // Grid/strip/carousel cells render well under 800px, so they get a resized + // variant; the lightbox gets the full-resolution blob. + let { images, fullImages } = useMemo(() => { + let toImage = ( + i: PubLeafletBlocksImageGallery.Image, + transform?: { width: number }, + ): GalleryImage => ({ + src: blobRefToSrc(i.image.ref, did, undefined, transform), + alt: i.alt || "", + width: i.aspectRatio.width, + height: i.aspectRatio.height, + }); + return { + images: block.images.map((i) => toImage(i, { width: 800 })), + fullImages: block.images.map((i) => toImage(i)), + }; + }, [block.images, did]); let [lightboxIndex, setLightboxIndex] = useState(null); let openLightbox = (index: number) => setLightboxIndex(index); @@ -76,7 +83,7 @@ export function PublishedImageGallery(props: { count={images.length} index={lightboxIndex} onIndexChange={setLightboxIndex} - renderSlide={(i) => } + renderSlide={(i) => } /> ); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx index bbb35899..e2f00cb7 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -472,7 +472,7 @@ export let Block = ({
@@ -516,7 +516,9 @@ export let Block = ({ height={b.block.aspectRatio?.height} width={b.block.aspectRatio?.width} className={`${isFullBleed ? "w-full border-none" : "rounded-lg border border-transparent "} ${className}`} - src={blobRefToSrc(b.block.image.ref, did)} + src={blobRefToSrc(b.block.image.ref, did, undefined, { + width: 2000, + })} /> {b.block.alt && } diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx index 76c2a93d..0e79ed58 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -132,10 +132,7 @@ export async function PublicationPageRenderer({ firstBatch, await getProfiles(bylineDidsForPosts(firstBatch)), ); - return [ - key, - { uris: ordered.map((p) => p.uri), initialPosts }, - ] as const; + return [key, { uris: ordered.map((p) => p.uri), initialPosts }] as const; }), ); @@ -201,7 +198,14 @@ export async function PublicationPageRenderer({ pageWidth={normalizedPublication?.theme?.pageWidth} iconUrl={ normalizedPublication?.icon - ? blobRefToSrc(normalizedPublication.icon.ref, did) + ? blobRefToSrc( + normalizedPublication.icon.ref, + did, + undefined, + { + width: 360, + }, + ) : undefined } wordmark={wordmarkFromTheme(normalizedPublication?.theme, did)} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx index b1a5d80c..9a0ceb60 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx @@ -109,7 +109,7 @@ let Block = async ({
@@ -123,7 +123,7 @@ let Block = async ({ alt={b.block.alt} height={b.block.aspectRatio?.height} width={b.block.aspectRatio?.width} - src={blobRefToSrc(b.block.image.ref, did, baseUrl)} + src={blobRefToSrc(b.block.image.ref, did, baseUrl, { width: 800 })} /> ); } @@ -141,7 +141,7 @@ let Block = async ({ alt={image.alt} height={image.aspectRatio.height} width={image.aspectRatio.width} - src={blobRefToSrc(image.image.ref, did, baseUrl)} + src={blobRefToSrc(image.image.ref, did, baseUrl, { width: 800 })} /> ))}
@@ -202,7 +202,9 @@ function ListItem(props: { className={`listMarker shrink-0 mx-2 z-1 mt-[14px] h-[5px] w-[5px] rounded-full bg-secondary`} /> {isChecklist && ( -
+
{props.item.checked ? : }
)} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts b/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts index 96a96618..677f09a5 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts +++ b/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts @@ -1,8 +1,8 @@ import { ogScreenshotResponse } from "src/utils/screenshotPage"; import { supabaseServerClient } from "supabase/serverClient"; import { jsonToLex } from "@atproto/lexicon"; -import { fetchAtprotoBlob } from "app/api/atproto_images/route"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { coverImageRedirect } from "src/utils/ogCoverImageRedirect"; import { resolveDocumentFilter } from "../resolveDocumentFilter"; // OG content is effectively immutable post-publish, and each regeneration is a @@ -30,28 +30,11 @@ export default async function OpenGraphImage(props: { if (document) { const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); if (docRecord?.coverImage) { - try { - // Get CID from the blob ref (handle both serialized and hydrated forms) - let cid = - (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || - docRecord.coverImage.ref.toString(); - - let imageResponse = await fetchAtprotoBlob(did, cid); - if (imageResponse) { - let imageBlob = await imageResponse.blob(); - - // Return the image with appropriate headers - return new Response(imageBlob, { - headers: { - "Content-Type": imageBlob.type || "image/jpeg", - "Cache-Control": "public, max-age=3600", - }, - }); - } - } catch (e) { - // Fall through to screenshot if cover image fetch fails - console.error("Failed to fetch cover image:", e); - } + // Get CID from the blob ref (handle both serialized and hydrated forms) + let cid = + (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || + docRecord.coverImage.ref.toString(); + return coverImageRedirect(did, cid); } } diff --git a/app/(app)/lish/[did]/[publication]/archive/page.tsx b/app/(app)/lish/[did]/[publication]/archive/page.tsx index f9747829..212b194a 100644 --- a/app/(app)/lish/[did]/[publication]/archive/page.tsx +++ b/app/(app)/lish/[did]/[publication]/archive/page.tsx @@ -125,7 +125,11 @@ export default async function PublicationArchive(props: { /> }, @@ -35,24 +57,36 @@ export async function GET( if (!record?.icon) return NextResponse.redirect(new URL("/icon.png", request.url)); - let identity = await idResolver.did.resolve(did); - let service = identity?.service?.find((f) => f.id === "#atproto_pds"); - if (!service) - return NextResponse.redirect(new URL("/icon.png", request.url)); let cid = (record.icon.ref as unknown as { $link: string })["$link"]; - const response = await fetch( - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, - ); - let blob = await response.blob(); - let resizedImage = await sharp(await blob.arrayBuffer()) + + // Already resized and stored for this CID? + const existing = await fetch(variantUrl(cid), { method: "HEAD" }); + if (existing.ok) return redirect(variantUrl(cid)); + + const response = await fetchAtprotoBlob(did, cid); + if (!response) + return NextResponse.redirect(new URL("/icon.png", request.url)); + let resizedImage = await sharp(await response.arrayBuffer()) .resize({ width: 32, height: 32 }) + .png() .toBuffer(); + + const { error } = await supabaseServerClient.storage + .from(ICON_BUCKET) + .upload(`${ICON_PREFIX}/${cid}`, new Uint8Array(resizedImage), { + contentType: "image/png", + // storage-js expects seconds, not a header value. + cacheControl: "31536000", + upsert: true, + }); + if (!error) return redirect(variantUrl(cid)); + console.log("failed to store favicon variant", cid, error); + + // Fall back to streaming the resized bytes if storage failed. return new Response(new Uint8Array(resizedImage), { headers: { "Content-Type": "image/png", - "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400", - "Cache-Control": - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", + ...CACHE_HEADERS, }, }); } catch (e) { diff --git a/app/(app)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx b/app/(app)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx index a7a4ecbb..aa514f19 100644 --- a/app/(app)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx +++ b/app/(app)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx @@ -15,7 +15,9 @@ export const SubscribeCard = (props: { }) => { let record = props.record; let iconUrl = record.icon - ? blobRefToSrc(record.icon.ref, new AtUri(props.uri).host) + ? blobRefToSrc(record.icon.ref, new AtUri(props.uri).host, undefined, { + width: 360, + }) : undefined; return (
f.id === "#atproto_pds"); - if (!service) return new NextResponse(null, { status: 404 }); + // Already resized and stored for this CID? + const existing = await fetch(variantUrl(cid), { method: "HEAD" }); + if (existing.ok) return redirect(variantUrl(cid)); - const blobResponse = await fetch( - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, - { - headers: { - "Accept-Encoding": "gzip, deflate, br, zstd", - }, - }, - ); - - if (!blobResponse.ok) { + const blobResponse = await fetchAtprotoBlob(pubUri.host, cid); + if (!blobResponse) { return new NextResponse(null, { status: 404 }); } @@ -137,14 +149,22 @@ export async function GET(req: NextRequest) { .webp({ quality: 90 }) .toBuffer(); - // Return with caching headers + const { error } = await supabaseServerClient.storage + .from(ICON_BUCKET) + .upload(`${ICON_PREFIX}/${cid}`, new Uint8Array(resizedImage), { + contentType: "image/webp", + // storage-js expects seconds, not a header value. + cacheControl: "31536000", + upsert: true, + }); + if (!error) return redirect(variantUrl(cid)); + console.log("failed to store pub icon variant", cid, error); + + // Fall back to streaming the resized bytes if storage failed. return new NextResponse(resizedImage, { headers: { "Content-Type": "image/webp", - // Cache for 1 hour, but serve stale for much longer while revalidating - "Cache-Control": - "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", - "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", + ...CACHE_HEADERS, }, }); } catch (error) { diff --git a/app/api/quote_screenshot/route.ts b/app/api/quote_screenshot/route.ts index 4da87385..79e379e3 100644 --- a/app/api/quote_screenshot/route.ts +++ b/app/api/quote_screenshot/route.ts @@ -20,7 +20,14 @@ export async function GET(req: NextRequest) { let image = await screenshotBskyCardImage(parsed.toString()); if (!image) return new Response("Screenshot failed", { status: 502 }); + // Cache at the edge so the warm-up fetch above actually saves the second + // render when the user posts. return new Response(new Uint8Array(image), { - headers: { "Content-Type": "image/webp" }, + headers: { + "Content-Type": "image/webp", + "Cache-Control": + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", + "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=604800", + }, }); } diff --git a/app/login/confirm/page.tsx b/app/login/confirm/page.tsx index b9267249..b482f033 100644 --- a/app/login/confirm/page.tsx +++ b/app/login/confirm/page.tsx @@ -30,7 +30,12 @@ export default async function LoginConfirmPage(props: { let record = normalizePublicationRecord(publication?.record); if (publication && record) { let iconUrl = record.icon - ? blobRefToSrc(record.icon.ref, new AtUri(publication.uri).host) + ? blobRefToSrc( + record.icon.ref, + new AtUri(publication.uri).host, + undefined, + { width: 360 }, + ) : undefined; return ( { let backgroundImage = themeRecord?.backgroundImage?.image?.ref && uri - ? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host) + ? blobRefToSrc( + themeRecord.backgroundImage.image.ref, + new AtUri(uri).host, + undefined, + { width: 2000 }, + ) : null; let backgroundImageRepeat = themeRecord?.backgroundImage?.repeat; @@ -209,6 +214,8 @@ const PubInfo = (props: { ? blobRefToSrc( props.pubRecord.icon.ref, new AtUri(props.uri).host, + undefined, + { width: 360 }, ) : undefined } diff --git a/components/ThemeManager/PublicationThemeProvider.tsx b/components/ThemeManager/PublicationThemeProvider.tsx index b6776c45..4320ac5f 100644 --- a/components/ThemeManager/PublicationThemeProvider.tsx +++ b/components/ThemeManager/PublicationThemeProvider.tsx @@ -90,6 +90,8 @@ export function PublicationBackgroundProvider(props: { ? blobRefToSrc( props.record?.theme?.backgroundImage?.image?.ref, props.pub_creator, + undefined, + { width: 2000 }, ) : null; diff --git a/emails/post.tsx b/emails/post.tsx index e1fcafea..ace76f32 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -1135,7 +1135,9 @@ const BlockRenderer = ({ ); } if (PubLeafletBlocksImage.isMain(block)) { - const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl); + const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl, { + width: 800, + }); // Deliberately no numeric `width`/`height` HTML attributes: Outlook // honors those over CSS `max-width`, so a 1200px natural-size photo // would blow out our 28rem container. `max-width: px` keeps @@ -1152,7 +1154,9 @@ const BlockRenderer = ({ } if (PubLeafletBlocksWebsite.isMain(block)) { const previewSrc = block.previewImage - ? blobRefToSrc(block.previewImage.ref, did, assetsBaseUrl) + ? blobRefToSrc(block.previewImage.ref, did, assetsBaseUrl, { + width: 360, + }) : undefined; return ( + ); } // default to "medium" to match the draft (StandardSitePostBlock), @@ -1275,9 +1283,7 @@ const BlockRenderer = ({ /> ); } - return ( - - ); + return ; }; // Matches the published web renderer's notice when a referenced standard-site diff --git a/emails/standardSiteBlocks.tsx b/emails/standardSiteBlocks.tsx index 2123efc6..72fbbf58 100644 --- a/emails/standardSiteBlocks.tsx +++ b/emails/standardSiteBlocks.tsx @@ -244,7 +244,10 @@ export const StandardSitePostEmailBlock = ({ // publication theme, else the post's own theme; standalone (no publication) // flips usePubTheme to standalone defaults. const cardTheme = showPublicationTheme - ? resolveCardTheme([post.publication?.record, post.record], !post.publication) + ? resolveCardTheme( + [post.publication?.record, post.record], + !post.publication, + ) : outerCardTheme(theme); const colors = resolveColors(cardTheme); const cardBg = cardTheme.pageBackground; @@ -257,9 +260,7 @@ export const StandardSitePostEmailBlock = ({ const authorLabel = post.contributors.length > 0 ? formatBylineNames( - post.contributors - .map(bylineLabel) - .filter((l): l is string => !!l), + post.contributors.map(bylineLabel).filter((l): l is string => !!l), ) || undefined : bylineLabel(post.author ?? { displayName: null, handle: null }); // Same Intl options as the web's LocalizedDate; UTC matches its SSR default @@ -272,8 +273,7 @@ export const StandardSitePostEmailBlock = ({ timeZone: "UTC", }) : undefined; - const description = - post.record.description || getFirstParagraph(post.record); + const description = post.record.description || getFirstParagraph(post.record); let postDid: string | undefined; try { @@ -293,14 +293,15 @@ export const StandardSitePostEmailBlock = ({ let pubDid: string | undefined; try { - pubDid = post.publication ? new AtUri(post.publication.uri).host : undefined; + pubDid = post.publication + ? new AtUri(post.publication.uri).host + : undefined; } catch { pubDid = undefined; } const showPubInfo = !!post.publication?.record && - (!currentPublicationUri || - post.publication.uri !== currentPublicationUri); + (!currentPublicationUri || post.publication.uri !== currentPublicationUri); const pubInfo = showPubInfo && post.publication?.record ? ( {title} {size !== "small" && description ? ( - + {description} ) : null} @@ -483,7 +483,7 @@ export const StandardSitePublicationEmailBlock = ({ const iconSrc = record.icon && host - ? blobRefToSrc(record.icon.ref, host, assetsBaseUrl) + ? blobRefToSrc(record.icon.ref, host, assetsBaseUrl, { width: 360 }) : undefined; const authorLabel = author ? bylineLabel(author) : undefined; diff --git a/src/utils/bskyPostEmbed.ts b/src/utils/bskyPostEmbed.ts index c62b12a0..812d8c34 100644 --- a/src/utils/bskyPostEmbed.ts +++ b/src/utils/bskyPostEmbed.ts @@ -41,7 +41,9 @@ export function bskyPostEmbed(args: { title: pub.name, icon: pub.icon && args.pubOwnerDid - ? blobRefToSrc(pub.icon.ref, args.pubOwnerDid) + ? blobRefToSrc(pub.icon.ref, args.pubOwnerDid, undefined, { + width: 360, + }) : undefined, }, associatedRefs: [ diff --git a/src/utils/ogCoverImageRedirect.ts b/src/utils/ogCoverImageRedirect.ts new file mode 100644 index 00000000..d8eaccef --- /dev/null +++ b/src/utils/ogCoverImageRedirect.ts @@ -0,0 +1,17 @@ +// 302 to the cached /api/atproto_images proxy for a post's cover image, used +// by the post opengraph-image routes. width=1200 requests a variant sized for +// unfurl cards instead of the full-resolution blob. Absolute URL because some +// unfurl bots mishandle a relative Location. +export function coverImageRedirect(did: string, cid: string) { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; + return new Response(null, { + status: 302, + headers: { + Location: `${baseUrl.replace(/\/$/, "")}/api/atproto_images?did=${did}&cid=${cid}&width=1200&v=1`, + "Cache-Control": + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", + "CDN-Cache-Control": + "public, s-maxage=86400, stale-while-revalidate=604800", + }, + }); +} diff --git a/src/utils/screenshotPage.ts b/src/utils/screenshotPage.ts index 62ea1458..5dd91c98 100644 --- a/src/utils/screenshotPage.ts +++ b/src/utils/screenshotPage.ts @@ -102,6 +102,7 @@ export async function ogScreenshotResponse( // each miss is a multi-second remote-browser render. "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", + "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=604800", }, }); } diff --git a/src/utils/wordmark.ts b/src/utils/wordmark.ts index 1333ac18..b5250c5f 100644 --- a/src/utils/wordmark.ts +++ b/src/utils/wordmark.ts @@ -14,7 +14,11 @@ export function wordmarkFromTheme( const wordmark = theme?.wordmark; if (!wordmark?.image?.ref) return null; return { - src: blobRefToSrc(wordmark.image.ref, did), + // Request a variant sized for the configured display width (2x for hidpi) + // instead of the full-resolution blob. + src: blobRefToSrc(wordmark.image.ref, did, undefined, { + width: (wordmark.width ?? 400) * 2, + }), width: wordmark.width ?? undefined, }; } From 3f03075ee713dcc079d7f5f9795c133d66feccbb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 23:31:16 +0000 Subject: [PATCH 2/3] Use the 1200 retina tier for web body images; single-hop OG redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the image audit: - Web post-body and gallery images were requesting the 800 tier, but the image-width ladder's 1200 tier exists precisely for document-body images at retina density (~600px column x 2) — at 800 photographs would render slightly soft on 2x displays. StaticPostContent and PublishedImageGallery now request 1200; the email renderers keep 800 (smaller columns, proxied images) and lightboxes keep full resolution. - The post OG cover path was a two-hop redirect chain (opengraph-image -> /api/atproto_images -> supabase.co), crossing origins on the second hop — og-image fetchers are flaky enough that chains risk broken unfurls. The variant-ensuring logic in /api/atproto_images is now extracted as ensureResizedVariantCached() and the OG routes redirect straight to the storage variant URL in one hop. This also restores the screenshot fallback when the cover blob can't be fetched, which the previous blind redirect had dropped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TG1Z2RXovEdV9MZVqc5yDM --- .../[rkey]/Blocks/PublishedImageGallery.tsx | 6 +- .../[rkey]/StaticPostContent.tsx | 4 +- .../[publication]/[rkey]/opengraph-image.ts | 4 +- .../p/[didOrHandle]/[rkey]/opengraph-image.ts | 4 +- app/api/atproto_images/route.ts | 109 ++++++++++-------- src/utils/ogCoverImageRedirect.ts | 27 +++-- 6 files changed, 95 insertions(+), 59 deletions(-) diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx index 746dbdb9..dfe7c797 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx @@ -24,8 +24,8 @@ export function PublishedImageGallery(props: { did: string; }) { let { block, did } = props; - // Grid/strip/carousel cells render well under 800px, so they get a resized - // variant; the lightbox gets the full-resolution blob. + // Grid/strip/carousel cells get the 1200 tier (document-body column at + // retina density); the lightbox gets the full-resolution blob. let { images, fullImages } = useMemo(() => { let toImage = ( i: PubLeafletBlocksImageGallery.Image, @@ -37,7 +37,7 @@ export function PublishedImageGallery(props: { height: i.aspectRatio.height, }); return { - images: block.images.map((i) => toImage(i, { width: 800 })), + images: block.images.map((i) => toImage(i, { width: 1200 })), fullImages: block.images.map((i) => toImage(i)), }; }, [block.images, did]); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx index 9a0ceb60..42803f8d 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx @@ -123,7 +123,7 @@ let Block = async ({ alt={b.block.alt} height={b.block.aspectRatio?.height} width={b.block.aspectRatio?.width} - src={blobRefToSrc(b.block.image.ref, did, baseUrl, { width: 800 })} + src={blobRefToSrc(b.block.image.ref, did, baseUrl, { width: 1200 })} /> ); } @@ -141,7 +141,7 @@ let Block = async ({ alt={image.alt} height={image.aspectRatio.height} width={image.aspectRatio.width} - src={blobRefToSrc(image.image.ref, did, baseUrl, { width: 800 })} + src={blobRefToSrc(image.image.ref, did, baseUrl, { width: 1200 })} /> ))}
diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts b/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts index 677f09a5..a71fb44f 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts +++ b/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts @@ -34,7 +34,9 @@ export default async function OpenGraphImage(props: { let cid = (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || docRecord.coverImage.ref.toString(); - return coverImageRedirect(did, cid); + let res = await coverImageRedirect(did, cid); + if (res) return res; + // Fall through to screenshot if the cover couldn't be cached } } diff --git a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts b/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts index 2b5da021..e694bd39 100644 --- a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts +++ b/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts @@ -45,7 +45,9 @@ export default async function OpenGraphImage(props: { let cid = (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || docRecord.coverImage.ref.toString(); - return coverImageRedirect(did, cid); + let res = await coverImageRedirect(did, cid); + if (res) return res; + // Fall through to screenshot if the cover couldn't be cached } } } diff --git a/app/api/atproto_images/route.ts b/app/api/atproto_images/route.ts index 24627f9c..d705853b 100644 --- a/app/api/atproto_images/route.ts +++ b/app/api/atproto_images/route.ts @@ -114,6 +114,63 @@ async function ensureOriginalCached( return { url, bytes }; } +/** + * Ensures a resized variant of the blob is cached in storage (resized once + * with sharp — Supabase's image transform bills per origin image per month) + * and returns its public URL. Falls back to the cached original's URL when a + * variant can't be produced, and null when nothing could be cached at all. + * Width/height are expected to already be snapped to the image-width ladder. + */ +export async function ensureResizedVariantCached( + did: string, + cid: string, + width?: number, + height?: number, +): Promise { + const variantPath = `${COVER_IMAGE_PREFIX}/resized/w${width ?? 0}-h${height ?? 0}/${cid}`; + const variantUrl = publicUrl(variantPath); + const existing = await fetch(variantUrl, { method: "HEAD" }); + if (existing.ok) return variantUrl; + + const original = await ensureOriginalCached(did, cid); + if (!original) return null; + let bytes = original.bytes; + if (!bytes) { + const res = await fetch(original.url); + if (res.ok) bytes = await res.arrayBuffer(); + } + if (bytes) { + try { + // rotate() applies EXIF orientation, which imgproxy did + // implicitly. withoutEnlargement mirrors imgproxy's no-upscale + // default. + const resized = await sharp(Buffer.from(bytes), { animated: true }) + .rotate() + .resize({ + width, + height, + fit: "inside", + withoutEnlargement: true, + }) + .toBuffer({ resolveWithObject: true }); + const { error } = await supabaseServerClient.storage + .from(COVER_IMAGE_BUCKET) + .upload(variantPath, new Uint8Array(resized.data), { + contentType: `image/${resized.info.format}`, + cacheControl: "31536000", + upsert: true, + }); + if (!error) return variantUrl; + console.log("failed to store cover variant", variantPath, error); + } catch (e) { + console.log("failed to resize cover image", e); + } + } + // Couldn't produce a variant; the cached original still beats + // streaming the full blob. + return original.url; +} + export async function GET(req: NextRequest) { const url = new URL(req.url); const params = { @@ -125,51 +182,13 @@ export async function GET(req: NextRequest) { const height = parseDimension(url.searchParams.get("height")); if (width || height) { - // Thumbnail: resized once with sharp (Supabase's image transform bills - // per origin image per month), stored, and served by redirect. - const variantPath = `${COVER_IMAGE_PREFIX}/resized/w${width ?? 0}-h${height ?? 0}/${params.cid}`; - const variantUrl = publicUrl(variantPath); - const existing = await fetch(variantUrl, { method: "HEAD" }); - if (existing.ok) return redirect(variantUrl); - - const original = await ensureOriginalCached(params.did, params.cid); - if (original) { - let bytes = original.bytes; - if (!bytes) { - const res = await fetch(original.url); - if (res.ok) bytes = await res.arrayBuffer(); - } - if (bytes) { - try { - // rotate() applies EXIF orientation, which imgproxy did - // implicitly. withoutEnlargement mirrors imgproxy's no-upscale - // default. - const resized = await sharp(Buffer.from(bytes), { animated: true }) - .rotate() - .resize({ - width, - height, - fit: "inside", - withoutEnlargement: true, - }) - .toBuffer({ resolveWithObject: true }); - const { error } = await supabaseServerClient.storage - .from(COVER_IMAGE_BUCKET) - .upload(variantPath, new Uint8Array(resized.data), { - contentType: `image/${resized.info.format}`, - cacheControl: "31536000", - upsert: true, - }); - if (!error) return redirect(variantUrl); - console.log("failed to store cover variant", variantPath, error); - } catch (e) { - console.log("failed to resize cover image", e); - } - } - // Couldn't produce a variant; the cached original still beats - // streaming the full blob through this function. - return redirect(original.url); - } + const variantUrl = await ensureResizedVariantCached( + params.did, + params.cid, + width, + height, + ); + if (variantUrl) return redirect(variantUrl); // Fall through to streaming if nothing could be cached. } else { const original = await ensureOriginalCached(params.did, params.cid); diff --git a/src/utils/ogCoverImageRedirect.ts b/src/utils/ogCoverImageRedirect.ts index d8eaccef..fec37c23 100644 --- a/src/utils/ogCoverImageRedirect.ts +++ b/src/utils/ogCoverImageRedirect.ts @@ -1,13 +1,26 @@ -// 302 to the cached /api/atproto_images proxy for a post's cover image, used -// by the post opengraph-image routes. width=1200 requests a variant sized for -// unfurl cards instead of the full-resolution blob. Absolute URL because some -// unfurl bots mishandle a relative Location. -export function coverImageRedirect(did: string, cid: string) { - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; +import { ensureResizedVariantCached } from "app/api/atproto_images/route"; +import { snapToImageWidth } from "supabase/imageSizes"; + +// 302 straight to the storage-cached cover variant for a post's +// opengraph-image route. A single hop rather than bouncing through +// /api/atproto_images: og-image fetchers are the flakiest HTTP clients around, +// and a two-hop cross-origin chain is where they give up. width=1200 requests +// a variant sized for unfurl cards instead of the full-resolution blob. +// Returns null when no variant could be cached so callers can fall back to +// the screenshot. +export async function coverImageRedirect(did: string, cid: string) { + const url = await ensureResizedVariantCached( + did, + cid, + snapToImageWidth(1200), + ); + if (!url) return null; return new Response(null, { status: 302, headers: { - Location: `${baseUrl.replace(/\/$/, "")}/api/atproto_images?did=${did}&cid=${cid}&width=1200&v=1`, + // A day, not a year: a republish can swap the cover image at the same + // route URL. + Location: url, "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", "CDN-Cache-Control": From 501aa0b8419ea1625e99369ff3e8ba91aab1aa04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 01:08:25 +0000 Subject: [PATCH 3/3] Simplify the image-caching changes - Move the blob-fetch and storage-cache machinery (fetchAtprotoBlob, ensureOriginalCached, ensureResizedVariantCached) out of the atproto_images route file into src/utils/atprotoImages, so library code no longer lives in (or is imported from) an HTTP endpoint module. The route is now a thin GET wrapper. - Extract the shared store-and-redirect scaffolding the two icon routes each open-coded (public-URL builder, HEAD check, upload with the storage-js seconds quirk, failure fallback) into one primitive, ensureDerivedImageCached(path, produce); each icon route now supplies only its resize step and its own cache headers. - Type transform widths as the ImageWidth union (360|800|1200|2000) via a d.ts companion to imageSizes.js, instead of naming each tier: call sites keep plain literals with no imports and the compiler rejects off-ladder values. The one computed width (wordmark) snaps explicitly. - coverImageRedirect takes the blob ref and does the $link||toString() extraction itself instead of duplicating it in both OG routes, and drops a no-op snapToImageWidth(1200). - PublishedImageGallery builds the full-resolution slide lazily in renderSlide instead of materializing a second parallel array that's unused until the lightbox opens. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TG1Z2RXovEdV9MZVqc5yDM --- .../[rkey]/Blocks/PublishedImageGallery.tsx | 39 ++-- .../[publication]/[rkey]/opengraph-image.ts | 6 +- .../lish/[did]/[publication]/icon/route.ts | 64 +++---- .../p/[didOrHandle]/[rkey]/opengraph-image.ts | 6 +- app/api/atproto_images/route.ts | 148 +-------------- app/api/pub_icon/route.ts | 81 +++----- src/utils/atprotoImages.ts | 176 ++++++++++++++++++ src/utils/blobRefToSrc.ts | 5 +- src/utils/ogCoverImageRedirect.ts | 24 ++- src/utils/uploadCoverImageThumb.ts | 2 +- src/utils/wordmark.ts | 3 +- supabase/imageSizes.d.ts | 8 + 12 files changed, 289 insertions(+), 273 deletions(-) create mode 100644 src/utils/atprotoImages.ts create mode 100644 supabase/imageSizes.d.ts diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx index dfe7c797..52dd328f 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx @@ -19,28 +19,31 @@ import { LightboxSlide, } from "components/Blocks/ImageGalleryBlock/ImageGalleryLightbox"; +function toImage( + i: PubLeafletBlocksImageGallery.Image, + did: string, + transform?: Parameters[3], +): GalleryImage { + return { + src: blobRefToSrc(i.image.ref, did, undefined, transform), + alt: i.alt || "", + width: i.aspectRatio.width, + height: i.aspectRatio.height, + }; +} + export function PublishedImageGallery(props: { block: PubLeafletBlocksImageGallery.Main; did: string; }) { let { block, did } = props; // Grid/strip/carousel cells get the 1200 tier (document-body column at - // retina density); the lightbox gets the full-resolution blob. - let { images, fullImages } = useMemo(() => { - let toImage = ( - i: PubLeafletBlocksImageGallery.Image, - transform?: { width: number }, - ): GalleryImage => ({ - src: blobRefToSrc(i.image.ref, did, undefined, transform), - alt: i.alt || "", - width: i.aspectRatio.width, - height: i.aspectRatio.height, - }); - return { - images: block.images.map((i) => toImage(i, { width: 1200 })), - fullImages: block.images.map((i) => toImage(i)), - }; - }, [block.images, did]); + // retina density); the lightbox gets the full-resolution blob, built + // per-slide only when it's actually opened. + let images = useMemo( + () => block.images.map((i) => toImage(i, did, { width: 1200 })), + [block.images, did], + ); let [lightboxIndex, setLightboxIndex] = useState(null); let openLightbox = (index: number) => setLightboxIndex(index); @@ -83,7 +86,9 @@ export function PublishedImageGallery(props: { count={images.length} index={lightboxIndex} onIndexChange={setLightboxIndex} - renderSlide={(i) => } + renderSlide={(i) => ( + + )} />
); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts b/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts index a71fb44f..8fda8b0c 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts +++ b/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts @@ -30,11 +30,7 @@ export default async function OpenGraphImage(props: { if (document) { const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); if (docRecord?.coverImage) { - // Get CID from the blob ref (handle both serialized and hydrated forms) - let cid = - (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || - docRecord.coverImage.ref.toString(); - let res = await coverImageRedirect(did, cid); + let res = await coverImageRedirect(did, docRecord.coverImage.ref); if (res) return res; // Fall through to screenshot if the cover couldn't be cached } diff --git a/app/(app)/lish/[did]/[publication]/icon/route.ts b/app/(app)/lish/[did]/[publication]/icon/route.ts index 0b8df83b..802fbbf2 100644 --- a/app/(app)/lish/[did]/[publication]/icon/route.ts +++ b/app/(app)/lish/[did]/[publication]/icon/route.ts @@ -3,13 +3,15 @@ import { supabaseServerClient } from "supabase/serverClient"; import sharp from "sharp"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; -import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { + ensureDerivedImageCached, + fetchAtprotoBlob, +} from "src/utils/atprotoImages"; export const dynamic = "force-dynamic"; // Favicon variants are keyed by CID, so they're immutable; the redirect // response itself stays short-lived since the record's icon can change. -const ICON_BUCKET = "url-previews"; const ICON_PREFIX = "pub-icon/w32"; const CACHE_HEADERS = { "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400", @@ -17,20 +19,6 @@ const CACHE_HEADERS = { "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", }; -function variantUrl(cid: string) { - return `${process.env.NEXT_PUBLIC_SUPABASE_API_URL}/storage/v1/object/public/${ICON_BUCKET}/${ICON_PREFIX}/${cid}`; -} - -// Bytes are served off Supabase's CDN rather than streamed through this -// function: bytes leaving Vercel bill at fast data transfer rates while -// Supabase serves the same bytes as cheap cached egress. -function redirect(to: string) { - return new NextResponse(null, { - status: 302, - headers: { Location: to, ...CACHE_HEADERS }, - }); -} - export async function GET( request: NextRequest, props: { params: Promise<{ did: string; publication: string }> }, @@ -59,31 +47,31 @@ export async function GET( let cid = (record.icon.ref as unknown as { $link: string })["$link"]; - // Already resized and stored for this CID? - const existing = await fetch(variantUrl(cid), { method: "HEAD" }); - if (existing.ok) return redirect(variantUrl(cid)); - - const response = await fetchAtprotoBlob(did, cid); - if (!response) - return NextResponse.redirect(new URL("/icon.png", request.url)); - let resizedImage = await sharp(await response.arrayBuffer()) - .resize({ width: 32, height: 32 }) - .png() - .toBuffer(); - - const { error } = await supabaseServerClient.storage - .from(ICON_BUCKET) - .upload(`${ICON_PREFIX}/${cid}`, new Uint8Array(resizedImage), { - contentType: "image/png", - // storage-js expects seconds, not a header value. - cacheControl: "31536000", - upsert: true, + let resized: Uint8Array | null = null; + const url = await ensureDerivedImageCached( + `${ICON_PREFIX}/${cid}`, + async () => { + const response = await fetchAtprotoBlob(did, cid); + if (!response) return null; + resized = new Uint8Array( + await sharp(await response.arrayBuffer()) + .resize({ width: 32, height: 32 }) + .png() + .toBuffer(), + ); + return { bytes: resized, contentType: "image/png" }; + }, + ); + if (url) + return new NextResponse(null, { + status: 302, + headers: { Location: url, ...CACHE_HEADERS }, }); - if (!error) return redirect(variantUrl(cid)); - console.log("failed to store favicon variant", cid, error); + if (!resized) + return NextResponse.redirect(new URL("/icon.png", request.url)); // Fall back to streaming the resized bytes if storage failed. - return new Response(new Uint8Array(resizedImage), { + return new Response(resized, { headers: { "Content-Type": "image/png", ...CACHE_HEADERS, diff --git a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts b/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts index e694bd39..36f2ffef 100644 --- a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts +++ b/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts @@ -41,11 +41,7 @@ export default async function OpenGraphImage(props: { if (document) { const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); if (docRecord?.coverImage) { - // Get CID from the blob ref (handle both serialized and hydrated forms) - let cid = - (docRecord.coverImage.ref as unknown as { $link: string })["$link"] || - docRecord.coverImage.ref.toString(); - let res = await coverImageRedirect(did, cid); + let res = await coverImageRedirect(did, docRecord.coverImage.ref); if (res) return res; // Fall through to screenshot if the cover couldn't be cached } diff --git a/app/api/atproto_images/route.ts b/app/api/atproto_images/route.ts index d705853b..104f1e07 100644 --- a/app/api/atproto_images/route.ts +++ b/app/api/atproto_images/route.ts @@ -1,61 +1,26 @@ export const runtime = "nodejs"; -import { IdResolver } from "@atproto/identity"; import { NextRequest, NextResponse } from "next/server"; -import sharp from "sharp"; -import { supabaseServerClient } from "supabase/serverClient"; -import { snapToImageWidth } from "supabase/imageSizes"; +import { snapToImageWidth, type ImageWidth } from "supabase/imageSizes"; +import { + ensureOriginalCached, + ensureResizedVariantCached, + fetchAtprotoBlob, +} from "src/utils/atprotoImages"; -let idResolver = new IdResolver(); - -// Reuse the existing image-cache bucket (see app/api/link_previews/route.ts). -const COVER_IMAGE_BUCKET = "url-previews"; -const COVER_IMAGE_PREFIX = "post-cover"; const CACHE_CONTROL = "public, max-age=31536000, immutable, s-maxage=86400, stale-while-revalidate=604800"; // CID-addressed content never changes, so cache it at the edge for a year. const CDN_CACHE_CONTROL = "public, s-maxage=31536000, immutable, stale-while-revalidate=604800"; -/** - * Fetches a blob from an AT Protocol PDS given a DID and CID - * Returns the Response object or null if the blob couldn't be fetched - */ -export async function fetchAtprotoBlob( - did: string, - cid: string, -): Promise { - if (!did || !cid) return null; - - let identity = await idResolver.did.resolve(did); - let service = identity?.service?.find((f) => f.id === "#atproto_pds"); - if (!service) return null; - - const response = await fetch( - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, - { - headers: { - "Accept-Encoding": "gzip, deflate, br, zstd", - }, - }, - ); - - if (!response.ok) return null; - - return response; -} - -function parseDimension(value: string | null): number | undefined { +function parseDimension(value: string | null): ImageWidth | undefined { if (!value) return undefined; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) return undefined; return snapToImageWidth(parsed); } -function publicUrl(path: string) { - return `${process.env.NEXT_PUBLIC_SUPABASE_API_URL}/storage/v1/object/public/${COVER_IMAGE_BUCKET}/${path}`; -} - // Bytes are served straight off Supabase's CDN rather than streamed through // this function: bytes leaving Vercel bill at fast data transfer rates // (currently in overage), while Supabase serves the same bytes as cheap @@ -72,105 +37,6 @@ function redirect(to: string) { }); } -/** - * Ensures the original PDS blob is cached in storage with a well-formed - * Cache-Control. (Uploads from before the storage-js seconds fix stored a - * malformed `max-age=public, ...` header, which defeats CDN caching — those - * get re-uploaded once.) Returns the object's public URL, plus the bytes - * when they already passed through here so callers can reuse them without - * another round trip. Null when the blob can't be produced at that URL. - */ -async function ensureOriginalCached( - did: string, - cid: string, -): Promise<{ url: string; bytes: ArrayBuffer | null } | null> { - if (!did || !cid) return null; - const path = `${COVER_IMAGE_PREFIX}/${cid}`; - const url = publicUrl(path); - - const head = await fetch(url, { method: "HEAD" }); - const malformed = (head.headers.get("cache-control") ?? "").includes( - "max-age=public", - ); - if (head.ok && !malformed) return { url, bytes: null }; - - let source = head.ok ? await fetch(url) : await fetchAtprotoBlob(did, cid); - if (!source || !source.ok) return null; - const bytes = await source.arrayBuffer(); - - const { error } = await supabaseServerClient.storage - .from(COVER_IMAGE_BUCKET) - .upload(path, bytes, { - contentType: source.headers.get("content-type") || undefined, - // storage-js expects seconds, not a header value. - cacheControl: "31536000", - upsert: true, - }); - if (error) { - console.log("failed to cache cover image", error); - // With nothing stored at the URL there is nothing to redirect to. - if (!head.ok) return null; - } - return { url, bytes }; -} - -/** - * Ensures a resized variant of the blob is cached in storage (resized once - * with sharp — Supabase's image transform bills per origin image per month) - * and returns its public URL. Falls back to the cached original's URL when a - * variant can't be produced, and null when nothing could be cached at all. - * Width/height are expected to already be snapped to the image-width ladder. - */ -export async function ensureResizedVariantCached( - did: string, - cid: string, - width?: number, - height?: number, -): Promise { - const variantPath = `${COVER_IMAGE_PREFIX}/resized/w${width ?? 0}-h${height ?? 0}/${cid}`; - const variantUrl = publicUrl(variantPath); - const existing = await fetch(variantUrl, { method: "HEAD" }); - if (existing.ok) return variantUrl; - - const original = await ensureOriginalCached(did, cid); - if (!original) return null; - let bytes = original.bytes; - if (!bytes) { - const res = await fetch(original.url); - if (res.ok) bytes = await res.arrayBuffer(); - } - if (bytes) { - try { - // rotate() applies EXIF orientation, which imgproxy did - // implicitly. withoutEnlargement mirrors imgproxy's no-upscale - // default. - const resized = await sharp(Buffer.from(bytes), { animated: true }) - .rotate() - .resize({ - width, - height, - fit: "inside", - withoutEnlargement: true, - }) - .toBuffer({ resolveWithObject: true }); - const { error } = await supabaseServerClient.storage - .from(COVER_IMAGE_BUCKET) - .upload(variantPath, new Uint8Array(resized.data), { - contentType: `image/${resized.info.format}`, - cacheControl: "31536000", - upsert: true, - }); - if (!error) return variantUrl; - console.log("failed to store cover variant", variantPath, error); - } catch (e) { - console.log("failed to resize cover image", e); - } - } - // Couldn't produce a variant; the cached original still beats - // streaming the full blob. - return original.url; -} - export async function GET(req: NextRequest) { const url = new URL(req.url); const params = { diff --git a/app/api/pub_icon/route.ts b/app/api/pub_icon/route.ts index 432e65aa..b18dd2e6 100644 --- a/app/api/pub_icon/route.ts +++ b/app/api/pub_icon/route.ts @@ -1,7 +1,10 @@ import { AtUri } from "@atproto/syntax"; import { NextRequest, NextResponse } from "next/server"; import { supabaseServerClient } from "supabase/serverClient"; -import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { + ensureDerivedImageCached, + fetchAtprotoBlob, +} from "src/utils/atprotoImages"; import { normalizePublicationRecord, type NormalizedPublication, @@ -15,10 +18,8 @@ import sharp from "sharp"; export const runtime = "nodejs"; -// Reuse the image-cache bucket (see app/api/atproto_images/route.ts). Variants -// are keyed by CID, so they're immutable; the redirect response itself stays -// short-lived since the publication record's icon can change. -const ICON_BUCKET = "url-previews"; +// Variants are keyed by CID, so they're immutable; the redirect response +// itself stays short-lived since the publication record's icon can change. const ICON_PREFIX = "pub-icon/w96"; const CACHE_HEADERS = { "Cache-Control": @@ -26,20 +27,6 @@ const CACHE_HEADERS = { "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", }; -function variantUrl(cid: string) { - return `${process.env.NEXT_PUBLIC_SUPABASE_API_URL}/storage/v1/object/public/${ICON_BUCKET}/${ICON_PREFIX}/${cid}`; -} - -// Bytes are served off Supabase's CDN rather than streamed through this -// function: bytes leaving Vercel bill at fast data transfer rates while -// Supabase serves the same bytes as cheap cached egress. -function redirect(to: string) { - return new NextResponse(null, { - status: 302, - headers: { Location: to, ...CACHE_HEADERS }, - }); -} - export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; const bgColor = searchParams.get("bg") || "#57822B"; @@ -128,40 +115,34 @@ export async function GET(req: NextRequest) { "$link" ]; - // Already resized and stored for this CID? - const existing = await fetch(variantUrl(cid), { method: "HEAD" }); - if (existing.ok) return redirect(variantUrl(cid)); - - const blobResponse = await fetchAtprotoBlob(pubUri.host, cid); - if (!blobResponse) { - return new NextResponse(null, { status: 404 }); - } - - // Get the image buffer - const imageBuffer = await blobResponse.arrayBuffer(); - - // Resize to 96x96 using Sharp - const resizedImage = await sharp(Buffer.from(imageBuffer)) - .resize(96, 96, { - fit: "cover", - position: "center", - }) - .webp({ quality: 90 }) - .toBuffer(); - - const { error } = await supabaseServerClient.storage - .from(ICON_BUCKET) - .upload(`${ICON_PREFIX}/${cid}`, new Uint8Array(resizedImage), { - contentType: "image/webp", - // storage-js expects seconds, not a header value. - cacheControl: "31536000", - upsert: true, + let resized: Uint8Array | null = null; + const url = await ensureDerivedImageCached( + `${ICON_PREFIX}/${cid}`, + async () => { + const blobResponse = await fetchAtprotoBlob(pubUri.host, cid); + if (!blobResponse) return null; + // Resize to 96x96 using Sharp + resized = new Uint8Array( + await sharp(Buffer.from(await blobResponse.arrayBuffer())) + .resize(96, 96, { + fit: "cover", + position: "center", + }) + .webp({ quality: 90 }) + .toBuffer(), + ); + return { bytes: resized, contentType: "image/webp" }; + }, + ); + if (url) + return new NextResponse(null, { + status: 302, + headers: { Location: url, ...CACHE_HEADERS }, }); - if (!error) return redirect(variantUrl(cid)); - console.log("failed to store pub icon variant", cid, error); + if (!resized) return new NextResponse(null, { status: 404 }); // Fall back to streaming the resized bytes if storage failed. - return new NextResponse(resizedImage, { + return new NextResponse(resized, { headers: { "Content-Type": "image/webp", ...CACHE_HEADERS, diff --git a/src/utils/atprotoImages.ts b/src/utils/atprotoImages.ts new file mode 100644 index 00000000..6af3bd57 --- /dev/null +++ b/src/utils/atprotoImages.ts @@ -0,0 +1,176 @@ +import { IdResolver } from "@atproto/identity"; +import sharp from "sharp"; +import { supabaseServerClient } from "supabase/serverClient"; +import type { ImageWidth } from "supabase/imageSizes"; + +// Cached-image machinery shared by /api/atproto_images, the publication icon +// routes, and the post OG images. Images derived from PDS blobs are stored in +// the url-previews bucket and served off Supabase's CDN by redirect rather +// than streamed through Vercel: bytes leaving Vercel bill at fast data +// transfer rates (currently in overage), while Supabase serves the same bytes +// as cheap cached egress. + +let idResolver = new IdResolver(); + +// Reuse the existing image-cache bucket (see app/api/link_previews/route.ts). +const IMAGE_CACHE_BUCKET = "url-previews"; +const COVER_IMAGE_PREFIX = "post-cover"; +// storage-js expects seconds, not a header value. +const STORAGE_CACHE_SECONDS = "31536000"; + +export function imageCacheUrl(path: string) { + return `${process.env.NEXT_PUBLIC_SUPABASE_API_URL}/storage/v1/object/public/${IMAGE_CACHE_BUCKET}/${path}`; +} + +/** + * Fetches a blob from an AT Protocol PDS given a DID and CID + * Returns the Response object or null if the blob couldn't be fetched + */ +export async function fetchAtprotoBlob( + did: string, + cid: string, +): Promise { + if (!did || !cid) return null; + + let identity = await idResolver.did.resolve(did); + let service = identity?.service?.find((f) => f.id === "#atproto_pds"); + if (!service) return null; + + const response = await fetch( + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, + { + headers: { + "Accept-Encoding": "gzip, deflate, br, zstd", + }, + }, + ); + + if (!response.ok) return null; + + return response; +} + +/** + * Ensures the original PDS blob is cached in storage with a well-formed + * Cache-Control. (Uploads from before the storage-js seconds fix stored a + * malformed `max-age=public, ...` header, which defeats CDN caching — those + * get re-uploaded once.) Returns the object's public URL, plus the bytes + * when they already passed through here so callers can reuse them without + * another round trip. Null when the blob can't be produced at that URL. + */ +export async function ensureOriginalCached( + did: string, + cid: string, +): Promise<{ url: string; bytes: ArrayBuffer | null } | null> { + if (!did || !cid) return null; + const path = `${COVER_IMAGE_PREFIX}/${cid}`; + const url = imageCacheUrl(path); + + const head = await fetch(url, { method: "HEAD" }); + const malformed = (head.headers.get("cache-control") ?? "").includes( + "max-age=public", + ); + if (head.ok && !malformed) return { url, bytes: null }; + + let source = head.ok ? await fetch(url) : await fetchAtprotoBlob(did, cid); + if (!source || !source.ok) return null; + const bytes = await source.arrayBuffer(); + + const { error } = await supabaseServerClient.storage + .from(IMAGE_CACHE_BUCKET) + .upload(path, bytes, { + contentType: source.headers.get("content-type") || undefined, + cacheControl: STORAGE_CACHE_SECONDS, + upsert: true, + }); + if (error) { + console.log("failed to cache cover image", error); + // With nothing stored at the URL there is nothing to redirect to. + if (!head.ok) return null; + } + return { url, bytes }; +} + +/** + * Ensures a derived image exists at `path` in the cache bucket, calling + * `produce` for the bytes on a miss. Returns its public URL, or null when the + * image couldn't be produced or stored. + */ +export async function ensureDerivedImageCached( + path: string, + produce: () => Promise<{ bytes: Uint8Array; contentType: string } | null>, +): Promise { + const url = imageCacheUrl(path); + const head = await fetch(url, { method: "HEAD" }); + if (head.ok) return url; + + const produced = await produce(); + if (!produced) return null; + + const { error } = await supabaseServerClient.storage + .from(IMAGE_CACHE_BUCKET) + .upload(path, produced.bytes, { + contentType: produced.contentType, + cacheControl: STORAGE_CACHE_SECONDS, + upsert: true, + }); + if (error) { + console.log("failed to store derived image", path, error); + return null; + } + return url; +} + +/** + * Ensures a resized variant of the blob is cached in storage (resized once + * with sharp — Supabase's image transform bills per origin image per month) + * and returns its public URL. Falls back to the cached original's URL when a + * variant can't be produced, and null when nothing could be cached at all. + */ +export async function ensureResizedVariantCached( + did: string, + cid: string, + width?: ImageWidth, + height?: ImageWidth, +): Promise { + const variantUrl = await ensureDerivedImageCached( + `${COVER_IMAGE_PREFIX}/resized/w${width ?? 0}-h${height ?? 0}/${cid}`, + async () => { + const original = await ensureOriginalCached(did, cid); + if (!original) return null; + let bytes = original.bytes; + if (!bytes) { + const res = await fetch(original.url); + if (res.ok) bytes = await res.arrayBuffer(); + } + if (!bytes) return null; + try { + // rotate() applies EXIF orientation, which imgproxy did + // implicitly. withoutEnlargement mirrors imgproxy's no-upscale + // default. + const resized = await sharp(Buffer.from(bytes), { animated: true }) + .rotate() + .resize({ + width, + height, + fit: "inside", + withoutEnlargement: true, + }) + .toBuffer({ resolveWithObject: true }); + return { + bytes: new Uint8Array(resized.data), + contentType: `image/${resized.info.format}`, + }; + } catch (e) { + console.log("failed to resize cover image", e); + return null; + } + }, + ); + if (variantUrl) return variantUrl; + // Couldn't produce a variant; the cached original (if any) still beats + // streaming the full blob. Re-checking it costs a HEAD on this rare path + // only. + const original = await ensureOriginalCached(did, cid); + return original?.url ?? null; +} diff --git a/src/utils/blobRefToSrc.ts b/src/utils/blobRefToSrc.ts index f5490b8c..fc43233b 100644 --- a/src/utils/blobRefToSrc.ts +++ b/src/utils/blobRefToSrc.ts @@ -1,4 +1,5 @@ import { BlobRef } from "@atproto/lexicon"; +import type { ImageWidth } from "supabase/imageSizes"; // `baseUrl` produces an absolute URL (needed for outbound email where the proxy // path can't resolve against the document origin). @@ -13,7 +14,7 @@ export const blobRefToSrc = ( b: BlobRef["ref"], did: string, baseUrl?: string, - transform?: { width?: number; height?: number }, + transform?: { width?: ImageWidth; height?: ImageWidth }, ) => { const link = (b as unknown as { $link: string })["$link"]; if (link.startsWith("http://") || link.startsWith("https://")) return link; @@ -27,4 +28,4 @@ export const blobRefToSrc = ( // Display widths (px) for cover-image thumbnails, used to request a right-sized // transform instead of shipping the full-resolution blob. -export const COVER_THUMBNAIL_WIDTH = { large: 800, medium: 360 }; +export const COVER_THUMBNAIL_WIDTH = { large: 800, medium: 360 } as const; diff --git a/src/utils/ogCoverImageRedirect.ts b/src/utils/ogCoverImageRedirect.ts index fec37c23..4c81ecf6 100644 --- a/src/utils/ogCoverImageRedirect.ts +++ b/src/utils/ogCoverImageRedirect.ts @@ -1,19 +1,17 @@ -import { ensureResizedVariantCached } from "app/api/atproto_images/route"; -import { snapToImageWidth } from "supabase/imageSizes"; +import { BlobRef } from "@atproto/lexicon"; +import { ensureResizedVariantCached } from "src/utils/atprotoImages"; // 302 straight to the storage-cached cover variant for a post's // opengraph-image route. A single hop rather than bouncing through -// /api/atproto_images: og-image fetchers are the flakiest HTTP clients around, -// and a two-hop cross-origin chain is where they give up. width=1200 requests -// a variant sized for unfurl cards instead of the full-resolution blob. -// Returns null when no variant could be cached so callers can fall back to -// the screenshot. -export async function coverImageRedirect(did: string, cid: string) { - const url = await ensureResizedVariantCached( - did, - cid, - snapToImageWidth(1200), - ); +// /api/atproto_images: og-image fetchers are the flakiest HTTP clients +// around, and a two-hop cross-origin chain is where they give up. 1200 (a +// ladder width) requests a variant sized for unfurl cards instead of the +// full-resolution blob. Returns null when no variant could be cached so +// callers can fall back to the screenshot. +export async function coverImageRedirect(did: string, ref: BlobRef["ref"]) { + // Handle both serialized and hydrated forms of the blob ref + const cid = (ref as unknown as { $link: string })["$link"] || ref.toString(); + const url = await ensureResizedVariantCached(did, cid, 1200); if (!url) return null; return new Response(null, { status: 302, diff --git a/src/utils/uploadCoverImageThumb.ts b/src/utils/uploadCoverImageThumb.ts index 1083484a..678cd570 100644 --- a/src/utils/uploadCoverImageThumb.ts +++ b/src/utils/uploadCoverImageThumb.ts @@ -1,6 +1,6 @@ import sharp from "sharp"; import { BlobRef } from "@atproto/api"; -import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { fetchAtprotoBlob } from "src/utils/atprotoImages"; // Fetch a document's cover image from its author's PDS, resize it to a Bluesky // external-card thumbnail (1200x630 webp), and upload it via `uploadThumb`. diff --git a/src/utils/wordmark.ts b/src/utils/wordmark.ts index b5250c5f..a2da4256 100644 --- a/src/utils/wordmark.ts +++ b/src/utils/wordmark.ts @@ -1,4 +1,5 @@ import type { PubLeafletPublication } from "lexicons/api"; +import { snapToImageWidth } from "supabase/imageSizes"; import { blobRefToSrc } from "./blobRefToSrc"; export type WordmarkData = { src: string; width?: number }; @@ -17,7 +18,7 @@ export function wordmarkFromTheme( // Request a variant sized for the configured display width (2x for hidpi) // instead of the full-resolution blob. src: blobRefToSrc(wordmark.image.ref, did, undefined, { - width: (wordmark.width ?? 400) * 2, + width: snapToImageWidth((wordmark.width ?? 400) * 2), }), width: wordmark.width ?? undefined, }; diff --git a/supabase/imageSizes.d.ts b/supabase/imageSizes.d.ts new file mode 100644 index 00000000..dc19f84e --- /dev/null +++ b/supabase/imageSizes.d.ts @@ -0,0 +1,8 @@ +// Type companion for imageSizes.js (which stays CommonJS so next.config.js +// can require it) — keep the union in sync with IMAGE_WIDTHS there. Typing +// widths as this union lets call sites request tiers with plain literals +// while the compiler rejects off-ladder values. +export type ImageWidth = 360 | 800 | 1200 | 2000; +// Mutable array type because next.config.js assigns it to `deviceSizes`. +export const IMAGE_WIDTHS: ImageWidth[]; +export function snapToImageWidth(width: number): ImageWidth;