at default-knot 285 lines 8.4 kB view raw
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};