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