a tool for shared writing and social publishing
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}