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 allowedWidths = [26, 34, 42, 52];
162 const requestedSize = searchParams.get("size");
163
164 let width = null;
165 if (requestedSize === "tiny") {
166 width = 32;
167 } else if (allowedWidths.includes(Number(requestedSize))) {
168 width = Number(requestedSize);
169 }
170 const format = searchParams.get("format") || "webp";
171 const validFormats = ["webp", "jpeg", "png"];
172 const outputFormat = validFormats.includes(format) ? format : "webp";
173
174 const contentTypes = {
175 webp: "image/webp",
176 jpeg: "image/jpeg",
177 png: "image/png",
178 };
179
180 const cache = caches.default;
181 let cacheKey = request.url;
182 let response = await cache.match(cacheKey);
183 if (response) return response;
184
185 const pathParts = pathname.slice(1).split("/");
186 if (pathParts.length < 2) {
187 return new Response("Bad URL", { status: 400 });
188 }
189
190 const [signatureHex, actor] = pathParts;
191 const actorBytes = new TextEncoder().encode(actor);
192
193 const key = await crypto.subtle.importKey(
194 "raw",
195 new TextEncoder().encode(env.AVATAR_SHARED_SECRET),
196 { name: "HMAC", hash: "SHA-256" },
197 false,
198 ["sign", "verify"],
199 );
200
201 const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes);
202 const computedSig = Array.from(new Uint8Array(computedSigBuffer))
203 .map((b) => b.toString(16).padStart(2, "0"))
204 .join("");
205
206 console.log({
207 level: "debug",
208 message: "avatar request for: " + actor,
209 computedSignature: computedSig,
210 providedSignature: signatureHex,
211 });
212
213 const sigBytes = Uint8Array.from(
214 signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
215 );
216 const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes);
217
218 if (!valid) {
219 return new Response("Invalid signature", { status: 403 });
220 }
221
222 try {
223 let avatarUrl = null;
224
225 // Try to get Tangled avatar from user's PDS first
226 avatarUrl = await getTangledAvatarFromPDS(actor);
227
228 // If no Tangled avatar, fall back to Bluesky
229 if (!avatarUrl) {
230 console.log({
231 level: "debug",
232 message: "no Tangled avatar, falling back to Bluesky",
233 actor: actor,
234 });
235
236 const profileResponse = await fetch(
237 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`,
238 );
239
240 if (profileResponse.ok) {
241 const profile = await profileResponse.json();
242 avatarUrl = profile.avatar;
243 }
244 }
245
246 if (!avatarUrl) {
247 // Generate a random color based on the actor string
248 console.log({
249 level: "debug",
250 message: "no avatar found, generating placeholder",
251 actor: actor,
252 });
253
254 const bgColor = stringToColor(actor);
255 const size = width ?? 128;
256 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>`;
257 const svgData = new TextEncoder().encode(svg);
258
259 response = new Response(svgData, {
260 headers: {
261 "Content-Type": "image/svg+xml",
262 "Cache-Control": "public, max-age=43200",
263 },
264 });
265 await cache.put(cacheKey, response.clone());
266 return response;
267 }
268
269 // Fetch and optionally resize the avatar
270 let avatarResponse;
271 const cfOptions = outputFormat !== "webp" || width ? {
272 cf: {
273 image: {
274 format: outputFormat,
275 ...(width ? { width, height: width, fit: "cover" } : {}),
276 },
277 },
278 }: {};
279
280 avatarResponse = await fetch(avatarUrl, cfOptions);
281
282 if (!avatarResponse.ok) {
283 return new Response(`failed to fetch avatar for ${actor}.`, {
284 status: avatarResponse.status,
285 });
286 }
287
288 const avatarData = await avatarResponse.arrayBuffer();
289
290 response = new Response(avatarData, {
291 headers: {
292 "Content-Type": contentTypes[outputFormat],
293 "Cache-Control": "public, max-age=43200",
294 },
295 });
296
297 await cache.put(cacheKey, response.clone());
298 return response;
299 } catch (error) {
300 return new Response(`error fetching avatar: ${error.message}`, {
301 status: 500,
302 });
303 }
304 },
305};