Image CDN for atproto built on cloudflare
1import { CID } from 'multiformats/cid';
2
3const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
4
5interface Env {
6 USER_CACHE: KVNamespace;
7}
8
9// TID (Timestamp Identifier) constants for AT Protocol record keys
10const TID_LENGTH = 13;
11const TID_REGEX = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/;
12const COLLECTION = 'blue.imgs.blup.image';
13
14function base62ToBytes(base62: string): Uint8Array {
15 let num = 0n;
16 for (let i = 0; i < base62.length; i++) {
17 num = num * 62n + BigInt(BASE62_CHARS.indexOf(base62[i]));
18 }
19
20 const bytes: number[] = [];
21 while (num > 0n) {
22 bytes.unshift(Number(num & 0xFFn));
23 num = num >> 8n;
24 }
25 return new Uint8Array(bytes);
26}
27
28/**
29 * Detect the format of the identifier in the URL path
30 */
31function detectIdentifierFormat(id: string): 'tid' | 'base32' | 'base62' {
32 if (id.startsWith('bafkrei')) return 'base32';
33 if (id.length === TID_LENGTH && TID_REGEX.test(id)) return 'tid';
34 return 'base62';
35}
36
37async function resolveHandleToDID(handle: string): Promise<string | null> {
38 let did: string | null = null;
39
40 // First try DNS TXT lookup
41 try {
42 // @ts-ignore - DNS API might not be in types yet
43 if (typeof globalThis.resolveDns !== 'undefined') {
44 // @ts-ignore
45 const records = await globalThis.resolveDns(`_atproto.${handle}`, 'TXT');
46 if (records && Array.isArray(records)) {
47 for (const rec of records) {
48 if (rec.startsWith('did=')) {
49 did = rec.split('did=')[1];
50 break;
51 }
52 }
53 }
54 }
55 } catch (error) {
56 console.log('DNS lookup failed, trying HTTPS fallback');
57 }
58
59 // Fallback to DNS-over-HTTPS (works everywhere)
60 if (!did) {
61 try {
62 const dnsUrl = `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`;
63
64 const response = await fetch(dnsUrl, {
65 headers: {
66 'Accept': 'application/dns-json'
67 },
68 cf: {
69 cacheTtl: 300,
70 cacheEverything: true
71 }
72 });
73
74 if (response.ok) {
75 const dnsData = await response.json() as {
76 Answer?: Array<{
77 name: string;
78 type: number;
79 data: string;
80 }>;
81 };
82
83 const txtRecord = dnsData.Answer?.find(record =>
84 record.type === 16 && record.data.includes('did=')
85 );
86
87 if (txtRecord) {
88 // Remove quotes and extract DID
89 const match = txtRecord.data.replace(/"/g, '').match(/did=(did:[^\s]+)/);
90 if (match) {
91 did = match[1];
92 }
93 }
94 }
95 } catch (error) {
96 console.error('DNS-over-HTTPS lookup failed:', error);
97 }
98 }
99
100 // If DNS lookup failed, try HTTPS well-known
101 if (!did) {
102 try {
103 const response = await fetch(`https://${handle}/.well-known/atproto-did`, {
104 cf: {
105 cacheTtl: 300,
106 cacheEverything: true
107 }
108 });
109
110 if (response.status === 200) {
111 const maybeDid = await response.text();
112 // Basic DID validation
113 if (maybeDid.startsWith('did:')) {
114 did = maybeDid.trim();
115 }
116 }
117 } catch (error) {
118 console.error('HTTPS well-known lookup failed:', error);
119 }
120 }
121
122 return did;
123}
124
125async function resolvePDSHost(did: string): Promise<string | null> {
126 try {
127 var url = new URL(`https://plc.directory/${did}`)
128 if (did.startsWith('did:web:')) {
129 // For web DIDs, construct the well-known URL
130 const domain = did.replace('did:web:', '');
131 url = new URL(`https://${domain}/.well-known/did.json`)
132 }
133
134 // For PLC DIDs, resolve via PLC directory
135 const plcResponse = await fetch(url, {
136 cf: {
137 cacheTtl: 3600,
138 cacheEverything: true
139 }
140 });
141
142 if (!plcResponse.ok) {
143 return null;
144 }
145
146 const plcData = await plcResponse.json() as {
147 service?: Array<{
148 id: string;
149 type: string;
150 serviceEndpoint: string;
151 }>;
152 };
153
154 const pdsService = plcData.service?.find(s => s.id === '#atproto_pds');
155 return pdsService?.serviceEndpoint || null;
156
157 } catch (error) {
158 console.error('Error resolving PDS host:', error);
159 return null;
160 }
161}
162
163/**
164 * Fetch blob CID from ATProto record using rkey
165 */
166async function fetchBlobCidFromRecord(
167 did: string,
168 rkey: string
169): Promise<string | null> {
170 const pdsHost = await resolvePDSHost(did);
171 if (!pdsHost) return null;
172
173 const url = `${pdsHost}/xrpc/com.atproto.repo.getRecord?` +
174 `repo=${encodeURIComponent(did)}` +
175 `&collection=${encodeURIComponent(COLLECTION)}` +
176 `&rkey=${encodeURIComponent(rkey)}`;
177
178 try {
179 const response = await fetch(url, {
180 method: 'GET',
181 headers: {
182 'Accept': 'application/json',
183 'User-Agent': 'CloudflareWorker/1.0'
184 },
185 cf: {
186 cacheTtl: 3600, // Cache record lookups for 1 hour
187 cacheEverything: true
188 }
189 });
190
191 if (!response.ok) {
192 console.error(`Record fetch failed: ${response.status}`);
193 return null;
194 }
195
196 const data = await response.json() as {
197 uri: string;
198 cid: string;
199 value: {
200 blob?: {
201 ref?: { $link: string };
202 };
203 };
204 };
205
206 return data.value?.blob?.ref?.$link || null;
207 } catch (error) {
208 console.error('Error fetching record:', error);
209 return null;
210 }
211}
212
213async function downloadBlobUnauthenticated(
214 did: string,
215 blobRef: string,
216 ctx: ExecutionContext
217): Promise<Response> {
218 // Resolve PDS host for the DID
219 const pdsHost = await resolvePDSHost(did);
220 if (!pdsHost) {
221 return new Response('Failed to resolve PDS host', { status: 400 });
222 }
223
224 const url = `${pdsHost}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(blobRef)}`;
225
226 try {
227 const response = await fetch(url, {
228 method: 'GET',
229 headers: {
230 'Accept': 'application/octet-stream',
231 'User-Agent': 'CloudflareWorker/1.0'
232 },
233 cf: {
234 cacheTtl: 31536000, // Cache for 1 year
235 cacheEverything: true
236 }
237 });
238
239 if (response.status === 200) {
240 // Return the response directly with appropriate headers
241 return new Response(response.body, {
242 status: 200,
243 headers: {
244 'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
245 'Cache-Control': 'public, max-age=31536000, immutable',
246 'Content-Length': response.headers.get('Content-Length') || '',
247 'Access-Control-Allow-Origin': '*'
248 }
249 });
250 }
251
252 return new Response('Blob not accessible via public endpoints', { status: 404 });
253 } catch (error) {
254 console.error('Error downloading blob:', error);
255 return new Response('Failed to download blob', { status: 500 });
256 }
257}
258
259// Export helper functions for testing
260export {
261 base62ToBytes,
262 detectIdentifierFormat,
263 resolveHandleToDID,
264 resolvePDSHost,
265 fetchBlobCidFromRecord,
266 downloadBlobUnauthenticated,
267};
268
269export default {
270 async fetch(
271 request: Request,
272 env: Env,
273 ctx: ExecutionContext
274 ): Promise<Response> {
275 // Handle CORS preflight
276 if (request.method === 'OPTIONS') {
277 return new Response(null, {
278 headers: {
279 'Access-Control-Allow-Origin': '*',
280 'Access-Control-Allow-Methods': 'GET, OPTIONS',
281 'Access-Control-Allow-Headers': 'Content-Type',
282 },
283 });
284 }
285
286 const url = new URL(request.url);
287 let [handle, base62OrCid] = url.pathname.split('/').filter(Boolean);
288
289 if (!handle || !base62OrCid) {
290 return new Response('Invalid path. Expected: /{handle}/{cid}', { status: 400 });
291 }
292
293 let did: string;
294 let cid: string;
295
296 // Handle resolution
297 if (handle.startsWith('did:')) {
298 did = handle;
299 } else {
300 const cacheKey = `${handle}`;
301 let cachedDid = await env.USER_CACHE.get(cacheKey);
302
303 if (!cachedDid) {
304 // Use DNS lookup for handle resolution
305 const resolvedDid = await resolveHandleToDID(handle);
306
307 if (!resolvedDid) {
308 return new Response('Handle not found', { status: 404 });
309 }
310
311 did = resolvedDid;
312
313 // Cache the resolved DID
314 const didId = did.replace('did:plc:', '').replace('did:web:', 'web:');
315 ctx.waitUntil(
316 env.USER_CACHE.put(cacheKey, didId, {
317 expirationTtl: 31536000 // 1 year
318 })
319 );
320 } else {
321 did = cachedDid.startsWith('web:')
322 ? `did:${cachedDid}`
323 : `did:plc:${cachedDid}`;
324 }
325 }
326
327 // ignore extensions on CID during lookup
328 const match = base62OrCid.match(/^([^.@]+)/);
329 if (match) {
330 base62OrCid = match[1];
331 }
332
333 // Detect identifier format: TID (rkey), base32 CID, or base62 CID
334 const format = detectIdentifierFormat(base62OrCid);
335
336 switch (format) {
337 case 'base32':
338 // Already a proper CID
339 cid = base62OrCid;
340 break;
341 case 'tid':
342 // Fetch record to get blob CID
343 const blobCid = await fetchBlobCidFromRecord(did, base62OrCid);
344 if (!blobCid) {
345 return new Response('Record not found', { status: 404 });
346 }
347 cid = blobCid;
348 break;
349 case 'base62':
350 default:
351 try {
352 // Convert base62 to CID
353 const bytes = base62ToBytes(base62OrCid);
354 const cidObj = CID.decode(bytes);
355 // Convert to base32 CIDv1 string (bafkrei format)
356 cid = cidObj.toString();
357 } catch (error) {
358 return new Response('Invalid CID encoding', { status: 400 });
359 }
360 }
361
362 // Download blob from AT Protocol PDS
363 return downloadBlobUnauthenticated(did, cid, ctx);
364 }
365} satisfies ExportedHandler<Env>;