forked from tangled.org/core
Monorepo for Tangled
at icy/tolqpt 7.3 kB view raw
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};