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) => { [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, built + // per-slide only when it's actually opened. let images = useMemo( - () => - block.images.map((i) => ({ - src: blobRefToSrc(i.image.ref, did), - alt: i.alt || "", - width: i.aspectRatio.width, - height: i.aspectRatio.height, - })), + () => block.images.map((i) => toImage(i, did, { width: 1200 })), [block.images, did], ); @@ -76,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]/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 4d2c3fab..d3ec6aed 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -138,10 +138,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; }), ); @@ -207,7 +204,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..42803f8d 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: 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)} + src={blobRefToSrc(image.image.ref, did, baseUrl, { width: 1200 })} /> ))}
@@ -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..8fda8b0c 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,9 @@ 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); - } + 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]/archive/page.tsx b/app/(app)/lish/[did]/[publication]/archive/page.tsx index 325c9a47..7205f1a9 100644 --- a/app/(app)/lish/[did]/[publication]/archive/page.tsx +++ b/app/(app)/lish/[did]/[publication]/archive/page.tsx @@ -130,7 +130,11 @@ export default async function PublicationArchive(props: { /> }, @@ -35,24 +45,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 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" }; + }, ); - let blob = await response.blob(); - let resizedImage = await sharp(await blob.arrayBuffer()) - .resize({ width: 32, height: 32 }) - .toBuffer(); - return new Response(new Uint8Array(resizedImage), { + if (url) + return new NextResponse(null, { + status: 302, + headers: { Location: url, ...CACHE_HEADERS }, + }); + if (!resized) + return NextResponse.redirect(new URL("/icon.png", request.url)); + + // Fall back to streaming the resized bytes if storage failed. + return new Response(resized, { 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 (
{ - 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,48 +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 }; -} - export async function GET(req: NextRequest) { const url = new URL(req.url); const params = { @@ -125,51 +48,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/app/api/auth/email-login/route.ts b/app/api/auth/email-login/route.ts index 107d23a4..01bd5495 100644 --- a/app/api/auth/email-login/route.ts +++ b/app/api/auth/email-login/route.ts @@ -66,7 +66,8 @@ export async function GET(req: NextRequest) { let publicationUri = subscribePublication || (await publicationUriForHost(new URL(redirect).host)); - if (publicationUri) confirmUrl.searchParams.set("publication", publicationUri); + if (publicationUri) + confirmUrl.searchParams.set("publication", publicationUri); return NextResponse.redirect(confirmUrl.toString()); } @@ -77,7 +78,8 @@ async function resolveSubscriptionEmailContext(publicationUri: string) { .eq("uri", publicationUri) .maybeSingle(); const normalized = normalizePublicationRecord(publication?.record); - const assetsBaseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; + const assetsBaseUrl = + process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; return { publicationName: normalized?.name, publicationUrl: normalized?.url, @@ -86,6 +88,7 @@ async function resolveSubscriptionEmailContext(publicationUri: string) { normalized.icon.ref, new AtUri(publicationUri).host, assetsBaseUrl, + { width: 360 }, ) : undefined, }; diff --git a/app/api/link_previews/route.ts b/app/api/link_previews/route.ts index f9fdf84e..6072d079 100644 --- a/app/api/link_previews/route.ts +++ b/app/api/link_previews/route.ts @@ -71,6 +71,10 @@ async function get_link_image_preview(url: string) { .toBuffer(); await supabase.storage.from("url-previews").upload(key, thumbnail, { contentType: "image/png", + // A day, not a year: the key is a hash of the url and gets upsert- + // overwritten when the link is re-previewed, so an immutable cache + // would pin stale thumbnails. (storage-js expects seconds.) + cacheControl: "86400", upsert: true, }); } else { diff --git a/app/api/pub_icon/route.ts b/app/api/pub_icon/route.ts index 1ee2b9a0..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 { IdResolver } from "@atproto/identity"; import { NextRequest, NextResponse } from "next/server"; import { supabaseServerClient } from "supabase/serverClient"; +import { + ensureDerivedImageCached, + fetchAtprotoBlob, +} from "src/utils/atprotoImages"; import { normalizePublicationRecord, type NormalizedPublication, @@ -13,10 +16,17 @@ import { import { publicationUriFilter } from "src/utils/uriHelpers"; import sharp from "sharp"; -const idResolver = new IdResolver(); - export const runtime = "nodejs"; +// 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": + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", +}; + export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; const bgColor = searchParams.get("bg") || "#57822B"; @@ -92,9 +102,7 @@ export async function GET(req: NextRequest) { return new NextResponse(svg, { headers: { "Content-Type": "image/svg+xml", - "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, }, }); } @@ -107,44 +115,37 @@ export async function GET(req: NextRequest) { "$link" ]; - // Fetch the blob from the PDS - const identity = await idResolver.did.resolve(pubUri.host); - const service = identity?.service?.find((f) => f.id === "#atproto_pds"); - if (!service) return new NextResponse(null, { status: 404 }); - - const blobResponse = await fetch( - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, - { - headers: { - "Accept-Encoding": "gzip, deflate, br, zstd", - }, + 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 (!resized) return new NextResponse(null, { status: 404 }); - if (!blobResponse.ok) { - 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(); - - // Return with caching headers - return new NextResponse(resizedImage, { + // Fall back to streaming the resized bytes if storage failed. + return new NextResponse(resized, { 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/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/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..4c81ecf6 --- /dev/null +++ b/src/utils/ogCoverImageRedirect.ts @@ -0,0 +1,28 @@ +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. 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, + headers: { + // 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": + "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/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 1333ac18..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 }; @@ -14,7 +15,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: 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;