[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

image functionality on cdn

+140 -2
+135
services/cdn/src/imageHandler.ts
··· 1 + import type { Context } from 'hono' 2 + import { pino } from 'pino' 3 + import type { BidirectionalResolver } from './id-resolver' 4 + 5 + // Get logger instance from parent 6 + const logger = pino({ 7 + name: 'cdn:image', 8 + transport: { 9 + target: 'pino-pretty', 10 + }, 11 + }) 12 + 13 + // In-memory image cache: did:cid -> {buffer, timestamp} 14 + interface CachedImage { 15 + buffer: ArrayBuffer 16 + timestamp: number 17 + contentType: string 18 + } 19 + 20 + const imageCache = new Map<string, CachedImage>() 21 + const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours 22 + 23 + // Fetch image from PDS 24 + export const getImage = async ( 25 + pdsUrl: URL | string, 26 + did: string, 27 + cid: string, 28 + ) => { 29 + const baseUrl = typeof pdsUrl === 'string' ? new URL(pdsUrl) : pdsUrl 30 + const imageUrl = new URL(`/xrpc/com.atproto.sync.getBlob`, baseUrl) 31 + imageUrl.searchParams.set('did', did) 32 + imageUrl.searchParams.set('cid', cid) 33 + const response = await fetch(imageUrl) 34 + 35 + if (!response.ok) { 36 + throw new Error( 37 + `Failed to fetch image: ${response.status} ${response.statusText}`, 38 + ) 39 + } 40 + 41 + const imageBytes = await response.arrayBuffer() 42 + // Get content type from response 43 + const contentType = response.headers.get('content-type') || 'image/jpeg' 44 + return { buffer: imageBytes, contentType } 45 + } 46 + 47 + // Cleanup old cache entries 48 + export function cleanupCache() { 49 + const now = Date.now() 50 + const keysToDelete: string[] = [] 51 + 52 + imageCache.forEach((entry, key) => { 53 + if (now - entry.timestamp > CACHE_TTL) { 54 + keysToDelete.push(key) 55 + } 56 + }) 57 + 58 + keysToDelete.forEach((key) => { 59 + imageCache.delete(key) 60 + }) 61 + 62 + if (keysToDelete.length > 0) { 63 + logger.info( 64 + { count: keysToDelete.length }, 65 + 'Cleaned up expired cache entries', 66 + ) 67 + } 68 + } 69 + 70 + // Generic image handler for both avatar and regular images 71 + export const imageHandler = async ( 72 + c: Context, 73 + bidirectionalResolver: BidirectionalResolver, 74 + ) => { 75 + const did = c.req.param('did') 76 + const cid = c.req.param('cid') 77 + const cacheKey = `${did}:${cid}` 78 + 79 + try { 80 + let imageBuffer: ArrayBuffer 81 + let contentType: string 82 + let fromCache = false 83 + 84 + const cachedEntry = imageCache.get(cacheKey) 85 + if (cachedEntry) { 86 + logger.info({ did, cid, cached: true }, 'Found image in cache') 87 + imageBuffer = cachedEntry.buffer 88 + contentType = cachedEntry.contentType 89 + fromCache = true 90 + } else { 91 + logger.info({ did, cid }, 'Resolving DID to find PDS') 92 + const didDoc = await bidirectionalResolver.resolveDidToDidDoc(did) 93 + 94 + if (!didDoc?.pds) { 95 + logger.error({ did }, 'PDS not found for DID') 96 + return c.json({ error: 'PDS not found for DID' }, 404) 97 + } 98 + 99 + logger.info( 100 + { pds: didDoc.pds.toString(), did, cid }, 101 + 'Fetching image from PDS', 102 + ) 103 + const result = await getImage(didDoc.pds, did, cid) 104 + imageBuffer = result.buffer 105 + contentType = result.contentType 106 + 107 + // Cache the image 108 + imageCache.set(cacheKey, { 109 + buffer: imageBuffer, 110 + timestamp: Date.now(), 111 + contentType, 112 + }) 113 + 114 + cleanupCache() 115 + } 116 + 117 + const fileSize = imageBuffer.byteLength 118 + 119 + // Set headers 120 + c.header('Content-Type', contentType) 121 + c.header('Content-Length', fileSize.toString()) 122 + c.header('ETag', cid) 123 + c.header('Cache-Control', 'public, max-age=86400') 124 + 125 + logger.info( 126 + { did, cid, size: fileSize, cached: fromCache, type: contentType }, 127 + 'Serving image', 128 + ) 129 + 130 + return c.body(imageBuffer) 131 + } catch (err) { 132 + logger.error({ err, did, cid }, 'Error serving image') 133 + return c.json({ error: 'Error serving image' }, 500) 134 + } 135 + }
+5 -2
services/cdn/src/index.ts
··· 1 1 import { Hono } from 'hono' 2 2 import { pino } from 'pino' 3 3 import { createBidirectionalResolver, createIdResolver } from './id-resolver' 4 + import { imageHandler } from './imageHandler' 4 5 import { videoHandler } from './videoHandler' 5 6 6 7 const logger = pino({ ··· 22 23 logger.info('Starting Spark CDN service') 23 24 24 25 try { 25 - // Create ID resolver 26 26 const resolver = createIdResolver() 27 27 const bidirectionalResolver = createBidirectionalResolver(resolver) 28 28 app = new Hono() ··· 33 33 ) 34 34 }) 35 35 36 - // Apply video route 37 36 app.get('/video/:did/:cid', (c) => videoHandler(c, bidirectionalResolver)) 37 + 38 + app.get('/avatar/:did/:cid', (c) => imageHandler(c, bidirectionalResolver)) 39 + 40 + app.get('/img/:did/:cid', (c) => imageHandler(c, bidirectionalResolver)) 38 41 39 42 logger.info({ port }, 'CDN service is running') 40 43 } catch (err) {