ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

combine session + profile endpts; add response caching

+272 -146
-100
netlify/functions/get-profile.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient } from '@atproto/oauth-client-node'; 3 - import { JoseKey } from '@atproto/jwk-jose'; 4 - import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 - import { getOAuthConfig } from './oauth-config'; 6 - import { Agent } from '@atproto/api'; 7 - import cookie from 'cookie'; 8 - 9 - function normalizePrivateKey(key: string): string { 10 - if (!key.includes('\n') && key.includes('\\n')) { 11 - return key.replace(/\\n/g, '\n'); 12 - } 13 - return key; 14 - } 15 - 16 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 17 - try { 18 - // Get session from cookie 19 - const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {}; 20 - const sessionId = cookies.atlast_session; 21 - 22 - if (!sessionId) { 23 - return { 24 - statusCode: 401, 25 - headers: { 'Content-Type': 'application/json' }, 26 - body: JSON.stringify({ error: 'No session cookie' }), 27 - }; 28 - } 29 - 30 - // Get DID from session 31 - const userSession = await userSessions.get(sessionId); 32 - if (!userSession) { 33 - return { 34 - statusCode: 401, 35 - headers: { 'Content-Type': 'application/json' }, 36 - body: JSON.stringify({ error: 'Invalid or expired session' }), 37 - }; 38 - } 39 - 40 - // Initialize OAuth client 41 - const config = getOAuthConfig(); 42 - const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 43 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 44 - 45 - const client = new NodeOAuthClient({ 46 - clientMetadata: { 47 - client_id: config.clientId, 48 - client_name: 'ATlast', 49 - client_uri: config.clientId.replace('/client-metadata.json', ''), 50 - redirect_uris: [config.redirectUri], 51 - scope: 'atproto transition:generic', 52 - grant_types: ['authorization_code', 'refresh_token'], 53 - response_types: ['code'], 54 - application_type: 'web', 55 - token_endpoint_auth_method: 'private_key_jwt', 56 - token_endpoint_auth_signing_alg: 'ES256', 57 - dpop_bound_access_tokens: true, 58 - jwks_uri: config.jwksUri, 59 - }, 60 - keyset: [privateKey], 61 - stateStore: stateStore as any, 62 - sessionStore: sessionStore as any, 63 - }); 64 - 65 - // Restore OAuth session 66 - const oauthSession = await client.restore(userSession.did); 67 - 68 - // Create agent from OAuth session 69 - const agent = new Agent(oauthSession); 70 - 71 - // Get profile 72 - const profile = await agent.getProfile({ actor: userSession.did }); 73 - 74 - return { 75 - statusCode: 200, 76 - headers: { 77 - 'Content-Type': 'application/json', 78 - 'Access-Control-Allow-Origin': '*', 79 - }, 80 - body: JSON.stringify({ 81 - did: userSession.did, 82 - handle: profile.data.handle, 83 - displayName: profile.data.displayName, 84 - avatar: profile.data.avatar, 85 - description: profile.data.description, 86 - }), 87 - }; 88 - 89 - } catch (error) { 90 - console.error('Get profile error:', error); 91 - return { 92 - statusCode: 500, 93 - headers: { 'Content-Type': 'application/json' }, 94 - body: JSON.stringify({ 95 - error: 'Failed to get profile', 96 - details: error instanceof Error ? error.message : 'Unknown error' 97 - }), 98 - }; 99 - } 100 - };
+114 -14
netlify/functions/session.ts
··· 1 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { userSessions } from './oauth-stores-db'; 2 + import { NodeOAuthClient } from '@atproto/oauth-client-node'; 3 + import { JoseKey } from '@atproto/jwk-jose'; 4 + import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 + import { getOAuthConfig } from './oauth-config'; 6 + import { Agent } from '@atproto/api'; 3 7 import cookie from 'cookie'; 8 + 9 + function normalizePrivateKey(key: string): string { 10 + if (!key.includes('\n') && key.includes('\\n')) { 11 + return key.replace(/\\n/g, '\n'); 12 + } 13 + return key; 14 + } 15 + 16 + // In-memory cache for profile data (lives for the function instance lifetime) 17 + const profileCache = new Map<string, { data: any; timestamp: number }>(); 18 + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes 4 19 5 20 export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 6 21 try { ··· 25 40 }; 26 41 } 27 42 28 - // For now, return minimal info 29 - // The OAuth client manages the actual tokens in sessionStore 30 - return { 31 - statusCode: 200, 32 - headers: { 33 - 'Content-Type': 'application/json', 34 - 'Access-Control-Allow-Origin': '*', 35 - }, 36 - body: JSON.stringify({ 43 + // Check cache first 44 + const cached = profileCache.get(userSession.did); 45 + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 46 + console.log('Returning cached profile for', userSession.did); 47 + return { 48 + statusCode: 200, 49 + headers: { 50 + 'Content-Type': 'application/json', 51 + 'Access-Control-Allow-Origin': '*', 52 + 'Cache-Control': 'private, max-age=300', // Browser can cache for 5 minutes 53 + }, 54 + body: JSON.stringify(cached.data), 55 + }; 56 + } 57 + 58 + // If not in cache, fetch full profile 59 + try { 60 + const config = getOAuthConfig(); 61 + const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 62 + const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 63 + 64 + const client = new NodeOAuthClient({ 65 + clientMetadata: { 66 + client_id: config.clientId, 67 + client_name: 'ATlast', 68 + client_uri: config.clientId.replace('/client-metadata.json', ''), 69 + redirect_uris: [config.redirectUri], 70 + scope: 'atproto transition:generic', 71 + grant_types: ['authorization_code', 'refresh_token'], 72 + response_types: ['code'], 73 + application_type: 'web', 74 + token_endpoint_auth_method: 'private_key_jwt', 75 + token_endpoint_auth_signing_alg: 'ES256', 76 + dpop_bound_access_tokens: true, 77 + jwks_uri: config.jwksUri, 78 + }, 79 + keyset: [privateKey], 80 + stateStore: stateStore as any, 81 + sessionStore: sessionStore as any, 82 + }); 83 + 84 + // Restore OAuth session 85 + const oauthSession = await client.restore(userSession.did); 86 + 87 + // Create agent from OAuth session 88 + const agent = new Agent(oauthSession); 89 + 90 + // Get profile 91 + const profile = await agent.getProfile({ actor: userSession.did }); 92 + 93 + const profileData = { 37 94 did: userSession.did, 38 - // We'll add handle and serviceEndpoint in the next phase 39 - // when we can restore the OAuth session 40 - }), 41 - }; 95 + handle: profile.data.handle, 96 + displayName: profile.data.displayName, 97 + avatar: profile.data.avatar, 98 + description: profile.data.description, 99 + }; 100 + 101 + // Cache the profile data 102 + profileCache.set(userSession.did, { 103 + data: profileData, 104 + timestamp: Date.now(), 105 + }); 106 + 107 + // Clean up old cache entries (simple cleanup) 108 + if (profileCache.size > 100) { 109 + const now = Date.now(); 110 + for (const [did, entry] of profileCache.entries()) { 111 + if (now - entry.timestamp > CACHE_TTL) { 112 + profileCache.delete(did); 113 + } 114 + } 115 + } 116 + 117 + return { 118 + statusCode: 200, 119 + headers: { 120 + 'Content-Type': 'application/json', 121 + 'Access-Control-Allow-Origin': '*', 122 + 'Cache-Control': 'private, max-age=300', // Browser can cache for 5 minutes 123 + }, 124 + body: JSON.stringify(profileData), 125 + }; 126 + } catch (error) { 127 + console.error('Profile fetch error:', error); 128 + 129 + // If profile fetch fails, return basic session info 130 + return { 131 + statusCode: 200, 132 + headers: { 133 + 'Content-Type': 'application/json', 134 + 'Access-Control-Allow-Origin': '*', 135 + }, 136 + body: JSON.stringify({ 137 + did: userSession.did, 138 + // Profile data unavailable 139 + }), 140 + }; 141 + } 42 142 } catch (error) { 43 143 console.error('Session error:', error); 44 144 return {
+19 -18
src/hooks/useAuth.ts
··· 27 27 // If we have a session parameter in URL, this is an OAuth callback 28 28 if (sessionId) { 29 29 setStatusMessage('Loading your session...'); 30 - await fetchProfile(); 30 + 31 + // Single call now gets both session AND profile data 32 + const data = await apiClient.getSession(); 33 + setSession({ 34 + did: data.did, 35 + handle: data.handle, 36 + displayName: data.displayName, 37 + avatar: data.avatar, 38 + description: data.description, 39 + }); 31 40 setCurrentStep('home'); 41 + setStatusMessage(`Welcome back, ${data.handle}!`); 42 + 32 43 window.history.replaceState({}, '', '/'); 33 44 return; 34 45 } 35 46 36 47 // Otherwise, check if there's an existing session cookie 37 - await apiClient.getSession(); 38 - await fetchProfile(); 39 - setCurrentStep('home'); 40 - } catch (error) { 41 - console.error('Session check error:', error); 42 - setCurrentStep('login'); 43 - } 44 - } 45 - 46 - async function fetchProfile() { 47 - try { 48 - const data = await apiClient.getProfile(); 48 + // Single call now gets both session AND profile data 49 + const data = await apiClient.getSession(); 49 50 setSession({ 50 51 did: data.did, 51 52 handle: data.handle, ··· 53 54 avatar: data.avatar, 54 55 description: data.description, 55 56 }); 56 - setStatusMessage(`Successfully logged in as ${data.handle}`); 57 - } catch (err) { 58 - console.error('Profile fetch error:', err); 59 - setStatusMessage('Failed to load profile'); 60 - throw err; 57 + setCurrentStep('home'); 58 + setStatusMessage(`Welcome back, ${data.handle}!`); 59 + } catch (error) { 60 + console.error('Session check error:', error); 61 + setCurrentStep('login'); 61 62 } 62 63 } 63 64
+139 -14
src/lib/apiClient.ts
··· 1 1 import type { AtprotoSession, BatchSearchResult, BatchFollowResult, SaveResultsResponse, SearchResult } from '../types'; 2 2 3 + // Client-side cache with TTL 4 + interface CacheEntry<T> { 5 + data: T; 6 + timestamp: number; 7 + } 8 + 9 + class ResponseCache { 10 + private cache = new Map<string, CacheEntry<any>>(); 11 + private readonly defaultTTL = 5 * 60 * 1000; // 5 minutes 12 + 13 + set<T>(key: string, data: T, ttl: number = this.defaultTTL): void { 14 + this.cache.set(key, { 15 + data, 16 + timestamp: Date.now(), 17 + }); 18 + 19 + // Clean up old entries periodically 20 + if (this.cache.size > 50) { 21 + this.cleanup(); 22 + } 23 + } 24 + 25 + get<T>(key: string, ttl: number = this.defaultTTL): T | null { 26 + const entry = this.cache.get(key); 27 + if (!entry) return null; 28 + 29 + if (Date.now() - entry.timestamp > ttl) { 30 + this.cache.delete(key); 31 + return null; 32 + } 33 + 34 + return entry.data as T; 35 + } 36 + 37 + invalidate(key: string): void { 38 + this.cache.delete(key); 39 + } 40 + 41 + invalidatePattern(pattern: string): void { 42 + for (const key of this.cache.keys()) { 43 + if (key.includes(pattern)) { 44 + this.cache.delete(key); 45 + } 46 + } 47 + } 48 + 49 + clear(): void { 50 + this.cache.clear(); 51 + } 52 + 53 + private cleanup(): void { 54 + const now = Date.now(); 55 + for (const [key, entry] of this.cache.entries()) { 56 + if (now - entry.timestamp > this.defaultTTL) { 57 + this.cache.delete(key); 58 + } 59 + } 60 + } 61 + } 62 + 63 + const cache = new ResponseCache(); 64 + 3 65 export const apiClient = { 4 66 // OAuth and Authentication 5 67 async startOAuth(handle: string): Promise<{ url: string }> { ··· 23 85 }, 24 86 25 87 async getSession(): Promise<{ did: string; handle: string; displayName?: string; avatar?: string; description?: string }> { 88 + // Check cache first 89 + const cacheKey = 'session'; 90 + const cached = cache.get<AtprotoSession>(cacheKey); 91 + if (cached) { 92 + console.log('Returning cached session'); 93 + return cached; 94 + } 95 + 26 96 const res = await fetch('/.netlify/functions/session', { 27 97 credentials: 'include' 28 98 }); ··· 31 101 throw new Error('No valid session'); 32 102 } 33 103 34 - return res.json(); 104 + const data = await res.json(); 105 + 106 + // Cache the session data for 5 minutes 107 + cache.set(cacheKey, data, 5 * 60 * 1000); 108 + 109 + return data; 35 110 }, 36 111 37 112 async getProfile(): Promise<AtprotoSession> { 38 - const res = await fetch('/.netlify/functions/get-profile', { 39 - credentials: 'include' 40 - }); 41 - 42 - if (!res.ok) { 43 - throw new Error('Failed to load profile'); 44 - } 45 - 46 - return res.json(); 113 + // This is now redundant - getSession returns profile data 114 + // Keeping for backwards compatibility but it just calls getSession 115 + return this.getSession(); 47 116 }, 48 117 49 118 async logout(): Promise<void> { ··· 55 124 if (!res.ok) { 56 125 throw new Error('Logout failed'); 57 126 } 127 + 128 + // Clear all caches on logout 129 + cache.clear(); 58 130 }, 59 131 60 132 // Upload History Operations ··· 68 140 unmatchedUsers: number; 69 141 }>; 70 142 }> { 143 + // Check cache first 144 + const cacheKey = 'uploads'; 145 + const cached = cache.get<any>(cacheKey, 2 * 60 * 1000); // 2 minute cache for uploads list 146 + if (cached) { 147 + console.log('Returning cached uploads'); 148 + return cached; 149 + } 150 + 71 151 const res = await fetch('/.netlify/functions/get-uploads', { 72 152 credentials: 'include' 73 153 }); ··· 76 156 throw new Error('Failed to fetch uploads'); 77 157 } 78 158 79 - return res.json(); 159 + const data = await res.json(); 160 + 161 + // Cache uploads list for 2 minutes 162 + cache.set(cacheKey, data, 2 * 60 * 1000); 163 + 164 + return data; 80 165 }, 81 166 82 167 async getUploadDetails(uploadId: string): Promise<{ 83 168 results: SearchResult[]; 84 169 }> { 170 + // Check cache first 171 + const cacheKey = `upload-details-${uploadId}`; 172 + const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); // 10 minute cache for specific upload 173 + if (cached) { 174 + console.log('Returning cached upload details for', uploadId); 175 + return cached; 176 + } 177 + 85 178 const res = await fetch(`/.netlify/functions/get-upload-details?uploadId=${uploadId}`, { 86 179 credentials: 'include' 87 180 }); ··· 90 183 throw new Error('Failed to fetch upload details'); 91 184 } 92 185 93 - return res.json(); 186 + const data = await res.json(); 187 + 188 + // Cache upload details for 10 minutes 189 + cache.set(cacheKey, data, 10 * 60 * 1000); 190 + 191 + return data; 94 192 }, 95 193 96 194 // Search Operations 97 195 async batchSearchActors(usernames: string[]): Promise<{ results: BatchSearchResult[] }> { 196 + // Create cache key from sorted usernames (so order doesn't matter) 197 + const cacheKey = `search-${usernames.slice().sort().join(',')}`; 198 + const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); // 10 minute cache for search results 199 + if (cached) { 200 + console.log('Returning cached search results for', usernames.length, 'users'); 201 + return cached; 202 + } 203 + 98 204 const res = await fetch('/.netlify/functions/batch-search-actors', { 99 205 method: 'POST', 100 206 credentials: 'include', ··· 106 212 throw new Error(`Batch search failed: ${res.status}`); 107 213 } 108 214 109 - return res.json(); 215 + const data = await res.json(); 216 + 217 + // Cache search results for 10 minutes 218 + cache.set(cacheKey, data, 10 * 60 * 1000); 219 + 220 + return data; 110 221 }, 111 222 112 223 // Follow Operations ··· 128 239 throw new Error('Batch follow failed'); 129 240 } 130 241 131 - return res.json(); 242 + const data = await res.json(); 243 + 244 + return data; 132 245 }, 133 246 134 247 // Save Results ··· 161 274 if (res.ok) { 162 275 const data = await res.json(); 163 276 console.log(`Successfully saved ${data.matchedUsers} matches`); 277 + 278 + // Invalidate uploads cache after saving 279 + cache.invalidate('uploads'); 280 + cache.set(`upload-details-${uploadId}`, { results }, 10 * 60 * 1000); 281 + 164 282 return data; 165 283 } else { 166 284 console.error('Failed to save results:', res.status, await res.text()); ··· 170 288 console.error('Error saving results (will continue in background):', error); 171 289 return null; 172 290 } 291 + }, 292 + 293 + // Cache management utilities 294 + cache: { 295 + clear: () => cache.clear(), 296 + invalidate: (key: string) => cache.invalidate(key), 297 + invalidatePattern: (pattern: string) => cache.invalidatePattern(pattern), 173 298 } 174 299 };