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.2 kB 305 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 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};