a tool for shared writing and social publishing
at feature/small-text 145 lines 4.9 kB view raw
1import { AtUri } from "@atproto/syntax"; 2import { IdResolver } from "@atproto/identity"; 3import { NextRequest, NextResponse } from "next/server"; 4import { PubLeafletPublication } from "lexicons/api"; 5import { supabaseServerClient } from "supabase/serverClient"; 6import sharp from "sharp"; 7 8const idResolver = new IdResolver(); 9 10export const runtime = "nodejs"; 11 12export async function GET(req: NextRequest) { 13 const searchParams = req.nextUrl.searchParams; 14 const bgColor = searchParams.get("bg") || "#0000E1"; 15 const fgColor = searchParams.get("fg") || "#FFFFFF"; 16 17 try { 18 const at_uri = searchParams.get("at_uri"); 19 20 if (!at_uri) { 21 return new NextResponse(null, { status: 400 }); 22 } 23 24 // Parse the AT URI 25 let uri: AtUri; 26 try { 27 uri = new AtUri(at_uri); 28 } catch (e) { 29 return new NextResponse(null, { status: 400 }); 30 } 31 32 let publicationRecord: PubLeafletPublication.Record | null = null; 33 let publicationUri: string; 34 35 // Check if it's a document or publication 36 if (uri.collection === "pub.leaflet.document") { 37 // Query the documents_in_publications table to get the publication 38 const { data: docInPub } = await supabaseServerClient 39 .from("documents_in_publications") 40 .select("publication, publications(record)") 41 .eq("document", at_uri) 42 .single(); 43 44 if (!docInPub || !docInPub.publications) { 45 return new NextResponse(null, { status: 404 }); 46 } 47 48 publicationUri = docInPub.publication; 49 publicationRecord = docInPub.publications 50 .record as PubLeafletPublication.Record; 51 } else if (uri.collection === "pub.leaflet.publication") { 52 // Query the publications table directly 53 const { data: publication } = await supabaseServerClient 54 .from("publications") 55 .select("record, uri") 56 .eq("uri", at_uri) 57 .single(); 58 59 if (!publication || !publication.record) { 60 return new NextResponse(null, { status: 404 }); 61 } 62 63 publicationUri = publication.uri; 64 publicationRecord = publication.record as PubLeafletPublication.Record; 65 } else { 66 // Not a supported collection 67 return new NextResponse(null, { status: 404 }); 68 } 69 70 // Check if the publication has an icon 71 if (!publicationRecord?.icon) { 72 // Generate a placeholder with the first letter of the publication name 73 const firstLetter = (publicationRecord?.name || "?") 74 .slice(0, 1) 75 .toUpperCase(); 76 77 // Create a simple SVG placeholder with theme colors 78 const svg = `<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg"> 79 <rect width="96" height="96" rx="48" ry="48" fill="${bgColor}"/> 80 <text x="50%" y="50%" font-size="64" font-weight="bold" font-family="Arial, Helvetica, sans-serif" fill="${fgColor}" text-anchor="middle" dominant-baseline="central">${firstLetter}</text> 81</svg>`; 82 83 return new NextResponse(svg, { 84 headers: { 85 "Content-Type": "image/svg+xml", 86 "Cache-Control": 87 "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 88 "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 89 }, 90 }); 91 } 92 93 // Parse the publication URI to get the DID 94 const pubUri = new AtUri(publicationUri); 95 96 // Get the CID from the icon blob 97 const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ 98 "$link" 99 ]; 100 101 // Fetch the blob from the PDS 102 const identity = await idResolver.did.resolve(pubUri.host); 103 const service = identity?.service?.find((f) => f.id === "#atproto_pds"); 104 if (!service) return new NextResponse(null, { status: 404 }); 105 106 const blobResponse = await fetch( 107 `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, 108 { 109 headers: { 110 "Accept-Encoding": "gzip, deflate, br, zstd", 111 }, 112 }, 113 ); 114 115 if (!blobResponse.ok) { 116 return new NextResponse(null, { status: 404 }); 117 } 118 119 // Get the image buffer 120 const imageBuffer = await blobResponse.arrayBuffer(); 121 122 // Resize to 96x96 using Sharp 123 const resizedImage = await sharp(Buffer.from(imageBuffer)) 124 .resize(96, 96, { 125 fit: "cover", 126 position: "center", 127 }) 128 .webp({ quality: 90 }) 129 .toBuffer(); 130 131 // Return with caching headers 132 return new NextResponse(resizedImage, { 133 headers: { 134 "Content-Type": "image/webp", 135 // Cache for 1 hour, but serve stale for much longer while revalidating 136 "Cache-Control": 137 "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 138 "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 139 }, 140 }); 141 } catch (error) { 142 console.error("Error fetching publication icon:", error); 143 return new NextResponse(null, { status: 500 }); 144 } 145}