forked from
tangled.org/core
Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
1import {
2 LocalActorResolver,
3 CompositeHandleResolver,
4 DohJsonHandleResolver,
5 WellKnownHandleResolver,
6 CompositeDidDocumentResolver,
7 PlcDidDocumentResolver,
8 WebDidDocumentResolver,
9} from "@atcute/identity-resolver";
10
11// Initialize resolvers for Cloudflare Workers
12const handleResolver = new CompositeHandleResolver({
13 strategy: "race",
14 methods: {
15 dns: new DohJsonHandleResolver({
16 dohUrl: "https://cloudflare-dns.com/dns-query",
17 }),
18 http: new WellKnownHandleResolver(),
19 },
20});
21
22const didDocumentResolver = new CompositeDidDocumentResolver({
23 methods: {
24 plc: new PlcDidDocumentResolver(),
25 web: new WebDidDocumentResolver(),
26 },
27});
28
29const actorResolver = new LocalActorResolver({
30 handleResolver,
31 didDocumentResolver,
32});
33
34export default {
35 async fetch(request, env) {
36 // Helper function to generate a color from a string
37 const stringToColor = (str) => {
38 let hash = 0;
39 for (let i = 0; i < str.length; i++) {
40 hash = str.charCodeAt(i) + ((hash << 5) - hash);
41 }
42 let color = "#";
43 for (let i = 0; i < 3; i++) {
44 const value = (hash >> (i * 8)) & 0xff;
45 color += ("00" + value.toString(16)).substr(-2);
46 }
47 return color;
48 };
49
50 // Helper function to fetch Tangled profile from PDS
51 const getTangledAvatarFromPDS = async (actor) => {
52 try {
53 // Resolve the identity
54 const identity = await actorResolver.resolve(actor);
55 if (!identity) {
56 console.log({
57 level: "debug",
58 message: "failed to resolve identity",
59 actor: actor,
60 });
61 return null;
62 }
63
64 const did = identity.did;
65 const pdsEndpoint = identity.pds.replace(/\/$/, ""); // Remove trailing slash
66
67 if (!pdsEndpoint) {
68 console.log({
69 level: "debug",
70 message: "no PDS endpoint found",
71 actor: actor,
72 did: did,
73 });
74 return null;
75 }
76
77 const profileUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=sh.tangled.actor.profile&rkey=self`;
78
79 // Fetch the Tangled profile record from PDS
80 const profileResponse = await fetch(profileUrl);
81
82 if (!profileResponse.ok) {
83 console.log({
84 level: "debug",
85 message: "no Tangled profile found on PDS",
86 actor: actor,
87 status: profileResponse.status,
88 });
89 return null;
90 }
91
92 const profileData = await profileResponse.json();
93 const avatarBlob = profileData?.value?.avatar;
94
95 if (!avatarBlob) {
96 console.log({
97 level: "debug",
98 message: "Tangled profile has no avatar",
99 actor: actor,
100 });
101 return null;
102 }
103
104 // Extract CID from blob reference object
105 // The ref might be an object with $link property or a string
106 let avatarCID;
107 if (typeof avatarBlob.ref === "string") {
108 avatarCID = avatarBlob.ref;
109 } else if (avatarBlob.ref?.$link) {
110 avatarCID = avatarBlob.ref.$link;
111 } else if (typeof avatarBlob === "string") {
112 avatarCID = avatarBlob;
113 }
114
115 if (!avatarCID || typeof avatarCID !== "string") {
116 console.log({
117 level: "warn",
118 message: "could not extract valid CID from avatar blob",
119 actor: actor,
120 avatarBlob: avatarBlob,
121 avatarBlobRef: avatarBlob.ref,
122 });
123 return null;
124 }
125
126 // Construct blob URL (pdsEndpoint already has trailing slash removed)
127 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCID}`;
128
129 return blobUrl;
130 } catch (e) {
131 console.log({
132 level: "warn",
133 message: "error fetching Tangled avatar from PDS",
134 actor: actor,
135 error: e.message,
136 });
137 return null;
138 }
139 };
140
141 const url = new URL(request.url);
142 const { pathname, searchParams } = url;
143
144 if (!pathname || pathname === "/") {
145 return new Response(
146 `This is Tangled's avatar service. It fetches your pretty avatar from your PDS, Bluesky, or generates a placeholder.
147You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`,
148 );
149 }
150
151 const size = searchParams.get("size");
152 const resizeToTiny = size === "tiny";
153 const format = searchParams.get("format") || "webp";
154 const validFormats = ["webp", "jpeg", "png"];
155 const outputFormat = validFormats.includes(format) ? format : "webp";
156
157 const contentTypes = {
158 webp: "image/webp",
159 jpeg: "image/jpeg",
160 png: "image/png",
161 };
162
163 const cache = caches.default;
164 let cacheKey = request.url;
165 let response = await cache.match(cacheKey);
166 if (response) return response;
167
168 const pathParts = pathname.slice(1).split("/");
169 if (pathParts.length < 2) {
170 return new Response("Bad URL", { status: 400 });
171 }
172
173 const [signatureHex, actor] = pathParts;
174 const actorBytes = new TextEncoder().encode(actor);
175
176 const key = await crypto.subtle.importKey(
177 "raw",
178 new TextEncoder().encode(env.AVATAR_SHARED_SECRET),
179 { name: "HMAC", hash: "SHA-256" },
180 false,
181 ["sign", "verify"],
182 );
183
184 const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes);
185 const computedSig = Array.from(new Uint8Array(computedSigBuffer))
186 .map((b) => b.toString(16).padStart(2, "0"))
187 .join("");
188
189 console.log({
190 level: "debug",
191 message: "avatar request for: " + actor,
192 computedSignature: computedSig,
193 providedSignature: signatureHex,
194 });
195
196 const sigBytes = Uint8Array.from(
197 signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)),
198 );
199 const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes);
200
201 if (!valid) {
202 return new Response("Invalid signature", { status: 403 });
203 }
204
205 try {
206 let avatarUrl = null;
207
208 // Try to get Tangled avatar from user's PDS first
209 avatarUrl = await getTangledAvatarFromPDS(actor);
210
211 // If no Tangled avatar, fall back to Bluesky
212 if (!avatarUrl) {
213 console.log({
214 level: "debug",
215 message: "no Tangled avatar, falling back to Bluesky",
216 actor: actor,
217 });
218
219 const profileResponse = await fetch(
220 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`,
221 );
222
223 if (profileResponse.ok) {
224 const profile = await profileResponse.json();
225 avatarUrl = profile.avatar;
226 }
227 }
228
229 if (!avatarUrl) {
230 // Generate a random color based on the actor string
231 console.log({
232 level: "debug",
233 message: "no avatar found, generating placeholder",
234 actor: actor,
235 });
236
237 const bgColor = stringToColor(actor);
238 const size = resizeToTiny ? 32 : 128;
239 const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
240 const svgData = new TextEncoder().encode(svg);
241
242 response = new Response(svgData, {
243 headers: {
244 "Content-Type": "image/svg+xml",
245 "Cache-Control": "public, max-age=43200",
246 },
247 });
248 await cache.put(cacheKey, response.clone());
249 return response;
250 }
251
252 // Fetch and optionally resize the avatar
253 let avatarResponse;
254 const cfOptions = outputFormat !== "webp" || resizeToTiny ? {
255 cf: {
256 image: {
257 format: outputFormat,
258 ...(resizeToTiny ? { width: 32, height: 32, fit: "cover" } : {}),
259 },
260 },
261 }: {};
262
263 avatarResponse = await fetch(avatarUrl, cfOptions);
264
265 if (!avatarResponse.ok) {
266 return new Response(`failed to fetch avatar for ${actor}.`, {
267 status: avatarResponse.status,
268 });
269 }
270
271 const avatarData = await avatarResponse.arrayBuffer();
272
273 response = new Response(avatarData, {
274 headers: {
275 "Content-Type": contentTypes[outputFormat],
276 "Cache-Control": "public, max-age=43200",
277 },
278 });
279
280 await cache.put(cacheKey, response.clone());
281 return response;
282 } catch (error) {
283 return new Response(`error fetching avatar: ${error.message}`, {
284 status: 500,
285 });
286 }
287 },
288};