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