···11+# avatar22+33+avatar is a small service that fetches your pretty Bluesky avatar and caches it on Cloudflare.44+It uses a shared secret `AVATAR_SHARED_SECRET` to ensure requests only originate from the trusted appview.55+66+It's deployed using `wrangler` like so:77+88+```99+npx wrangler deploy1010+npx wrangler secrets put AVATAR_SHARED_SECRET1111+```
+88
avatar/src/index.js
···11+export default {22+ async fetch(request, env) {33+ const url = new URL(request.url);44+ const { pathname } = url;55+66+ if (!pathname || pathname === '/') {77+ return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare.88+You can't use this directly unforunately since all requests are signed and may only originate from the appview.`);99+ }1010+1111+ const cache = caches.default;1212+1313+ let cacheKey = request.url;1414+ let response = await cache.match(cacheKey);1515+ if (response) {1616+ return response;1717+ }1818+1919+ const pathParts = pathname.slice(1).split('/');2020+ if (pathParts.length < 2) {2121+ return new Response('Bad URL', { status: 400 });2222+ }2323+2424+ const [signatureHex, actor] = pathParts;2525+2626+ const actorBytes = new TextEncoder().encode(actor);2727+2828+ const key = await crypto.subtle.importKey(2929+ 'raw',3030+ new TextEncoder().encode(env.AVATAR_SHARED_SECRET),3131+ { name: 'HMAC', hash: 'SHA-256' },3232+ false,3333+ ['sign', 'verify'],3434+ );3535+3636+ const computedSigBuffer = await crypto.subtle.sign('HMAC', key, actorBytes);3737+ const computedSig = Array.from(new Uint8Array(computedSigBuffer))3838+ .map((b) => b.toString(16).padStart(2, '0'))3939+ .join('');4040+4141+ console.log({4242+ level: 'debug',4343+ message: 'avatar request for: ' + actor,4444+ computedSignature: computedSig,4545+ providedSignature: signatureHex,4646+ });4747+4848+ const sigBytes = Uint8Array.from(signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)));4949+ const valid = await crypto.subtle.verify('HMAC', key, sigBytes, actorBytes);5050+5151+ if (!valid) {5252+ return new Response('Invalid signature', { status: 403 });5353+ }5454+5555+ try {5656+ const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, { method: 'GET' });5757+ const profile = await profileResponse.json();5858+ const avatar = profile.avatar;5959+6060+ if (!avatar) {6161+ return new Response(`avatar not found for ${actor}.`, { status: 404 });6262+ }6363+6464+ // fetch the actual avatar image6565+ const avatarResponse = await fetch(avatar);6666+ if (!avatarResponse.ok) {6767+ return new Response(`failed to fetch avatar for ${actor}.`, { status: avatarResponse.status });6868+ }6969+7070+ const avatarData = await avatarResponse.arrayBuffer();7171+ const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg';7272+7373+ response = new Response(avatarData, {7474+ headers: {7575+ 'Content-Type': contentType,7676+ 'Cache-Control': 'public, max-age=3600',7777+ },7878+ });7979+8080+ // cache it in cf using request.url as the key8181+ await cache.put(cacheKey, response.clone());8282+8383+ return response;8484+ } catch (error) {8585+ return new Response(`error fetching avatar: ${error.message}`, { status: 500 });8686+ }8787+ },8888+};