1import {
2 LocalActorResolver,
3 CompositeHandleResolver,
4 DohJsonHandleResolver,
5 WellKnownHandleResolver,
6 CompositeDidDocumentResolver,
7 PlcDidDocumentResolver,
8 WebDidDocumentResolver,
9} from "@atcute/identity-resolver";
10
11// Initialize resolvers for Cloudflare Workers
12const handleResolver = new CompositeHandleResolver({
13 strategy: "race",
14 methods: {
15 dns: new DohJsonHandleResolver({
16 dohUrl: "https://cloudflare-dns.com/dns-query",
17 }),
18 http: new WellKnownHandleResolver(),
19 },
20});
21
22const didDocumentResolver = new CompositeDidDocumentResolver({
23 methods: {
24 plc: new PlcDidDocumentResolver(),
25 web: new WebDidDocumentResolver(),
26 },
27});
28
29const actorResolver = new LocalActorResolver({
30 handleResolver,
31 didDocumentResolver,
32});
33
34export default {
35 async fetch(request, env) {
36 // Helper function to generate a color from a string
37 const stringToColor = (str) => {
38 let hash = 0;
39 for (let i = 0; i < str.length; i++) {
40 hash = str.charCodeAt(i) + ((hash << 5) - hash);
41 }
42 let color = "#";
43 for (let i = 0; i < 3; i++) {
44 const value = (hash >> (i * 8)) & 0xff;
45 color += ("00" + value.toString(16)).substr(-2);
46 }
47 return color;
48 };
49
50 // Helper function to fetch Tangled profile from PDS
51 const getTangledAvatarFromPDS = async (actor) => {
52 try {
53 // Resolve the identity
54 const identity = await actorResolver.resolve(actor);
55 if (!identity) {
56 console.log({
57 level: "debug",
58 message: "failed to resolve identity",
59 actor: actor,
60 });
61 return null;
62 }
63
64 const did = identity.did;
65 const pdsEndpoint = identity.pds.replace(/\/$/, ""); // Remove trailing slash
66
67 if (!pdsEndpoint) {
68 console.log({
69 level: "debug",
70 message: "no PDS endpoint found",
71 actor: actor,
72 did: did,
73 });
74 return null;
75 }
76
77 const profileUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=sh.tangled.actor.profile&rkey=self`;
78
79 // Fetch the Tangled profile record from PDS
80 const profileResponse = await fetch(profileUrl);
81
82 if (!profileResponse.ok) {
83 console.log({
84 level: "debug",
85 message: "no Tangled profile found on PDS",
86 actor: actor,
87 status: profileResponse.status,
88 });
89 return null;
90 }
91
92 const profileData = await profileResponse.json();
93 const avatarBlob = profileData?.value?.avatar;
94
95 if (!avatarBlob) {
96 console.log({
97 level: "debug",
98 message: "Tangled profile has no avatar",
99 actor: actor,
100 });
101 return null;
102 }
103
104 // Extract CID from blob reference object
105 // The ref might be an object with $link property or a string
106 let avatarCID;
107 if (typeof avatarBlob.ref === "string") {
108 avatarCID = avatarBlob.ref;
109 } else if (avatarBlob.ref?.$link) {
110 avatarCID = avatarBlob.ref.$link;
111 } else if (typeof avatarBlob === "string") {
112 avatarCID = avatarBlob;
113 }
114
115 if (!avatarCID || typeof avatarCID !== "string") {
116 console.log({
117 level: "warn",
118 message: "could not extract valid CID from avatar blob",
119 actor: actor,
120 avatarBlob: avatarBlob,
121 avatarBlobRef: avatarBlob.ref,
122 });
123 return null;
124 }
125
126 // Construct blob URL (pdsEndpoint already has trailing slash removed)
127 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCID}`;
128
129 return blobUrl;
130 } catch (e) {
131 console.log({
132 level: "warn",
133 message: "error fetching Tangled avatar from PDS",
134 actor: actor,
135 error: e.message,
136 });
137 return null;
138 }
139 };
140
141 const url = new URL(request.url);
142 const { pathname, searchParams } = url;
143
144 if (!pathname || pathname === "/") {
145 return new Response(
146 `This is Tangled's avatar service. It fetches your pretty avatar from your PDS, Bluesky, or generates a placeholder.
147You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`,
148 );
149 }
150
151 const size = searchParams.get("size");
152 const resizeToTiny = size === "tiny";
153
154 const cache = caches.default;
155 let cacheKey = request.url;
156 let response = await cache.match(cacheKey);
157 if (response) return response;
158
159 const pathParts = pathname.slice(1).split("/");
160 if (pathParts.length < 2) {
161 return new Response("Bad URL", { status: 400 });
162 }
163
164 const [signatureHex, actor] = pathParts;
165 const actorBytes = new TextEncoder().encode(actor);
166
167 const key = await crypto.subtle.importKey(
168 "raw",
169 new TextEncoder().encode(env.AVATAR_SHARED_SECRET),
170 { name: "HMAC", hash: "SHA-256" },
171 false,
172 ["sign", "verify"],
173 );
174
175 const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes);
176 const computedSig = Array.from(new Uint8Array(computedSigBuffer))
177 .map((b) => b.toString(16).padStart(2, "0"))
178 .join("");
179
180 console.log({
181 level: "debug",
182 message: "avatar request for: " + actor,
183 computedSignature: computedSig,
184 providedSignature: signatureHex,
185 });
186
187 const sigBytes = Uint8Array.from(
188 signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
189 );
190 const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes);
191
192 if (!valid) {
193 return new Response("Invalid signature", { status: 403 });
194 }
195
196 try {
197 let avatarUrl = null;
198
199 // Try to get Tangled avatar from user's PDS first
200 avatarUrl = await getTangledAvatarFromPDS(actor);
201
202 // If no Tangled avatar, fall back to Bluesky
203 if (!avatarUrl) {
204 console.log({
205 level: "debug",
206 message: "no Tangled avatar, falling back to Bluesky",
207 actor: actor,
208 });
209
210 const profileResponse = await fetch(
211 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`,
212 );
213
214 if (profileResponse.ok) {
215 const profile = await profileResponse.json();
216 avatarUrl = profile.avatar;
217 }
218 }
219
220 if (!avatarUrl) {
221 // Generate a random color based on the actor string
222 console.log({
223 level: "debug",
224 message: "no avatar found, generating placeholder",
225 actor: actor,
226 });
227
228 const bgColor = stringToColor(actor);
229 const size = resizeToTiny ? 32 : 128;
230 const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
231 const svgData = new TextEncoder().encode(svg);
232
233 response = new Response(svgData, {
234 headers: {
235 "Content-Type": "image/svg+xml",
236 "Cache-Control": "public, max-age=43200",
237 },
238 });
239 await cache.put(cacheKey, response.clone());
240 return response;
241 }
242
243 // Fetch and optionally resize the avatar
244 let avatarResponse;
245 if (resizeToTiny) {
246 avatarResponse = await fetch(avatarUrl, {
247 cf: {
248 image: {
249 width: 32,
250 height: 32,
251 fit: "cover",
252 format: "webp",
253 },
254 },
255 });
256 } else {
257 avatarResponse = await fetch(avatarUrl);
258 }
259
260 if (!avatarResponse.ok) {
261 return new Response(`failed to fetch avatar for ${actor}.`, {
262 status: avatarResponse.status,
263 });
264 }
265
266 const avatarData = await avatarResponse.arrayBuffer();
267 const contentType =
268 avatarResponse.headers.get("content-type") || "image/jpeg";
269
270 response = new Response(avatarData, {
271 headers: {
272 "Content-Type": contentType,
273 "Cache-Control": "public, max-age=43200",
274 },
275 });
276
277 await cache.put(cacheKey, response.clone());
278 return response;
279 } catch (error) {
280 return new Response(`error fetching avatar: ${error.message}`, {
281 status: 500,
282 });
283 }
284 },
285};