forked from tangled.org/core
this repo has no description

avatar: use cf workers image resizing api to serve tiny imgs

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

authored by anirudh.fi and committed by Tangled 0caa2162 33365e5e

Changed files
+90 -69
avatar
src
+90 -69
avatar/src/index.js
··· 1 1 export default { 2 - async fetch(request, env) { 3 - const url = new URL(request.url); 4 - const { pathname } = url; 2 + async fetch(request, env) { 3 + const url = new URL(request.url); 4 + const { pathname, searchParams } = url; 5 5 6 - if (!pathname || pathname === '/') { 7 - return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. 8 - You can't use this directly unforunately since all requests are signed and may only originate from the appview.`); 9 - } 6 + if (!pathname || pathname === "/") { 7 + return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. 8 + You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`); 9 + } 10 10 11 - const cache = caches.default; 11 + const size = searchParams.get("size"); 12 + const resizeToTiny = size === "tiny"; 12 13 13 - let cacheKey = request.url; 14 - let response = await cache.match(cacheKey); 15 - if (response) { 16 - return response; 17 - } 14 + const cache = caches.default; 15 + let cacheKey = request.url; 16 + let response = await cache.match(cacheKey); 17 + if (response) return response; 18 18 19 - const pathParts = pathname.slice(1).split('/'); 20 - if (pathParts.length < 2) { 21 - return new Response('Bad URL', { status: 400 }); 22 - } 19 + const pathParts = pathname.slice(1).split("/"); 20 + if (pathParts.length < 2) { 21 + return new Response("Bad URL", { status: 400 }); 22 + } 23 23 24 - const [signatureHex, actor] = pathParts; 24 + const [signatureHex, actor] = pathParts; 25 + const actorBytes = new TextEncoder().encode(actor); 25 26 26 - const actorBytes = new TextEncoder().encode(actor); 27 + const key = await crypto.subtle.importKey( 28 + "raw", 29 + new TextEncoder().encode(env.AVATAR_SHARED_SECRET), 30 + { name: "HMAC", hash: "SHA-256" }, 31 + false, 32 + ["sign", "verify"], 33 + ); 27 34 28 - const key = await crypto.subtle.importKey( 29 - 'raw', 30 - new TextEncoder().encode(env.AVATAR_SHARED_SECRET), 31 - { name: 'HMAC', hash: 'SHA-256' }, 32 - false, 33 - ['sign', 'verify'], 34 - ); 35 + const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes); 36 + const computedSig = Array.from(new Uint8Array(computedSigBuffer)) 37 + .map((b) => b.toString(16).padStart(2, "0")) 38 + .join(""); 35 39 36 - const computedSigBuffer = await crypto.subtle.sign('HMAC', key, actorBytes); 37 - const computedSig = Array.from(new Uint8Array(computedSigBuffer)) 38 - .map((b) => b.toString(16).padStart(2, '0')) 39 - .join(''); 40 + console.log({ 41 + level: "debug", 42 + message: "avatar request for: " + actor, 43 + computedSignature: computedSig, 44 + providedSignature: signatureHex, 45 + }); 40 46 41 - console.log({ 42 - level: 'debug', 43 - message: 'avatar request for: ' + actor, 44 - computedSignature: computedSig, 45 - providedSignature: signatureHex, 46 - }); 47 + const sigBytes = Uint8Array.from( 48 + signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)), 49 + ); 50 + const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes); 47 51 48 - const sigBytes = Uint8Array.from(signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16))); 49 - const valid = await crypto.subtle.verify('HMAC', key, sigBytes, actorBytes); 52 + if (!valid) { 53 + return new Response("Invalid signature", { status: 403 }); 54 + } 50 55 51 - if (!valid) { 52 - return new Response('Invalid signature', { status: 403 }); 53 - } 56 + try { 57 + const profileResponse = await fetch( 58 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, 59 + ); 60 + const profile = await profileResponse.json(); 61 + const avatar = profile.avatar; 54 62 55 - try { 56 - const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, { method: 'GET' }); 57 - const profile = await profileResponse.json(); 58 - const avatar = profile.avatar; 63 + if (!avatar) { 64 + return new Response(`avatar not found for ${actor}.`, { status: 404 }); 65 + } 59 66 60 - if (!avatar) { 61 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 62 - } 67 + // Resize if requested 68 + let avatarResponse; 69 + if (resizeToTiny) { 70 + avatarResponse = await fetch(avatar, { 71 + cf: { 72 + image: { 73 + width: 32, 74 + height: 32, 75 + fit: "cover", 76 + format: "webp", 77 + }, 78 + }, 79 + }); 80 + } else { 81 + avatarResponse = await fetch(avatar); 82 + } 63 83 64 - // fetch the actual avatar image 65 - const avatarResponse = await fetch(avatar); 66 - if (!avatarResponse.ok) { 67 - return new Response(`failed to fetch avatar for ${actor}.`, { status: avatarResponse.status }); 68 - } 84 + if (!avatarResponse.ok) { 85 + return new Response(`failed to fetch avatar for ${actor}.`, { 86 + status: avatarResponse.status, 87 + }); 88 + } 69 89 70 - const avatarData = await avatarResponse.arrayBuffer(); 71 - const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; 90 + const avatarData = await avatarResponse.arrayBuffer(); 91 + const contentType = 92 + avatarResponse.headers.get("content-type") || "image/jpeg"; 72 93 73 - response = new Response(avatarData, { 74 - headers: { 75 - 'Content-Type': contentType, 76 - 'Cache-Control': 'public, max-age=43200', // 12 h 77 - }, 78 - }); 94 + response = new Response(avatarData, { 95 + headers: { 96 + "Content-Type": contentType, 97 + "Cache-Control": "public, max-age=43200", 98 + }, 99 + }); 79 100 80 - // cache it in cf using request.url as the key 81 - await cache.put(cacheKey, response.clone()); 82 - 83 - return response; 84 - } catch (error) { 85 - return new Response(`error fetching avatar: ${error.message}`, { status: 500 }); 86 - } 87 - }, 101 + await cache.put(cacheKey, response.clone()); 102 + return response; 103 + } catch (error) { 104 + return new Response(`error fetching avatar: ${error.message}`, { 105 + status: 500, 106 + }); 107 + } 108 + }, 88 109 };