···11+# avatar
22+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 deploy
1010+npx wrangler secrets put AVATAR_SHARED_SECRET
1111+```
+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 image
6565+ 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 key
8181+ 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+};