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