+90
-69
avatar/src/index.js
+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
};