This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / avatar / src / index.js
9.0 kB 298 lines
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};