From 2b3304bd339117c58cd13b6b55f0519d79907108 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Wed, 9 Jul 2025 15:06:14 +0300 Subject: [PATCH] avatar: use cf workers image resizing api to serve tiny imgs Change-Id: ymlyvrmsmrslxtmtunlxsxnvousmurto Signed-off-by: Anirudh Oppiliappan --- avatar/src/index.js | 193 ++++++++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 86 deletions(-) diff --git a/avatar/src/index.js b/avatar/src/index.js index 5ac1fdd..923b3cb 100644 --- a/avatar/src/index.js +++ b/avatar/src/index.js @@ -1,88 +1,109 @@ export default { - async fetch(request, env) { - const url = new URL(request.url); - const { pathname } = url; - - if (!pathname || pathname === '/') { - return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. -You can't use this directly unforunately since all requests are signed and may only originate from the appview.`); - } - - const cache = caches.default; - - let cacheKey = request.url; - let response = await cache.match(cacheKey); - if (response) { - return response; - } - - const pathParts = pathname.slice(1).split('/'); - if (pathParts.length < 2) { - return new Response('Bad URL', { status: 400 }); - } - - const [signatureHex, actor] = pathParts; - - const actorBytes = new TextEncoder().encode(actor); - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(env.AVATAR_SHARED_SECRET), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign', 'verify'], - ); - - const computedSigBuffer = await crypto.subtle.sign('HMAC', key, actorBytes); - const computedSig = Array.from(new Uint8Array(computedSigBuffer)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - - console.log({ - level: 'debug', - message: 'avatar request for: ' + actor, - computedSignature: computedSig, - providedSignature: signatureHex, - }); - - const sigBytes = Uint8Array.from(signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16))); - const valid = await crypto.subtle.verify('HMAC', key, sigBytes, actorBytes); - - if (!valid) { - return new Response('Invalid signature', { status: 403 }); - } - - try { - const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, { method: 'GET' }); - const profile = await profileResponse.json(); - const avatar = profile.avatar; - - if (!avatar) { - return new Response(`avatar not found for ${actor}.`, { status: 404 }); - } - - // fetch the actual avatar image - const avatarResponse = await fetch(avatar); - if (!avatarResponse.ok) { - return new Response(`failed to fetch avatar for ${actor}.`, { status: avatarResponse.status }); - } - - const avatarData = await avatarResponse.arrayBuffer(); - const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; - - response = new Response(avatarData, { - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=43200', // 12 h - }, - }); - - // cache it in cf using request.url as the key - await cache.put(cacheKey, response.clone()); - - return response; - } catch (error) { - return new Response(`error fetching avatar: ${error.message}`, { status: 500 }); - } - }, + async fetch(request, env) { + const url = new URL(request.url); + const { pathname, searchParams } = url; + + if (!pathname || pathname === "/") { + return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. +You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`); + } + + const size = searchParams.get("size"); + const resizeToTiny = size === "tiny"; + + const cache = caches.default; + let cacheKey = request.url; + let response = await cache.match(cacheKey); + if (response) return response; + + const pathParts = pathname.slice(1).split("/"); + if (pathParts.length < 2) { + return new Response("Bad URL", { status: 400 }); + } + + const [signatureHex, actor] = pathParts; + const actorBytes = new TextEncoder().encode(actor); + + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(env.AVATAR_SHARED_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); + + const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes); + const computedSig = Array.from(new Uint8Array(computedSigBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + console.log({ + level: "debug", + message: "avatar request for: " + actor, + computedSignature: computedSig, + providedSignature: signatureHex, + }); + + const sigBytes = Uint8Array.from( + signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)), + ); + const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes); + + if (!valid) { + return new Response("Invalid signature", { status: 403 }); + } + + try { + const profileResponse = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, + ); + const profile = await profileResponse.json(); + const avatar = profile.avatar; + + if (!avatar) { + return new Response(`avatar not found for ${actor}.`, { status: 404 }); + } + + // Resize if requested + let avatarResponse; + if (resizeToTiny) { + avatarResponse = await fetch(avatar, { + cf: { + image: { + width: 32, + height: 32, + fit: "cover", + format: "webp", + }, + }, + }); + } else { + avatarResponse = await fetch(avatar); + } + + if (!avatarResponse.ok) { + return new Response(`failed to fetch avatar for ${actor}.`, { + status: avatarResponse.status, + }); + } + + const avatarData = await avatarResponse.arrayBuffer(); + const contentType = + avatarResponse.headers.get("content-type") || "image/jpeg"; + + response = new Response(avatarData, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=43200", + }, + }); + + await cache.put(cacheKey, response.clone()); + return response; + } catch (error) { + return new Response(`error fetching avatar: ${error.message}`, { + status: 500, + }); + } + }, }; -- 2.43.0