Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
at master 288 lines 8.6 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 const format = searchParams.get("format") || "webp"; 154 const validFormats = ["webp", "jpeg", "png"]; 155 const outputFormat = validFormats.includes(format) ? format : "webp"; 156 157 const contentTypes = { 158 webp: "image/webp", 159 jpeg: "image/jpeg", 160 png: "image/png", 161 }; 162 163 const cache = caches.default; 164 let cacheKey = request.url; 165 let response = await cache.match(cacheKey); 166 if (response) return response; 167 168 const pathParts = pathname.slice(1).split("/"); 169 if (pathParts.length < 2) { 170 return new Response("Bad URL", { status: 400 }); 171 } 172 173 const [signatureHex, actor] = pathParts; 174 const actorBytes = new TextEncoder().encode(actor); 175 176 const key = await crypto.subtle.importKey( 177 "raw", 178 new TextEncoder().encode(env.AVATAR_SHARED_SECRET), 179 { name: "HMAC", hash: "SHA-256" }, 180 false, 181 ["sign", "verify"], 182 ); 183 184 const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes); 185 const computedSig = Array.from(new Uint8Array(computedSigBuffer)) 186 .map((b) => b.toString(16).padStart(2, "0")) 187 .join(""); 188 189 console.log({ 190 level: "debug", 191 message: "avatar request for: " + actor, 192 computedSignature: computedSig, 193 providedSignature: signatureHex, 194 }); 195 196 const sigBytes = Uint8Array.from( 197 signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)), 198 ); 199 const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes); 200 201 if (!valid) { 202 return new Response("Invalid signature", { status: 403 }); 203 } 204 205 try { 206 let avatarUrl = null; 207 208 // Try to get Tangled avatar from user's PDS first 209 avatarUrl = await getTangledAvatarFromPDS(actor); 210 211 // If no Tangled avatar, fall back to Bluesky 212 if (!avatarUrl) { 213 console.log({ 214 level: "debug", 215 message: "no Tangled avatar, falling back to Bluesky", 216 actor: actor, 217 }); 218 219 const profileResponse = await fetch( 220 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, 221 ); 222 223 if (profileResponse.ok) { 224 const profile = await profileResponse.json(); 225 avatarUrl = profile.avatar; 226 } 227 } 228 229 if (!avatarUrl) { 230 // Generate a random color based on the actor string 231 console.log({ 232 level: "debug", 233 message: "no avatar found, generating placeholder", 234 actor: actor, 235 }); 236 237 const bgColor = stringToColor(actor); 238 const size = resizeToTiny ? 32 : 128; 239 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>`; 240 const svgData = new TextEncoder().encode(svg); 241 242 response = new Response(svgData, { 243 headers: { 244 "Content-Type": "image/svg+xml", 245 "Cache-Control": "public, max-age=43200", 246 }, 247 }); 248 await cache.put(cacheKey, response.clone()); 249 return response; 250 } 251 252 // Fetch and optionally resize the avatar 253 let avatarResponse; 254 const cfOptions = outputFormat !== "webp" || resizeToTiny ? { 255 cf: { 256 image: { 257 format: outputFormat, 258 ...(resizeToTiny ? { width: 32, height: 32, fit: "cover" } : {}), 259 }, 260 }, 261 }: {}; 262 263 avatarResponse = await fetch(avatarUrl, cfOptions); 264 265 if (!avatarResponse.ok) { 266 return new Response(`failed to fetch avatar for ${actor}.`, { 267 status: avatarResponse.status, 268 }); 269 } 270 271 const avatarData = await avatarResponse.arrayBuffer(); 272 273 response = new Response(avatarData, { 274 headers: { 275 "Content-Type": contentTypes[outputFormat], 276 "Cache-Control": "public, max-age=43200", 277 }, 278 }); 279 280 await cache.put(cacheKey, response.clone()); 281 return response; 282 } catch (error) { 283 return new Response(`error fetching avatar: ${error.message}`, { 284 status: 500, 285 }); 286 } 287 }, 288};