Image CDN for atproto built on cloudflare
at main 9.8 kB view raw
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>;