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 { 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}