forked from tangled.org/core
this repo has no description
1export default { 2 async fetch(request, env) { 3 // Helper function to generate a color from a string 4 const stringToColor = (str) => { 5 let hash = 0; 6 for (let i = 0; i < str.length; i++) { 7 hash = str.charCodeAt(i) + ((hash << 5) - hash); 8 } 9 let color = "#"; 10 for (let i = 0; i < 3; i++) { 11 const value = (hash >> (i * 8)) & 0xff; 12 color += ("00" + value.toString(16)).substr(-2); 13 } 14 return color; 15 }; 16 17 const url = new URL(request.url); 18 const { pathname, searchParams } = url; 19 20 if (!pathname || pathname === "/") { 21 return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. 22You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`); 23 } 24 25 const size = searchParams.get("size"); 26 const resizeToTiny = size === "tiny"; 27 28 const cache = caches.default; 29 let cacheKey = request.url; 30 let response = await cache.match(cacheKey); 31 if (response) return response; 32 33 const pathParts = pathname.slice(1).split("/"); 34 if (pathParts.length < 2) { 35 return new Response("Bad URL", { status: 400 }); 36 } 37 38 const [signatureHex, actor] = pathParts; 39 const actorBytes = new TextEncoder().encode(actor); 40 41 const key = await crypto.subtle.importKey( 42 "raw", 43 new TextEncoder().encode(env.AVATAR_SHARED_SECRET), 44 { name: "HMAC", hash: "SHA-256" }, 45 false, 46 ["sign", "verify"], 47 ); 48 49 const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes); 50 const computedSig = Array.from(new Uint8Array(computedSigBuffer)) 51 .map((b) => b.toString(16).padStart(2, "0")) 52 .join(""); 53 54 console.log({ 55 level: "debug", 56 message: "avatar request for: " + actor, 57 computedSignature: computedSig, 58 providedSignature: signatureHex, 59 }); 60 61 const sigBytes = Uint8Array.from( 62 signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)), 63 ); 64 const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes); 65 66 if (!valid) { 67 return new Response("Invalid signature", { status: 403 }); 68 } 69 70 try { 71 const profileResponse = await fetch( 72 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, 73 ); 74 const profile = await profileResponse.json(); 75 const avatar = profile.avatar; 76 77 let avatarUrl = profile.avatar; 78 79 if (!avatarUrl) { 80 // Generate a random color based on the actor string 81 const bgColor = stringToColor(actor); 82 const size = resizeToTiny ? 32 : 128; 83 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>`; 84 const svgData = new TextEncoder().encode(svg); 85 86 response = new Response(svgData, { 87 headers: { 88 "Content-Type": "image/svg+xml", 89 "Cache-Control": "public, max-age=43200", 90 }, 91 }); 92 await cache.put(cacheKey, response.clone()); 93 return response; 94 } 95 96 // Resize if requested 97 let avatarResponse; 98 if (resizeToTiny) { 99 avatarResponse = await fetch(avatarUrl, { 100 cf: { 101 image: { 102 width: 32, 103 height: 32, 104 fit: "cover", 105 format: "webp", 106 }, 107 }, 108 }); 109 } else { 110 avatarResponse = await fetch(avatarUrl); 111 } 112 113 if (!avatarResponse.ok) { 114 return new Response(`failed to fetch avatar for ${actor}.`, { 115 status: avatarResponse.status, 116 }); 117 } 118 119 const avatarData = await avatarResponse.arrayBuffer(); 120 const contentType = 121 avatarResponse.headers.get("content-type") || "image/jpeg"; 122 123 response = new Response(avatarData, { 124 headers: { 125 "Content-Type": contentType, 126 "Cache-Control": "public, max-age=43200", 127 }, 128 }); 129 130 await cache.put(cacheKey, response.clone()); 131 return response; 132 } catch (error) { 133 return new Response(`error fetching avatar: ${error.message}`, { 134 status: 500, 135 }); 136 } 137 }, 138};