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

linting

+77 -60
netlify/functions/batch-follow-users.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient, atprotoLoopbackClientMetadata } 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 // Only allow POST 18 - if (event.httpMethod !== 'POST') { 19 return { 20 statusCode: 405, 21 - headers: { 'Content-Type': 'application/json' }, 22 - body: JSON.stringify({ error: 'Method not allowed' }), 23 }; 24 } 25 26 try { 27 // Parse request body 28 - const body = JSON.parse(event.body || '{}'); 29 const dids: string[] = body.dids || []; 30 31 if (!Array.isArray(dids) || dids.length === 0) { 32 return { 33 statusCode: 400, 34 - headers: { 'Content-Type': 'application/json' }, 35 - body: JSON.stringify({ error: 'dids array is required and must not be empty' }), 36 }; 37 } 38 ··· 40 if (dids.length > 100) { 41 return { 42 statusCode: 400, 43 - headers: { 'Content-Type': 'application/json' }, 44 - body: JSON.stringify({ error: 'Maximum 100 DIDs per batch' }), 45 }; 46 } 47 48 // Get session from cookie 49 - const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {}; 50 const sessionId = cookies.atlast_session; 51 52 if (!sessionId) { 53 return { 54 statusCode: 401, 55 - headers: { 'Content-Type': 'application/json' }, 56 - body: JSON.stringify({ error: 'No session cookie' }), 57 }; 58 } 59 ··· 62 if (!userSession) { 63 return { 64 statusCode: 401, 65 - headers: { 'Content-Type': 'application/json' }, 66 - body: JSON.stringify({ error: 'Invalid or expired session' }), 67 }; 68 } 69 70 const config = getOAuthConfig(); 71 - const isDev = config.clientType === 'loopback'; 72 73 let client: NodeOAuthClient; 74 ··· 83 } else { 84 // Production with private key 85 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 86 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 87 88 client = new NodeOAuthClient({ 89 clientMetadata: { 90 client_id: config.clientId, 91 - client_name: 'ATlast', 92 - client_uri: config.clientId.replace('/client-metadata.json', ''), 93 redirect_uris: [config.redirectUri], 94 - scope: 'atproto transition:generic', 95 - grant_types: ['authorization_code', 'refresh_token'], 96 - response_types: ['code'], 97 - application_type: 'web', 98 - token_endpoint_auth_method: 'private_key_jwt', 99 - token_endpoint_auth_signing_alg: 'ES256', 100 dpop_bound_access_tokens: true, 101 jwks_uri: config.jwksUri, 102 }, ··· 108 109 // Restore OAuth session 110 const oauthSession = await client.restore(userSession.did); 111 - 112 // Create agent from OAuth session 113 const agent = new Agent(oauthSession); 114 ··· 116 const results = []; 117 let consecutiveErrors = 0; 118 const MAX_CONSECUTIVE_ERRORS = 3; 119 - 120 for (const did of dids) { 121 try { 122 await agent.api.com.atproto.repo.createRecord({ 123 repo: userSession.did, 124 - collection: 'app.bsky.graph.follow', 125 record: { 126 - $type: 'app.bsky.graph.follow', 127 subject: did, 128 createdAt: new Date().toISOString(), 129 }, 130 }); 131 - 132 results.push({ 133 did, 134 success: true, 135 - error: null 136 }); 137 - 138 // Reset error counter on success 139 consecutiveErrors = 0; 140 } catch (error) { 141 consecutiveErrors++; 142 - 143 results.push({ 144 did, 145 success: false, 146 - error: error instanceof Error ? error.message : 'Follow failed' 147 }); 148 - 149 // If we hit rate limits, implement exponential backoff 150 - if (error instanceof Error && 151 - (error.message.includes('rate limit') || error.message.includes('429'))) { 152 - const backoffDelay = Math.min(200 * Math.pow(2, consecutiveErrors), 2000); 153 console.log(`Rate limit hit. Backing off for ${backoffDelay}ms...`); 154 - await new Promise(resolve => setTimeout(resolve, backoffDelay)); 155 } else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 156 // For other repeated errors, small backoff 157 - await new Promise(resolve => setTimeout(resolve, 500)); 158 } 159 } 160 } 161 162 - const successCount = results.filter(r => r.success).length; 163 - const failCount = results.filter(r => !r.success).length; 164 165 return { 166 statusCode: 200, 167 headers: { 168 - 'Content-Type': 'application/json', 169 - 'Access-Control-Allow-Origin': '*', 170 }, 171 body: JSON.stringify({ 172 success: true, 173 total: dids.length, 174 succeeded: successCount, 175 failed: failCount, 176 - results 177 }), 178 }; 179 - 180 } catch (error) { 181 - console.error('Batch follow error:', error); 182 return { 183 statusCode: 500, 184 - headers: { 'Content-Type': 'application/json' }, 185 - body: JSON.stringify({ 186 - error: 'Failed to follow users', 187 - details: error instanceof Error ? error.message : 'Unknown error' 188 }), 189 }; 190 } 191 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { 3 + NodeOAuthClient, 4 + atprotoLoopbackClientMetadata, 5 + } from "@atproto/oauth-client-node"; 6 + import { JoseKey } from "@atproto/jwk-jose"; 7 + import { stateStore, sessionStore, userSessions } from "./oauth-stores-db"; 8 + import { getOAuthConfig } from "./oauth-config"; 9 + import { Agent } from "@atproto/api"; 10 + import cookie from "cookie"; 11 12 function normalizePrivateKey(key: string): string { 13 + if (!key.includes("\n") && key.includes("\\n")) { 14 + return key.replace(/\\n/g, "\n"); 15 } 16 return key; 17 } 18 19 + export const handler: Handler = async ( 20 + event: HandlerEvent, 21 + ): Promise<HandlerResponse> => { 22 // Only allow POST 23 + if (event.httpMethod !== "POST") { 24 return { 25 statusCode: 405, 26 + headers: { "Content-Type": "application/json" }, 27 + body: JSON.stringify({ error: "Method not allowed" }), 28 }; 29 } 30 31 try { 32 // Parse request body 33 + const body = JSON.parse(event.body || "{}"); 34 const dids: string[] = body.dids || []; 35 36 if (!Array.isArray(dids) || dids.length === 0) { 37 return { 38 statusCode: 400, 39 + headers: { "Content-Type": "application/json" }, 40 + body: JSON.stringify({ 41 + error: "dids array is required and must not be empty", 42 + }), 43 }; 44 } 45 ··· 47 if (dids.length > 100) { 48 return { 49 statusCode: 400, 50 + headers: { "Content-Type": "application/json" }, 51 + body: JSON.stringify({ error: "Maximum 100 DIDs per batch" }), 52 }; 53 } 54 55 // Get session from cookie 56 + const cookies = event.headers.cookie 57 + ? cookie.parse(event.headers.cookie) 58 + : {}; 59 const sessionId = cookies.atlast_session; 60 61 if (!sessionId) { 62 return { 63 statusCode: 401, 64 + headers: { "Content-Type": "application/json" }, 65 + body: JSON.stringify({ error: "No session cookie" }), 66 }; 67 } 68 ··· 71 if (!userSession) { 72 return { 73 statusCode: 401, 74 + headers: { "Content-Type": "application/json" }, 75 + body: JSON.stringify({ error: "Invalid or expired session" }), 76 }; 77 } 78 79 const config = getOAuthConfig(); 80 + const isDev = config.clientType === "loopback"; 81 82 let client: NodeOAuthClient; 83 ··· 92 } else { 93 // Production with private key 94 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 95 + const privateKey = await JoseKey.fromImportable( 96 + normalizedKey, 97 + "main-key", 98 + ); 99 100 client = new NodeOAuthClient({ 101 clientMetadata: { 102 client_id: config.clientId, 103 + client_name: "ATlast", 104 + client_uri: config.clientId.replace("/client-metadata.json", ""), 105 redirect_uris: [config.redirectUri], 106 + scope: "atproto transition:generic", 107 + grant_types: ["authorization_code", "refresh_token"], 108 + response_types: ["code"], 109 + application_type: "web", 110 + token_endpoint_auth_method: "private_key_jwt", 111 + token_endpoint_auth_signing_alg: "ES256", 112 dpop_bound_access_tokens: true, 113 jwks_uri: config.jwksUri, 114 }, ··· 120 121 // Restore OAuth session 122 const oauthSession = await client.restore(userSession.did); 123 + 124 // Create agent from OAuth session 125 const agent = new Agent(oauthSession); 126 ··· 128 const results = []; 129 let consecutiveErrors = 0; 130 const MAX_CONSECUTIVE_ERRORS = 3; 131 + 132 for (const did of dids) { 133 try { 134 await agent.api.com.atproto.repo.createRecord({ 135 repo: userSession.did, 136 + collection: "app.bsky.graph.follow", 137 record: { 138 + $type: "app.bsky.graph.follow", 139 subject: did, 140 createdAt: new Date().toISOString(), 141 }, 142 }); 143 + 144 results.push({ 145 did, 146 success: true, 147 + error: null, 148 }); 149 + 150 // Reset error counter on success 151 consecutiveErrors = 0; 152 } catch (error) { 153 consecutiveErrors++; 154 + 155 results.push({ 156 did, 157 success: false, 158 + error: error instanceof Error ? error.message : "Follow failed", 159 }); 160 + 161 // If we hit rate limits, implement exponential backoff 162 + if ( 163 + error instanceof Error && 164 + (error.message.includes("rate limit") || 165 + error.message.includes("429")) 166 + ) { 167 + const backoffDelay = Math.min( 168 + 200 * Math.pow(2, consecutiveErrors), 169 + 2000, 170 + ); 171 console.log(`Rate limit hit. Backing off for ${backoffDelay}ms...`); 172 + await new Promise((resolve) => setTimeout(resolve, backoffDelay)); 173 } else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 174 // For other repeated errors, small backoff 175 + await new Promise((resolve) => setTimeout(resolve, 500)); 176 } 177 } 178 } 179 180 + const successCount = results.filter((r) => r.success).length; 181 + const failCount = results.filter((r) => !r.success).length; 182 183 return { 184 statusCode: 200, 185 headers: { 186 + "Content-Type": "application/json", 187 + "Access-Control-Allow-Origin": "*", 188 }, 189 body: JSON.stringify({ 190 success: true, 191 total: dids.length, 192 succeeded: successCount, 193 failed: failCount, 194 + results, 195 }), 196 }; 197 } catch (error) { 198 + console.error("Batch follow error:", error); 199 return { 200 statusCode: 500, 201 + headers: { "Content-Type": "application/json" }, 202 + body: JSON.stringify({ 203 + error: "Failed to follow users", 204 + details: error instanceof Error ? error.message : "Unknown error", 205 }), 206 }; 207 } 208 + };
+93 -76
netlify/functions/batch-search-actors.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient, atprotoLoopbackClientMetadata } 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 // Parse batch request 19 - const body = JSON.parse(event.body || '{}'); 20 const usernames: string[] = body.usernames || []; 21 - 22 if (!Array.isArray(usernames) || usernames.length === 0) { 23 return { 24 statusCode: 400, 25 - headers: { 'Content-Type': 'application/json' }, 26 - body: JSON.stringify({ error: 'usernames array is required and must not be empty' }), 27 }; 28 } 29 ··· 31 if (usernames.length > 50) { 32 return { 33 statusCode: 400, 34 - headers: { 'Content-Type': 'application/json' }, 35 - body: JSON.stringify({ error: 'Maximum 50 usernames per batch' }), 36 }; 37 } 38 39 // Get session from cookie 40 - const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {}; 41 const sessionId = cookies.atlast_session; 42 43 if (!sessionId) { 44 return { 45 statusCode: 401, 46 - headers: { 'Content-Type': 'application/json' }, 47 - body: JSON.stringify({ error: 'No session cookie' }), 48 }; 49 } 50 ··· 53 if (!userSession) { 54 return { 55 statusCode: 401, 56 - headers: { 'Content-Type': 'application/json' }, 57 - body: JSON.stringify({ error: 'Invalid or expired session' }), 58 }; 59 } 60 61 const config = getOAuthConfig(); 62 - const isDev = config.clientType === 'loopback'; 63 64 let client: NodeOAuthClient; 65 ··· 74 } else { 75 // Production with private key 76 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 77 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 78 79 client = new NodeOAuthClient({ 80 clientMetadata: { 81 client_id: config.clientId, 82 - client_name: 'ATlast', 83 - client_uri: config.clientId.replace('/client-metadata.json', ''), 84 redirect_uris: [config.redirectUri], 85 - scope: 'atproto transition:generic', 86 - grant_types: ['authorization_code', 'refresh_token'], 87 - response_types: ['code'], 88 - application_type: 'web', 89 - token_endpoint_auth_method: 'private_key_jwt', 90 - token_endpoint_auth_signing_alg: 'ES256', 91 dpop_bound_access_tokens: true, 92 jwks_uri: config.jwksUri, 93 }, ··· 99 100 // Restore OAuth session 101 const oauthSession = await client.restore(userSession.did); 102 - 103 // Create agent from OAuth session 104 const agent = new Agent(oauthSession); 105 ··· 110 q: username, 111 limit: 20, 112 }); 113 - 114 // Filter and rank matches (same logic as before) 115 const normalize = (s: string) => s.toLowerCase().replace(/[._-]/g, ""); 116 const normalizedUsername = normalize(username); 117 118 - const rankedActors = response.data.actors.map((actor: any) => { 119 - const handlePart = actor.handle.split('.')[0]; 120 - const normalizedHandle = normalize(handlePart); 121 - const normalizedFullHandle = normalize(actor.handle); 122 - const normalizedDisplayName = normalize(actor.displayName || ''); 123 124 - let score = 0; 125 - if (normalizedHandle === normalizedUsername) score = 100; 126 - else if (normalizedFullHandle === normalizedUsername) score = 90; 127 - else if (normalizedDisplayName === normalizedUsername) score = 80; 128 - else if (normalizedHandle.includes(normalizedUsername)) score = 60; 129 - else if (normalizedFullHandle.includes(normalizedUsername)) score = 50; 130 - else if (normalizedDisplayName.includes(normalizedUsername)) score = 40; 131 - else if (normalizedUsername.includes(normalizedHandle)) score = 30; 132 133 - return { 134 - ...actor, 135 - matchScore: score, 136 - did: actor.did 137 - }; 138 - }) 139 - .filter((actor: any) => actor.matchScore > 0) 140 - .sort((a: any, b: any) => b.matchScore - a.matchScore) 141 - .slice(0, 5); 142 143 return { 144 username, 145 actors: rankedActors, 146 - error: null 147 }; 148 } catch (error) { 149 return { 150 username, 151 actors: [], 152 - error: error instanceof Error ? error.message : 'Search failed' 153 }; 154 } 155 }); ··· 158 159 // Enrich results with follower and post counts using getProfiles 160 const allDids = results 161 - .flatMap(r => r.actors.map((a: any) => a.did)) 162 .filter((did): did is string => !!did); 163 164 if (allDids.length > 0) { 165 // Create a map to store enriched profile data 166 - const profileDataMap = new Map<string, { postCount: number; followerCount: number }>(); 167 - 168 // Batch fetch profiles (25 at a time - API limit) 169 const PROFILE_BATCH_SIZE = 25; 170 for (let i = 0; i < allDids.length; i += PROFILE_BATCH_SIZE) { 171 const batch = allDids.slice(i, i + PROFILE_BATCH_SIZE); 172 try { 173 const profilesResponse = await agent.app.bsky.actor.getProfiles({ 174 - actors: batch 175 }); 176 - 177 profilesResponse.data.profiles.forEach((profile: any) => { 178 profileDataMap.set(profile.did, { 179 postCount: profile.postsCount || 0, 180 - followerCount: profile.followersCount || 0 181 }); 182 }); 183 } catch (error) { 184 - console.error('Failed to fetch profile batch:', error); 185 // Continue even if one batch fails 186 } 187 } 188 - 189 // Merge enriched data back into results 190 - results.forEach(result => { 191 result.actors = result.actors.map((actor: any) => { 192 const enrichedData = profileDataMap.get(actor.did); 193 return { 194 ...actor, 195 postCount: enrichedData?.postCount || 0, 196 - followerCount: enrichedData?.followerCount || 0 197 }; 198 }); 199 }); ··· 202 return { 203 statusCode: 200, 204 headers: { 205 - 'Content-Type': 'application/json', 206 - 'Access-Control-Allow-Origin': '*', 207 }, 208 body: JSON.stringify({ results }), 209 }; 210 - 211 } catch (error) { 212 - console.error('Batch search error:', error); 213 return { 214 statusCode: 500, 215 - headers: { 'Content-Type': 'application/json' }, 216 - body: JSON.stringify({ 217 - error: 'Failed to search actors', 218 - details: error instanceof Error ? error.message : 'Unknown error' 219 }), 220 }; 221 } 222 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { 3 + NodeOAuthClient, 4 + atprotoLoopbackClientMetadata, 5 + } from "@atproto/oauth-client-node"; 6 + import { JoseKey } from "@atproto/jwk-jose"; 7 + import { stateStore, sessionStore, userSessions } from "./oauth-stores-db"; 8 + import { getOAuthConfig } from "./oauth-config"; 9 + import { Agent } from "@atproto/api"; 10 + import cookie from "cookie"; 11 12 function normalizePrivateKey(key: string): string { 13 + if (!key.includes("\n") && key.includes("\\n")) { 14 + return key.replace(/\\n/g, "\n"); 15 } 16 return key; 17 } 18 19 + export const handler: Handler = async ( 20 + event: HandlerEvent, 21 + ): Promise<HandlerResponse> => { 22 try { 23 // Parse batch request 24 + const body = JSON.parse(event.body || "{}"); 25 const usernames: string[] = body.usernames || []; 26 + 27 if (!Array.isArray(usernames) || usernames.length === 0) { 28 return { 29 statusCode: 400, 30 + headers: { "Content-Type": "application/json" }, 31 + body: JSON.stringify({ 32 + error: "usernames array is required and must not be empty", 33 + }), 34 }; 35 } 36 ··· 38 if (usernames.length > 50) { 39 return { 40 statusCode: 400, 41 + headers: { "Content-Type": "application/json" }, 42 + body: JSON.stringify({ error: "Maximum 50 usernames per batch" }), 43 }; 44 } 45 46 // Get session from cookie 47 + const cookies = event.headers.cookie 48 + ? cookie.parse(event.headers.cookie) 49 + : {}; 50 const sessionId = cookies.atlast_session; 51 52 if (!sessionId) { 53 return { 54 statusCode: 401, 55 + headers: { "Content-Type": "application/json" }, 56 + body: JSON.stringify({ error: "No session cookie" }), 57 }; 58 } 59 ··· 62 if (!userSession) { 63 return { 64 statusCode: 401, 65 + headers: { "Content-Type": "application/json" }, 66 + body: JSON.stringify({ error: "Invalid or expired session" }), 67 }; 68 } 69 70 const config = getOAuthConfig(); 71 + const isDev = config.clientType === "loopback"; 72 73 let client: NodeOAuthClient; 74 ··· 83 } else { 84 // Production with private key 85 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 86 + const privateKey = await JoseKey.fromImportable( 87 + normalizedKey, 88 + "main-key", 89 + ); 90 91 client = new NodeOAuthClient({ 92 clientMetadata: { 93 client_id: config.clientId, 94 + client_name: "ATlast", 95 + client_uri: config.clientId.replace("/client-metadata.json", ""), 96 redirect_uris: [config.redirectUri], 97 + scope: "atproto transition:generic", 98 + grant_types: ["authorization_code", "refresh_token"], 99 + response_types: ["code"], 100 + application_type: "web", 101 + token_endpoint_auth_method: "private_key_jwt", 102 + token_endpoint_auth_signing_alg: "ES256", 103 dpop_bound_access_tokens: true, 104 jwks_uri: config.jwksUri, 105 }, ··· 111 112 // Restore OAuth session 113 const oauthSession = await client.restore(userSession.did); 114 + 115 // Create agent from OAuth session 116 const agent = new Agent(oauthSession); 117 ··· 122 q: username, 123 limit: 20, 124 }); 125 + 126 // Filter and rank matches (same logic as before) 127 const normalize = (s: string) => s.toLowerCase().replace(/[._-]/g, ""); 128 const normalizedUsername = normalize(username); 129 130 + const rankedActors = response.data.actors 131 + .map((actor: any) => { 132 + const handlePart = actor.handle.split(".")[0]; 133 + const normalizedHandle = normalize(handlePart); 134 + const normalizedFullHandle = normalize(actor.handle); 135 + const normalizedDisplayName = normalize(actor.displayName || ""); 136 137 + let score = 0; 138 + if (normalizedHandle === normalizedUsername) score = 100; 139 + else if (normalizedFullHandle === normalizedUsername) score = 90; 140 + else if (normalizedDisplayName === normalizedUsername) score = 80; 141 + else if (normalizedHandle.includes(normalizedUsername)) score = 60; 142 + else if (normalizedFullHandle.includes(normalizedUsername)) 143 + score = 50; 144 + else if (normalizedDisplayName.includes(normalizedUsername)) 145 + score = 40; 146 + else if (normalizedUsername.includes(normalizedHandle)) score = 30; 147 148 + return { 149 + ...actor, 150 + matchScore: score, 151 + did: actor.did, 152 + }; 153 + }) 154 + .filter((actor: any) => actor.matchScore > 0) 155 + .sort((a: any, b: any) => b.matchScore - a.matchScore) 156 + .slice(0, 5); 157 158 return { 159 username, 160 actors: rankedActors, 161 + error: null, 162 }; 163 } catch (error) { 164 return { 165 username, 166 actors: [], 167 + error: error instanceof Error ? error.message : "Search failed", 168 }; 169 } 170 }); ··· 173 174 // Enrich results with follower and post counts using getProfiles 175 const allDids = results 176 + .flatMap((r) => r.actors.map((a: any) => a.did)) 177 .filter((did): did is string => !!did); 178 179 if (allDids.length > 0) { 180 // Create a map to store enriched profile data 181 + const profileDataMap = new Map< 182 + string, 183 + { postCount: number; followerCount: number } 184 + >(); 185 + 186 // Batch fetch profiles (25 at a time - API limit) 187 const PROFILE_BATCH_SIZE = 25; 188 for (let i = 0; i < allDids.length; i += PROFILE_BATCH_SIZE) { 189 const batch = allDids.slice(i, i + PROFILE_BATCH_SIZE); 190 try { 191 const profilesResponse = await agent.app.bsky.actor.getProfiles({ 192 + actors: batch, 193 }); 194 + 195 profilesResponse.data.profiles.forEach((profile: any) => { 196 profileDataMap.set(profile.did, { 197 postCount: profile.postsCount || 0, 198 + followerCount: profile.followersCount || 0, 199 }); 200 }); 201 } catch (error) { 202 + console.error("Failed to fetch profile batch:", error); 203 // Continue even if one batch fails 204 } 205 } 206 + 207 // Merge enriched data back into results 208 + results.forEach((result) => { 209 result.actors = result.actors.map((actor: any) => { 210 const enrichedData = profileDataMap.get(actor.did); 211 return { 212 ...actor, 213 postCount: enrichedData?.postCount || 0, 214 + followerCount: enrichedData?.followerCount || 0, 215 }; 216 }); 217 }); ··· 220 return { 221 statusCode: 200, 222 headers: { 223 + "Content-Type": "application/json", 224 + "Access-Control-Allow-Origin": "*", 225 }, 226 body: JSON.stringify({ results }), 227 }; 228 } catch (error) { 229 + console.error("Batch search error:", error); 230 return { 231 statusCode: 500, 232 + headers: { "Content-Type": "application/json" }, 233 + body: JSON.stringify({ 234 + error: "Failed to search actors", 235 + details: error instanceof Error ? error.message : "Unknown error", 236 }), 237 }; 238 } 239 + };
+38 -36
netlify/functions/client-metadata.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 3 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 4 - 5 try { 6 // Get the host that's requesting the metadata 7 // This will be different for production vs preview deploys vs dev --live 8 - const requestHost = process.env.DEPLOY_URL 9 - ? new URL(process.env.DEPLOY_URL).host 10 - : (event.headers['x-forwarded-host'] || event.headers.host); 11 - 12 if (!requestHost) { 13 return { 14 statusCode: 400, 15 - headers: { 'Content-Type': 'application/json' }, 16 - body: JSON.stringify({ error: 'Missing host header' }), 17 }; 18 } 19 20 - // Check if this is a loopback/development request 21 - const isLoopback = requestHost.startsWith('127.0.0.1') || 22 - requestHost.startsWith('[::1]') || 23 - requestHost === 'localhost'; 24 25 if (isLoopback) { 26 // For loopback clients, return minimal metadata ··· 28 // loopback clients use hardcoded metadata on the server side 29 const appUrl = `http://${requestHost}`; 30 const redirectUri = `${appUrl}/.netlify/functions/oauth-callback`; 31 - 32 return { 33 statusCode: 200, 34 headers: { 35 - 'Content-Type': 'application/json', 36 - 'Access-Control-Allow-Origin': '*', 37 }, 38 body: JSON.stringify({ 39 client_id: appUrl, // Just the origin for loopback 40 - client_name: 'ATlast (Local Dev)', 41 client_uri: appUrl, 42 redirect_uris: [redirectUri], 43 - scope: 'atproto transition:generic', 44 - grant_types: ['authorization_code', 'refresh_token'], 45 - response_types: ['code'], 46 - application_type: 'web', 47 - token_endpoint_auth_method: 'none', // No auth for loopback 48 dpop_bound_access_tokens: true, 49 }), 50 }; ··· 58 59 const metadata = { 60 client_id: clientId, 61 - client_name: 'ATlast', 62 client_uri: appUrl, 63 redirect_uris: [redirectUri], 64 - scope: 'atproto transition:generic', 65 - grant_types: ['authorization_code', 'refresh_token'], 66 - response_types: ['code'], 67 - application_type: 'web', 68 - token_endpoint_auth_method: 'private_key_jwt', 69 - token_endpoint_auth_signing_alg: 'ES256', 70 dpop_bound_access_tokens: true, 71 jwks_uri: jwksUri, 72 }; ··· 74 return { 75 statusCode: 200, 76 headers: { 77 - 'Content-Type': 'application/json', 78 - 'Access-Control-Allow-Origin': '*', 79 - 'Cache-Control': 'no-store' 80 }, 81 body: JSON.stringify(metadata), 82 }; 83 } catch (error) { 84 - console.error('Client metadata error:', error); 85 return { 86 statusCode: 500, 87 - headers: { 'Content-Type': 'application/json' }, 88 - body: JSON.stringify({ error: 'Internal server error' }), 89 }; 90 } 91 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 3 + export const handler: Handler = async ( 4 + event: HandlerEvent, 5 + ): Promise<HandlerResponse> => { 6 try { 7 // Get the host that's requesting the metadata 8 // This will be different for production vs preview deploys vs dev --live 9 + const requestHost = process.env.DEPLOY_URL 10 + ? new URL(process.env.DEPLOY_URL).host 11 + : event.headers["x-forwarded-host"] || event.headers.host; 12 + 13 if (!requestHost) { 14 return { 15 statusCode: 400, 16 + headers: { "Content-Type": "application/json" }, 17 + body: JSON.stringify({ error: "Missing host header" }), 18 }; 19 } 20 21 + // Check if this is a loopback/development request 22 + const isLoopback = 23 + requestHost.startsWith("127.0.0.1") || 24 + requestHost.startsWith("[::1]") || 25 + requestHost === "localhost"; 26 27 if (isLoopback) { 28 // For loopback clients, return minimal metadata ··· 30 // loopback clients use hardcoded metadata on the server side 31 const appUrl = `http://${requestHost}`; 32 const redirectUri = `${appUrl}/.netlify/functions/oauth-callback`; 33 + 34 return { 35 statusCode: 200, 36 headers: { 37 + "Content-Type": "application/json", 38 + "Access-Control-Allow-Origin": "*", 39 }, 40 body: JSON.stringify({ 41 client_id: appUrl, // Just the origin for loopback 42 + client_name: "ATlast (Local Dev)", 43 client_uri: appUrl, 44 redirect_uris: [redirectUri], 45 + scope: "atproto repo:app.bsky.graph.follow", 46 + grant_types: ["authorization_code", "refresh_token"], 47 + response_types: ["code"], 48 + application_type: "web", 49 + token_endpoint_auth_method: "none", // No auth for loopback 50 dpop_bound_access_tokens: true, 51 }), 52 }; ··· 60 61 const metadata = { 62 client_id: clientId, 63 + client_name: "ATlast", 64 client_uri: appUrl, 65 redirect_uris: [redirectUri], 66 + scope: "atproto transition:generic", 67 + grant_types: ["authorization_code", "refresh_token"], 68 + response_types: ["code"], 69 + application_type: "web", 70 + token_endpoint_auth_method: "private_key_jwt", 71 + token_endpoint_auth_signing_alg: "ES256", 72 dpop_bound_access_tokens: true, 73 jwks_uri: jwksUri, 74 }; ··· 76 return { 77 statusCode: 200, 78 headers: { 79 + "Content-Type": "application/json", 80 + "Access-Control-Allow-Origin": "*", 81 + "Cache-Control": "no-store", 82 }, 83 body: JSON.stringify(metadata), 84 }; 85 } catch (error) { 86 + console.error("Client metadata error:", error); 87 return { 88 statusCode: 500, 89 + headers: { "Content-Type": "application/json" }, 90 + body: JSON.stringify({ error: "Internal server error" }), 91 }; 92 } 93 + };
+57 -56
netlify/functions/db-helpers.ts
··· 1 - import { getDbClient } from './db'; 2 3 export async function createUpload( 4 uploadId: string, 5 did: string, 6 sourcePlatform: string, 7 totalUsers: number, 8 - matchedUsers: number 9 ) { 10 const sql = getDbClient(); 11 await sql` ··· 17 18 export async function getOrCreateSourceAccount( 19 sourcePlatform: string, 20 - sourceUsername: string 21 ): Promise<number> { 22 const sql = getDbClient(); 23 - const normalized = sourceUsername.toLowerCase().replace(/[._-]/g, ''); 24 - 25 const result = await sql` 26 INSERT INTO source_accounts (source_platform, source_username, normalized_username) 27 VALUES (${sourcePlatform}, ${sourceUsername}, ${normalized}) ··· 29 source_username = ${sourceUsername} 30 RETURNING id 31 `; 32 - 33 return (result as any[])[0].id; 34 } 35 ··· 37 uploadId: string, 38 did: string, 39 sourceAccountId: number, 40 - sourceDate: string 41 ) { 42 const sql = getDbClient(); 43 await sql` ··· 55 atprotoAvatar: string | undefined, 56 matchScore: number, 57 postCount: number, 58 - followerCount: number 59 ): Promise<number> { 60 const sql = getDbClient(); 61 const result = await sql` 62 INSERT INTO atproto_matches ( 63 - source_account_id, atproto_did, atproto_handle, 64 atproto_display_name, atproto_avatar, match_score, 65 post_count, follower_count 66 ) ··· 79 last_verified = NOW() 80 RETURNING id 81 `; 82 - 83 return (result as any[])[0].id; 84 } 85 86 export async function markSourceAccountMatched(sourceAccountId: number) { 87 const sql = getDbClient(); 88 await sql` 89 - UPDATE source_accounts 90 SET match_found = true, match_found_at = NOW() 91 WHERE id = ${sourceAccountId} 92 `; ··· 96 did: string, 97 atprotoMatchId: number, 98 sourceAccountId: number, 99 - viewed: boolean = false 100 ) { 101 const sql = getDbClient(); 102 await sql` 103 INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) 104 - VALUES (${did}, ${atprotoMatchId}, ${sourceAccountId}, ${viewed}, ${viewed ? 'NOW()' : null}) 105 ON CONFLICT (did, atproto_match_id) DO UPDATE SET 106 viewed = ${viewed}, 107 viewed_at = CASE WHEN ${viewed} THEN NOW() ELSE user_match_status.viewed_at END ··· 111 // NEW: Bulk operations for Phase 2 112 export async function bulkCreateSourceAccounts( 113 sourcePlatform: string, 114 - usernames: string[] 115 ): Promise<Map<string, number>> { 116 const sql = getDbClient(); 117 - 118 // Prepare bulk insert values 119 - const values = usernames.map(username => ({ 120 platform: sourcePlatform, 121 username: username, 122 - normalized: username.toLowerCase().replace(/[._-]/g, '') 123 })); 124 - 125 // Build bulk insert query with unnest 126 - const platforms = values.map(v => v.platform); 127 - const source_usernames = values.map(v => v.username); 128 - const normalized = values.map(v => v.normalized); 129 130 const result = await sql` 131 INSERT INTO source_accounts (source_platform, source_username, normalized_username) ··· 140 RETURNING id, normalized_username 141 `; 142 143 - 144 // Create map of normalized username to ID 145 const idMap = new Map<string, number>(); 146 for (const row of result as any[]) { 147 idMap.set(row.normalized_username, row.id); 148 } 149 - 150 return idMap; 151 } 152 153 export async function bulkLinkUserToSourceAccounts( 154 uploadId: string, 155 did: string, 156 - links: Array<{ sourceAccountId: number; sourceDate: string }> 157 ) { 158 const sql = getDbClient(); 159 - 160 const numLinks = links.length; 161 if (numLinks === 0) return; 162 163 // Extract arrays for columns that change 164 - const sourceAccountIds = links.map(l => l.sourceAccountId); 165 - const sourceDates = links.map(l => l.sourceDate); 166 167 // Create arrays for the static columns 168 const uploadIds = Array(numLinks).fill(uploadId); 169 const dids = Array(numLinks).fill(did); 170 - 171 // Use the parallel UNNEST pattern, which is proven to work in other functions 172 await sql` 173 INSERT INTO user_source_follows (upload_id, did, source_account_id, source_date) ··· 193 matchScore: number; 194 postCount?: number; 195 followerCount?: number; 196 - }> 197 ): Promise<Map<string, number>> { 198 const sql = getDbClient(); 199 - 200 if (matches.length === 0) return new Map(); 201 - 202 - const sourceAccountId = matches.map(m => m.sourceAccountId) 203 - const atprotoDid = matches.map(m => m.atprotoDid) 204 - const atprotoHandle = matches.map(m => m.atprotoHandle) 205 - const atprotoDisplayName = matches.map(m => m.atprotoDisplayName || null) 206 - const atprotoAvatar = matches.map(m => m.atprotoAvatar || null) 207 - const atprotoDescription = matches.map(m => m.atprotoDescription || null) 208 - const matchScore = matches.map(m => m.matchScore) 209 - const postCount = matches.map(m => m.postCount || 0) 210 - const followerCount = matches.map(m => m.followerCount || 0) 211 212 const result = await sql` 213 INSERT INTO atproto_matches ( 214 - source_account_id, atproto_did, atproto_handle, 215 atproto_display_name, atproto_avatar, atproto_description, 216 match_score, post_count, follower_count 217 ) ··· 241 last_verified = NOW() 242 RETURNING id, source_account_id, atproto_did 243 `; 244 - 245 // Create map of "sourceAccountId:atprotoDid" to match ID 246 const idMap = new Map<string, number>(); 247 for (const row of result as any[]) { 248 idMap.set(`${row.source_account_id}:${row.atproto_did}`, row.id); 249 } 250 - 251 return idMap; 252 } 253 254 - export async function bulkMarkSourceAccountsMatched(sourceAccountIds: number[]) { 255 const sql = getDbClient(); 256 - 257 if (sourceAccountIds.length === 0) return; 258 - 259 await sql` 260 - UPDATE source_accounts 261 SET match_found = true, match_found_at = NOW() 262 WHERE id = ANY(${sourceAccountIds}) 263 `; ··· 269 atprotoMatchId: number; 270 sourceAccountId: number; 271 viewed: boolean; 272 - }> 273 ) { 274 const sql = getDbClient(); 275 - 276 if (statuses.length === 0) return; 277 - 278 - const did = statuses.map(s => s.did) 279 - const atprotoMatchId = statuses.map(s => s.atprotoMatchId) 280 - const sourceAccountId = statuses.map(s => s.sourceAccountId) 281 - const viewedFlags = statuses.map(s => s.viewed); 282 - const viewedDates = statuses.map(s => s.viewed ? new Date() : null); 283 284 await sql` 285 INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) ··· 294 viewed = EXCLUDED.viewed, 295 viewed_at = CASE WHEN EXCLUDED.viewed THEN NOW() ELSE user_match_status.viewed_at END 296 `; 297 - }
··· 1 + import { getDbClient } from "./db"; 2 3 export async function createUpload( 4 uploadId: string, 5 did: string, 6 sourcePlatform: string, 7 totalUsers: number, 8 + matchedUsers: number, 9 ) { 10 const sql = getDbClient(); 11 await sql` ··· 17 18 export async function getOrCreateSourceAccount( 19 sourcePlatform: string, 20 + sourceUsername: string, 21 ): Promise<number> { 22 const sql = getDbClient(); 23 + const normalized = sourceUsername.toLowerCase().replace(/[._-]/g, ""); 24 + 25 const result = await sql` 26 INSERT INTO source_accounts (source_platform, source_username, normalized_username) 27 VALUES (${sourcePlatform}, ${sourceUsername}, ${normalized}) ··· 29 source_username = ${sourceUsername} 30 RETURNING id 31 `; 32 + 33 return (result as any[])[0].id; 34 } 35 ··· 37 uploadId: string, 38 did: string, 39 sourceAccountId: number, 40 + sourceDate: string, 41 ) { 42 const sql = getDbClient(); 43 await sql` ··· 55 atprotoAvatar: string | undefined, 56 matchScore: number, 57 postCount: number, 58 + followerCount: number, 59 ): Promise<number> { 60 const sql = getDbClient(); 61 const result = await sql` 62 INSERT INTO atproto_matches ( 63 + source_account_id, atproto_did, atproto_handle, 64 atproto_display_name, atproto_avatar, match_score, 65 post_count, follower_count 66 ) ··· 79 last_verified = NOW() 80 RETURNING id 81 `; 82 + 83 return (result as any[])[0].id; 84 } 85 86 export async function markSourceAccountMatched(sourceAccountId: number) { 87 const sql = getDbClient(); 88 await sql` 89 + UPDATE source_accounts 90 SET match_found = true, match_found_at = NOW() 91 WHERE id = ${sourceAccountId} 92 `; ··· 96 did: string, 97 atprotoMatchId: number, 98 sourceAccountId: number, 99 + viewed: boolean = false, 100 ) { 101 const sql = getDbClient(); 102 await sql` 103 INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) 104 + VALUES (${did}, ${atprotoMatchId}, ${sourceAccountId}, ${viewed}, ${viewed ? "NOW()" : null}) 105 ON CONFLICT (did, atproto_match_id) DO UPDATE SET 106 viewed = ${viewed}, 107 viewed_at = CASE WHEN ${viewed} THEN NOW() ELSE user_match_status.viewed_at END ··· 111 // NEW: Bulk operations for Phase 2 112 export async function bulkCreateSourceAccounts( 113 sourcePlatform: string, 114 + usernames: string[], 115 ): Promise<Map<string, number>> { 116 const sql = getDbClient(); 117 + 118 // Prepare bulk insert values 119 + const values = usernames.map((username) => ({ 120 platform: sourcePlatform, 121 username: username, 122 + normalized: username.toLowerCase().replace(/[._-]/g, ""), 123 })); 124 + 125 // Build bulk insert query with unnest 126 + const platforms = values.map((v) => v.platform); 127 + const source_usernames = values.map((v) => v.username); 128 + const normalized = values.map((v) => v.normalized); 129 130 const result = await sql` 131 INSERT INTO source_accounts (source_platform, source_username, normalized_username) ··· 140 RETURNING id, normalized_username 141 `; 142 143 // Create map of normalized username to ID 144 const idMap = new Map<string, number>(); 145 for (const row of result as any[]) { 146 idMap.set(row.normalized_username, row.id); 147 } 148 + 149 return idMap; 150 } 151 152 export async function bulkLinkUserToSourceAccounts( 153 uploadId: string, 154 did: string, 155 + links: Array<{ sourceAccountId: number; sourceDate: string }>, 156 ) { 157 const sql = getDbClient(); 158 + 159 const numLinks = links.length; 160 if (numLinks === 0) return; 161 162 // Extract arrays for columns that change 163 + const sourceAccountIds = links.map((l) => l.sourceAccountId); 164 + const sourceDates = links.map((l) => l.sourceDate); 165 166 // Create arrays for the static columns 167 const uploadIds = Array(numLinks).fill(uploadId); 168 const dids = Array(numLinks).fill(did); 169 + 170 // Use the parallel UNNEST pattern, which is proven to work in other functions 171 await sql` 172 INSERT INTO user_source_follows (upload_id, did, source_account_id, source_date) ··· 192 matchScore: number; 193 postCount?: number; 194 followerCount?: number; 195 + }>, 196 ): Promise<Map<string, number>> { 197 const sql = getDbClient(); 198 + 199 if (matches.length === 0) return new Map(); 200 + 201 + const sourceAccountId = matches.map((m) => m.sourceAccountId); 202 + const atprotoDid = matches.map((m) => m.atprotoDid); 203 + const atprotoHandle = matches.map((m) => m.atprotoHandle); 204 + const atprotoDisplayName = matches.map((m) => m.atprotoDisplayName || null); 205 + const atprotoAvatar = matches.map((m) => m.atprotoAvatar || null); 206 + const atprotoDescription = matches.map((m) => m.atprotoDescription || null); 207 + const matchScore = matches.map((m) => m.matchScore); 208 + const postCount = matches.map((m) => m.postCount || 0); 209 + const followerCount = matches.map((m) => m.followerCount || 0); 210 211 const result = await sql` 212 INSERT INTO atproto_matches ( 213 + source_account_id, atproto_did, atproto_handle, 214 atproto_display_name, atproto_avatar, atproto_description, 215 match_score, post_count, follower_count 216 ) ··· 240 last_verified = NOW() 241 RETURNING id, source_account_id, atproto_did 242 `; 243 + 244 // Create map of "sourceAccountId:atprotoDid" to match ID 245 const idMap = new Map<string, number>(); 246 for (const row of result as any[]) { 247 idMap.set(`${row.source_account_id}:${row.atproto_did}`, row.id); 248 } 249 + 250 return idMap; 251 } 252 253 + export async function bulkMarkSourceAccountsMatched( 254 + sourceAccountIds: number[], 255 + ) { 256 const sql = getDbClient(); 257 + 258 if (sourceAccountIds.length === 0) return; 259 + 260 await sql` 261 + UPDATE source_accounts 262 SET match_found = true, match_found_at = NOW() 263 WHERE id = ANY(${sourceAccountIds}) 264 `; ··· 270 atprotoMatchId: number; 271 sourceAccountId: number; 272 viewed: boolean; 273 + }>, 274 ) { 275 const sql = getDbClient(); 276 + 277 if (statuses.length === 0) return; 278 + 279 + const did = statuses.map((s) => s.did); 280 + const atprotoMatchId = statuses.map((s) => s.atprotoMatchId); 281 + const sourceAccountId = statuses.map((s) => s.sourceAccountId); 282 + const viewedFlags = statuses.map((s) => s.viewed); 283 + const viewedDates = statuses.map((s) => (s.viewed ? new Date() : null)); 284 285 await sql` 286 INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) ··· 295 viewed = EXCLUDED.viewed, 296 viewed_at = CASE WHEN EXCLUDED.viewed THEN NOW() ELSE user_match_status.viewed_at END 297 `; 298 + }
+28 -24
netlify/functions/db.ts
··· 1 - import { neon, NeonQueryFunction } from '@neondatabase/serverless'; 2 3 let sql: NeonQueryFunction<any, any> | undefined = undefined; 4 let connectionInitialized = false; ··· 14 export async function initDB() { 15 const sql = getDbClient(); 16 17 - console.log('🧠 Connecting to DB:', process.env.NETLIFY_DATABASE_URL); 18 19 try { 20 - const res: any = await sql`SELECT current_database() AS db, current_user AS user, NOW() AS now`; 21 - console.log('✅ Connected:', res[0]); 22 } catch (e) { 23 - console.error('❌ Connection failed:', e); 24 throw e; 25 } 26 ··· 143 `; 144 145 // ==================== ENHANCED INDEXES FOR PHASE 2 ==================== 146 - 147 // Existing indexes 148 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 149 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`; ··· 159 160 // For sorting 161 await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_stats ON atproto_matches(source_account_id, found_at DESC, post_count DESC, follower_count DESC)`; 162 - 163 // For session lookups (most frequent query) 164 - await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_did ON user_sessions(did)`; 165 - await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at)`; 166 - 167 // For OAuth state/session cleanup 168 await sql`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at)`; 169 await sql`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires ON oauth_sessions(expires_at)`; 170 - 171 // For upload queries by user 172 await sql`CREATE INDEX IF NOT EXISTS idx_user_uploads_did_created ON user_uploads(did, created_at DESC)`; 173 - 174 // For upload details pagination (composite index for ORDER BY + JOIN) 175 await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_upload_created ON user_source_follows(upload_id, source_account_id)`; 176 - 177 // For match status queries 178 await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_match_id ON user_match_status(atproto_match_id)`; 179 - 180 // Composite index for the common join pattern in get-upload-details 181 await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source_active ON atproto_matches(source_account_id, is_active) WHERE is_active = true`; 182 - 183 // For bulk operations - normalized username lookups 184 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`; 185 186 - console.log('✅ Database indexes created/verified'); 187 } 188 189 export async function cleanupExpiredSessions() { 190 const sql = getDbClient(); 191 - 192 // Use indexes for efficient cleanup 193 - const statesDeleted = await sql`DELETE FROM oauth_states WHERE expires_at < NOW()`; 194 - const sessionsDeleted = await sql`DELETE FROM oauth_sessions WHERE expires_at < NOW()`; 195 - const userSessionsDeleted = await sql`DELETE FROM user_sessions WHERE expires_at < NOW()`; 196 - 197 - console.log('🧹 Cleanup:', { 198 states: (statesDeleted as any).length, 199 sessions: (sessionsDeleted as any).length, 200 - userSessions: (userSessionsDeleted as any).length 201 }); 202 } 203 204 - export { getDbClient as sql };
··· 1 + import { neon, NeonQueryFunction } from "@neondatabase/serverless"; 2 3 let sql: NeonQueryFunction<any, any> | undefined = undefined; 4 let connectionInitialized = false; ··· 14 export async function initDB() { 15 const sql = getDbClient(); 16 17 + console.log("🧠 Connecting to DB:", process.env.NETLIFY_DATABASE_URL); 18 19 try { 20 + const res: any = 21 + await sql`SELECT current_database() AS db, current_user AS user, NOW() AS now`; 22 + console.log("✅ Connected:", res[0]); 23 } catch (e) { 24 + console.error("❌ Connection failed:", e); 25 throw e; 26 } 27 ··· 144 `; 145 146 // ==================== ENHANCED INDEXES FOR PHASE 2 ==================== 147 + 148 // Existing indexes 149 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 150 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`; ··· 160 161 // For sorting 162 await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_stats ON atproto_matches(source_account_id, found_at DESC, post_count DESC, follower_count DESC)`; 163 + 164 // For session lookups (most frequent query) 165 + await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_did ON user_sessions(did)`; 166 + await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at)`; 167 + 168 // For OAuth state/session cleanup 169 await sql`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at)`; 170 await sql`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires ON oauth_sessions(expires_at)`; 171 + 172 // For upload queries by user 173 await sql`CREATE INDEX IF NOT EXISTS idx_user_uploads_did_created ON user_uploads(did, created_at DESC)`; 174 + 175 // For upload details pagination (composite index for ORDER BY + JOIN) 176 await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_upload_created ON user_source_follows(upload_id, source_account_id)`; 177 + 178 // For match status queries 179 await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_match_id ON user_match_status(atproto_match_id)`; 180 + 181 // Composite index for the common join pattern in get-upload-details 182 await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source_active ON atproto_matches(source_account_id, is_active) WHERE is_active = true`; 183 + 184 // For bulk operations - normalized username lookups 185 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`; 186 187 + console.log("✅ Database indexes created/verified"); 188 } 189 190 export async function cleanupExpiredSessions() { 191 const sql = getDbClient(); 192 + 193 // Use indexes for efficient cleanup 194 + const statesDeleted = 195 + await sql`DELETE FROM oauth_states WHERE expires_at < NOW()`; 196 + const sessionsDeleted = 197 + await sql`DELETE FROM oauth_sessions WHERE expires_at < NOW()`; 198 + const userSessionsDeleted = 199 + await sql`DELETE FROM user_sessions WHERE expires_at < NOW()`; 200 + 201 + console.log("🧹 Cleanup:", { 202 states: (statesDeleted as any).length, 203 sessions: (sessionsDeleted as any).length, 204 + userSessions: (userSessionsDeleted as any).length, 205 }); 206 } 207 208 + export { getDbClient as sql };
+44 -39
netlify/functions/get-upload-details.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { userSessions } from './oauth-stores-db'; 3 - import { getDbClient } from './db'; 4 - import cookie from 'cookie'; 5 6 const DEFAULT_PAGE_SIZE = 50; 7 const MAX_PAGE_SIZE = 100; 8 9 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 10 try { 11 const uploadId = event.queryStringParameters?.uploadId; 12 - const page = parseInt(event.queryStringParameters?.page || '1'); 13 const pageSize = Math.min( 14 - parseInt(event.queryStringParameters?.pageSize || String(DEFAULT_PAGE_SIZE)), 15 - MAX_PAGE_SIZE 16 ); 17 18 if (!uploadId) { 19 return { 20 statusCode: 400, 21 - headers: { 'Content-Type': 'application/json' }, 22 - body: JSON.stringify({ error: 'uploadId is required' }), 23 }; 24 } 25 26 if (page < 1 || pageSize < 1) { 27 return { 28 statusCode: 400, 29 - headers: { 'Content-Type': 'application/json' }, 30 - body: JSON.stringify({ error: 'Invalid page or pageSize parameters' }), 31 }; 32 } 33 34 // Get session from cookie 35 - const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {}; 36 const sessionId = cookies.atlast_session; 37 38 if (!sessionId) { 39 return { 40 statusCode: 401, 41 - headers: { 'Content-Type': 'application/json' }, 42 - body: JSON.stringify({ error: 'No session cookie' }), 43 }; 44 } 45 ··· 48 if (!userSession) { 49 return { 50 statusCode: 401, 51 - headers: { 'Content-Type': 'application/json' }, 52 - body: JSON.stringify({ error: 'Invalid or expired session' }), 53 }; 54 } 55 ··· 64 if ((uploadCheck as any[]).length === 0) { 65 return { 66 statusCode: 404, 67 - headers: { 'Content-Type': 'application/json' }, 68 - body: JSON.stringify({ error: 'Upload not found' }), 69 }; 70 } 71 ··· 75 76 // Fetch paginated results with optimized query 77 const results = await sql` 78 - SELECT 79 sa.source_username, 80 sa.normalized_username, 81 usf.source_date, ··· 98 LEFT JOIN atproto_matches am ON sa.id = am.source_account_id AND am.is_active = true 99 LEFT JOIN user_match_status ums ON am.id = ums.atproto_match_id AND ums.did = ${userSession.did} 100 WHERE usf.upload_id = ${uploadId} 101 - ORDER BY 102 -- 1. Users with matches first 103 CASE WHEN am.atproto_did IS NOT NULL THEN 0 ELSE 1 END, 104 -- 2. New matches (found after initial upload) ··· 115 116 // Group results by source username 117 const groupedResults = new Map<string, any>(); 118 - 119 (results as any[]).forEach((row: any) => { 120 const username = row.source_username; 121 - 122 // Get or create the entry for this username 123 let userResult = groupedResults.get(username); 124 - 125 if (!userResult) { 126 userResult = { 127 sourceUser: { 128 username: username, 129 - date: row.source_date || '', 130 }, 131 atprotoMatches: [], 132 }; 133 groupedResults.set(username, userResult); // Add to map, this preserves the order 134 } 135 - 136 // Add the match (if it exists) to the array 137 if (row.atproto_did) { 138 userResult.atprotoMatches.push({ ··· 156 return { 157 statusCode: 200, 158 headers: { 159 - 'Content-Type': 'application/json', 160 - 'Access-Control-Allow-Origin': '*', 161 - 'Cache-Control': 'private, max-age=600', // 10 minute browser cache 162 }, 163 - body: JSON.stringify({ 164 results: searchResults, 165 pagination: { 166 page, ··· 168 totalPages, 169 totalUsers, 170 hasNextPage: page < totalPages, 171 - hasPrevPage: page > 1 172 - } 173 }), 174 }; 175 - 176 } catch (error) { 177 - console.error('Get upload details error:', error); 178 return { 179 statusCode: 500, 180 - headers: { 'Content-Type': 'application/json' }, 181 - body: JSON.stringify({ 182 - error: 'Failed to fetch upload details', 183 - details: error instanceof Error ? error.message : 'Unknown error' 184 }), 185 }; 186 } 187 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { userSessions } from "./oauth-stores-db"; 3 + import { getDbClient } from "./db"; 4 + import cookie from "cookie"; 5 6 const DEFAULT_PAGE_SIZE = 50; 7 const MAX_PAGE_SIZE = 100; 8 9 + export const handler: Handler = async ( 10 + event: HandlerEvent, 11 + ): Promise<HandlerResponse> => { 12 try { 13 const uploadId = event.queryStringParameters?.uploadId; 14 + const page = parseInt(event.queryStringParameters?.page || "1"); 15 const pageSize = Math.min( 16 + parseInt( 17 + event.queryStringParameters?.pageSize || String(DEFAULT_PAGE_SIZE), 18 + ), 19 + MAX_PAGE_SIZE, 20 ); 21 22 if (!uploadId) { 23 return { 24 statusCode: 400, 25 + headers: { "Content-Type": "application/json" }, 26 + body: JSON.stringify({ error: "uploadId is required" }), 27 }; 28 } 29 30 if (page < 1 || pageSize < 1) { 31 return { 32 statusCode: 400, 33 + headers: { "Content-Type": "application/json" }, 34 + body: JSON.stringify({ error: "Invalid page or pageSize parameters" }), 35 }; 36 } 37 38 // Get session from cookie 39 + const cookies = event.headers.cookie 40 + ? cookie.parse(event.headers.cookie) 41 + : {}; 42 const sessionId = cookies.atlast_session; 43 44 if (!sessionId) { 45 return { 46 statusCode: 401, 47 + headers: { "Content-Type": "application/json" }, 48 + body: JSON.stringify({ error: "No session cookie" }), 49 }; 50 } 51 ··· 54 if (!userSession) { 55 return { 56 statusCode: 401, 57 + headers: { "Content-Type": "application/json" }, 58 + body: JSON.stringify({ error: "Invalid or expired session" }), 59 }; 60 } 61 ··· 70 if ((uploadCheck as any[]).length === 0) { 71 return { 72 statusCode: 404, 73 + headers: { "Content-Type": "application/json" }, 74 + body: JSON.stringify({ error: "Upload not found" }), 75 }; 76 } 77 ··· 81 82 // Fetch paginated results with optimized query 83 const results = await sql` 84 + SELECT 85 sa.source_username, 86 sa.normalized_username, 87 usf.source_date, ··· 104 LEFT JOIN atproto_matches am ON sa.id = am.source_account_id AND am.is_active = true 105 LEFT JOIN user_match_status ums ON am.id = ums.atproto_match_id AND ums.did = ${userSession.did} 106 WHERE usf.upload_id = ${uploadId} 107 + ORDER BY 108 -- 1. Users with matches first 109 CASE WHEN am.atproto_did IS NOT NULL THEN 0 ELSE 1 END, 110 -- 2. New matches (found after initial upload) ··· 121 122 // Group results by source username 123 const groupedResults = new Map<string, any>(); 124 + 125 (results as any[]).forEach((row: any) => { 126 const username = row.source_username; 127 + 128 // Get or create the entry for this username 129 let userResult = groupedResults.get(username); 130 + 131 if (!userResult) { 132 userResult = { 133 sourceUser: { 134 username: username, 135 + date: row.source_date || "", 136 }, 137 atprotoMatches: [], 138 }; 139 groupedResults.set(username, userResult); // Add to map, this preserves the order 140 } 141 + 142 // Add the match (if it exists) to the array 143 if (row.atproto_did) { 144 userResult.atprotoMatches.push({ ··· 162 return { 163 statusCode: 200, 164 headers: { 165 + "Content-Type": "application/json", 166 + "Access-Control-Allow-Origin": "*", 167 + "Cache-Control": "private, max-age=600", // 10 minute browser cache 168 }, 169 + body: JSON.stringify({ 170 results: searchResults, 171 pagination: { 172 page, ··· 174 totalPages, 175 totalUsers, 176 hasNextPage: page < totalPages, 177 + hasPrevPage: page > 1, 178 + }, 179 }), 180 }; 181 } catch (error) { 182 + console.error("Get upload details error:", error); 183 return { 184 statusCode: 500, 185 + headers: { "Content-Type": "application/json" }, 186 + body: JSON.stringify({ 187 + error: "Failed to fetch upload details", 188 + details: error instanceof Error ? error.message : "Unknown error", 189 }), 190 }; 191 } 192 + };
+24 -21
netlify/functions/get-uploads.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { userSessions } from './oauth-stores-db'; 3 - import { getDbClient } from './db'; 4 - import cookie from 'cookie'; 5 6 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 7 try { 8 // Get session from cookie 9 - const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {}; 10 const sessionId = cookies.atlast_session; 11 12 if (!sessionId) { 13 return { 14 statusCode: 401, 15 - headers: { 'Content-Type': 'application/json' }, 16 - body: JSON.stringify({ error: 'No session cookie' }), 17 }; 18 } 19 ··· 22 if (!userSession) { 23 return { 24 statusCode: 401, 25 - headers: { 'Content-Type': 'application/json' }, 26 - body: JSON.stringify({ error: 'Invalid or expired session' }), 27 }; 28 } 29 ··· 31 32 // Fetch all uploads for this user 33 const uploads = await sql` 34 - SELECT 35 upload_id, 36 source_platform, 37 created_at, ··· 46 return { 47 statusCode: 200, 48 headers: { 49 - 'Content-Type': 'application/json', 50 - 'Access-Control-Allow-Origin': '*', 51 }, 52 body: JSON.stringify({ 53 uploads: (uploads as any[]).map((upload: any) => ({ ··· 57 totalUsers: upload.total_users, 58 matchedUsers: upload.matched_users, 59 unmatchedUsers: upload.unmatched_users, 60 - })) 61 }), 62 }; 63 - 64 } catch (error) { 65 - console.error('Get uploads error:', error); 66 return { 67 statusCode: 500, 68 - headers: { 'Content-Type': 'application/json' }, 69 - body: JSON.stringify({ 70 - error: 'Failed to fetch uploads', 71 - details: error instanceof Error ? error.message : 'Unknown error' 72 }), 73 }; 74 } 75 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { userSessions } from "./oauth-stores-db"; 3 + import { getDbClient } from "./db"; 4 + import cookie from "cookie"; 5 6 + export const handler: Handler = async ( 7 + event: HandlerEvent, 8 + ): Promise<HandlerResponse> => { 9 try { 10 // Get session from cookie 11 + const cookies = event.headers.cookie 12 + ? cookie.parse(event.headers.cookie) 13 + : {}; 14 const sessionId = cookies.atlast_session; 15 16 if (!sessionId) { 17 return { 18 statusCode: 401, 19 + headers: { "Content-Type": "application/json" }, 20 + body: JSON.stringify({ error: "No session cookie" }), 21 }; 22 } 23 ··· 26 if (!userSession) { 27 return { 28 statusCode: 401, 29 + headers: { "Content-Type": "application/json" }, 30 + body: JSON.stringify({ error: "Invalid or expired session" }), 31 }; 32 } 33 ··· 35 36 // Fetch all uploads for this user 37 const uploads = await sql` 38 + SELECT 39 upload_id, 40 source_platform, 41 created_at, ··· 50 return { 51 statusCode: 200, 52 headers: { 53 + "Content-Type": "application/json", 54 + "Access-Control-Allow-Origin": "*", 55 }, 56 body: JSON.stringify({ 57 uploads: (uploads as any[]).map((upload: any) => ({ ··· 61 totalUsers: upload.total_users, 62 matchedUsers: upload.matched_users, 63 unmatchedUsers: upload.unmatched_users, 64 + })), 65 }), 66 }; 67 } catch (error) { 68 + console.error("Get uploads error:", error); 69 return { 70 statusCode: 500, 71 + headers: { "Content-Type": "application/json" }, 72 + body: JSON.stringify({ 73 + error: "Failed to fetch uploads", 74 + details: error instanceof Error ? error.message : "Unknown error", 75 }), 76 }; 77 } 78 + };
+11 -11
netlify/functions/init-db.ts
··· 1 - import { Handler } from '@netlify/functions'; 2 - import { initDB } from './db'; 3 4 export const handler: Handler = async () => { 5 try { 6 await initDB(); 7 return { 8 statusCode: 200, 9 - headers: { 'Content-Type': 'application/json' }, 10 - body: JSON.stringify({ message: 'Database initialized successfully' }), 11 }; 12 } catch (error) { 13 - console.error('Database initialization error:', error); 14 return { 15 statusCode: 500, 16 - headers: { 'Content-Type': 'application/json' }, 17 - body: JSON.stringify({ 18 - error: 'Failed to initialize database', 19 - details: error instanceof Error ? error.message : 'Unknown error', 20 - stack: error instanceof Error ? error.stack : undefined 21 }), 22 }; 23 } 24 - };
··· 1 + import { Handler } from "@netlify/functions"; 2 + import { initDB } from "./db"; 3 4 export const handler: Handler = async () => { 5 try { 6 await initDB(); 7 return { 8 statusCode: 200, 9 + headers: { "Content-Type": "application/json" }, 10 + body: JSON.stringify({ message: "Database initialized successfully" }), 11 }; 12 } catch (error) { 13 + console.error("Database initialization error:", error); 14 return { 15 statusCode: 500, 16 + headers: { "Content-Type": "application/json" }, 17 + body: JSON.stringify({ 18 + error: "Failed to initialize database", 19 + details: error instanceof Error ? error.message : "Unknown error", 20 + stack: error instanceof Error ? error.stack : undefined, 21 }), 22 }; 23 } 24 + };
+13 -13
netlify/functions/jwks.ts
··· 1 - import { Handler } from '@netlify/functions'; 2 3 const PUBLIC_JWK = { 4 - "kty": "EC", 5 - "x": "3sVbr4xwN7UtmG1L19vL0x9iN-FRcl7p-Wja_xPbhhk", 6 - "y": "Y1XKDaAyDwijp8aEIGHmO46huKjajSQH2cbfpWaWpQ4", 7 - "crv": "P-256", 8 - "kid": "main-key", 9 - "use": "sig", 10 - "alg": "ES256" 11 }; 12 export const handler: Handler = async () => { 13 return { 14 statusCode: 200, 15 headers: { 16 - 'Content-Type': 'application/json', 17 - 'Access-Control-Allow-Origin': '*', 18 - 'Cache-Control': 'public, max-age=3600' 19 }, 20 - body: JSON.stringify({ keys: [PUBLIC_JWK] }) 21 }; 22 - };
··· 1 + import { Handler } from "@netlify/functions"; 2 3 const PUBLIC_JWK = { 4 + kty: "EC", 5 + x: "3sVbr4xwN7UtmG1L19vL0x9iN-FRcl7p-Wja_xPbhhk", 6 + y: "Y1XKDaAyDwijp8aEIGHmO46huKjajSQH2cbfpWaWpQ4", 7 + crv: "P-256", 8 + kid: "main-key", 9 + use: "sig", 10 + alg: "ES256", 11 }; 12 export const handler: Handler = async () => { 13 return { 14 statusCode: 200, 15 headers: { 16 + "Content-Type": "application/json", 17 + "Access-Control-Allow-Origin": "*", 18 + "Cache-Control": "public, max-age=3600", 19 }, 20 + body: JSON.stringify({ keys: [PUBLIC_JWK] }), 21 }; 22 + };
+63 -52
netlify/functions/oauth-callback.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient, atprotoLoopbackClientMetadata } 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 * as crypto from 'crypto'; 7 8 function normalizePrivateKey(key: string): string { 9 - if (!key.includes('\n') && key.includes('\\n')) { 10 - return key.replace(/\\n/g, '\n'); 11 } 12 return key; 13 } 14 15 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 16 const config = getOAuthConfig(); 17 - const isDev = config.clientType === 'loopback'; 18 - 19 - let currentUrl = isDev 20 - ? 'http://127.0.0.1:8888' 21 - : (process.env.DEPLOY_URL 22 - ? `https://${new URL(process.env.DEPLOY_URL).host}` 23 - : process.env.URL || process.env.DEPLOY_PRIME_URL || 'https://atlast.byarielm.fyi'); 24 25 try { 26 - const params = new URLSearchParams(event.rawUrl.split('?')[1] || ''); 27 - const code = params.get('code'); 28 - const state = params.get('state'); 29 30 - console.log('OAuth callback - Mode:', isDev ? 'loopback' : 'production'); 31 - console.log('OAuth callback - URL:', currentUrl); 32 33 if (!code || !state) { 34 return { 35 statusCode: 302, 36 headers: { 37 - 'Location': `${currentUrl}/?error=Missing OAuth parameters` 38 }, 39 - body: '' 40 }; 41 } 42 ··· 44 45 if (isDev) { 46 // LOOPBACK MODE: Use atprotoLoopbackClientMetadata and NO keyset 47 - console.log('🔧 Loopback callback'); 48 - 49 const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 50 - 51 client = new NodeOAuthClient({ 52 clientMetadata: clientMetadata, 53 // No keyset for loopback! ··· 57 } else { 58 // PRODUCTION MODE 59 if (!process.env.OAUTH_PRIVATE_KEY) { 60 - console.error('OAUTH_PRIVATE_KEY not set'); 61 return { 62 statusCode: 302, 63 - headers: { 'Location': `${currentUrl}/?error=Server configuration error` }, 64 - body: '' 65 }; 66 } 67 68 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 69 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 70 - 71 - const currentHost = process.env.DEPLOY_URL 72 ? new URL(process.env.DEPLOY_URL).host 73 - : (event.headers['x-forwarded-host'] || event.headers.host); 74 - 75 currentUrl = `https://${currentHost}`; 76 const redirectUri = `${currentUrl}/.netlify/functions/oauth-callback`; 77 const jwksUri = `${currentUrl}/.netlify/functions/jwks`; ··· 80 client = new NodeOAuthClient({ 81 clientMetadata: { 82 client_id: clientId, 83 - client_name: 'ATlast', 84 client_uri: currentUrl, 85 redirect_uris: [redirectUri], 86 - scope: 'atproto transition:generic', 87 - grant_types: ['authorization_code', 'refresh_token'], 88 - response_types: ['code'], 89 - application_type: 'web', 90 - token_endpoint_auth_method: 'private_key_jwt', 91 - token_endpoint_auth_signing_alg: 'ES256', 92 dpop_bound_access_tokens: true, 93 jwks_uri: jwksUri, 94 } as any, ··· 107 108 // Cookie flags - no Secure flag for loopback 109 const cookieFlags = isDev 110 - ? 'HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/' 111 - : 'HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/; Secure'; 112 - 113 return { 114 statusCode: 302, 115 headers: { 116 - 'Location': `${currentUrl}/?session=${sessionId}`, 117 - 'Set-Cookie': `atlast_session=${sessionId}; ${cookieFlags}` 118 }, 119 - body: '' 120 }; 121 - 122 } catch (error) { 123 - console.error('OAuth callback error:', error); 124 return { 125 statusCode: 302, 126 headers: { 127 - 'Location': `${currentUrl}/?error=OAuth failed: ${error instanceof Error ? error.message : 'Unknown error'}` 128 }, 129 - body: '' 130 }; 131 } 132 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { 3 + NodeOAuthClient, 4 + atprotoLoopbackClientMetadata, 5 + } from "@atproto/oauth-client-node"; 6 + import { JoseKey } from "@atproto/jwk-jose"; 7 + import { stateStore, sessionStore, userSessions } from "./oauth-stores-db"; 8 + import { getOAuthConfig } from "./oauth-config"; 9 + import * as crypto from "crypto"; 10 11 function normalizePrivateKey(key: string): string { 12 + if (!key.includes("\n") && key.includes("\\n")) { 13 + return key.replace(/\\n/g, "\n"); 14 } 15 return key; 16 } 17 18 + export const handler: Handler = async ( 19 + event: HandlerEvent, 20 + ): Promise<HandlerResponse> => { 21 const config = getOAuthConfig(); 22 + const isDev = config.clientType === "loopback"; 23 + 24 + let currentUrl = isDev 25 + ? "http://127.0.0.1:8888" 26 + : process.env.DEPLOY_URL 27 + ? `https://${new URL(process.env.DEPLOY_URL).host}` 28 + : process.env.URL || 29 + process.env.DEPLOY_PRIME_URL || 30 + "https://atlast.byarielm.fyi"; 31 32 try { 33 + const params = new URLSearchParams(event.rawUrl.split("?")[1] || ""); 34 + const code = params.get("code"); 35 + const state = params.get("state"); 36 37 + console.log("OAuth callback - Mode:", isDev ? "loopback" : "production"); 38 + console.log("OAuth callback - URL:", currentUrl); 39 40 if (!code || !state) { 41 return { 42 statusCode: 302, 43 headers: { 44 + Location: `${currentUrl}/?error=Missing OAuth parameters`, 45 }, 46 + body: "", 47 }; 48 } 49 ··· 51 52 if (isDev) { 53 // LOOPBACK MODE: Use atprotoLoopbackClientMetadata and NO keyset 54 + console.log("🔧 Loopback callback"); 55 + 56 const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 57 + 58 client = new NodeOAuthClient({ 59 clientMetadata: clientMetadata, 60 // No keyset for loopback! ··· 64 } else { 65 // PRODUCTION MODE 66 if (!process.env.OAUTH_PRIVATE_KEY) { 67 + console.error("OAUTH_PRIVATE_KEY not set"); 68 return { 69 statusCode: 302, 70 + headers: { 71 + Location: `${currentUrl}/?error=Server configuration error`, 72 + }, 73 + body: "", 74 }; 75 } 76 77 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 78 + const privateKey = await JoseKey.fromImportable( 79 + normalizedKey, 80 + "main-key", 81 + ); 82 + 83 + const currentHost = process.env.DEPLOY_URL 84 ? new URL(process.env.DEPLOY_URL).host 85 + : event.headers["x-forwarded-host"] || event.headers.host; 86 + 87 currentUrl = `https://${currentHost}`; 88 const redirectUri = `${currentUrl}/.netlify/functions/oauth-callback`; 89 const jwksUri = `${currentUrl}/.netlify/functions/jwks`; ··· 92 client = new NodeOAuthClient({ 93 clientMetadata: { 94 client_id: clientId, 95 + client_name: "ATlast", 96 client_uri: currentUrl, 97 redirect_uris: [redirectUri], 98 + scope: "atproto transition:generic", 99 + grant_types: ["authorization_code", "refresh_token"], 100 + response_types: ["code"], 101 + application_type: "web", 102 + token_endpoint_auth_method: "private_key_jwt", 103 + token_endpoint_auth_signing_alg: "ES256", 104 dpop_bound_access_tokens: true, 105 jwks_uri: jwksUri, 106 } as any, ··· 119 120 // Cookie flags - no Secure flag for loopback 121 const cookieFlags = isDev 122 + ? "HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/" 123 + : "HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/; Secure"; 124 + 125 return { 126 statusCode: 302, 127 headers: { 128 + Location: `${currentUrl}/?session=${sessionId}`, 129 + "Set-Cookie": `atlast_session=${sessionId}; ${cookieFlags}`, 130 }, 131 + body: "", 132 }; 133 } catch (error) { 134 + console.error("OAuth callback error:", error); 135 return { 136 statusCode: 302, 137 headers: { 138 + Location: `${currentUrl}/?error=OAuth failed: ${error instanceof Error ? error.message : "Unknown error"}`, 139 }, 140 + body: "", 141 }; 142 } 143 + };
+26 -21
netlify/functions/oauth-config.ts
··· 1 export function getOAuthConfig() { 2 // Check if we have a public URL (production or --live mode) 3 - const baseUrl = process.env.URL || process.env.DEPLOY_URL || process.env.DEPLOY_PRIME_URL; 4 5 // Development: loopback client for local dev 6 // Check if we're running on localhost (true local dev) 7 - const isLocalhost = !baseUrl || 8 - baseUrl.includes('localhost') || 9 - baseUrl.includes('127.0.0.1') || 10 - baseUrl.startsWith('http://localhost') || 11 - baseUrl.startsWith('http://127.0.0.1'); 12 - 13 // Use loopback for localhost, production for everything else 14 const isDev = isLocalhost; 15 16 if (isDev) { 17 - const port = process.env.PORT || '8888'; 18 19 // Special loopback client_id format with query params 20 const clientId = `http://localhost?${new URLSearchParams([ 21 - ['redirect_uri', `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`], 22 - ['scope', 'atproto transition:generic'], 23 ])}`; 24 25 - console.log('Using loopback OAuth for local development'); 26 - console.log('Access your app at: http://127.0.0.1:' + port); 27 - 28 return { 29 clientId: clientId, 30 redirectUri: `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 31 jwksUri: undefined, 32 - clientType: 'loopback' as const, 33 }; 34 } 35 36 // Production: discoverable client logic 37 if (!baseUrl) { 38 - throw new Error('No public URL available'); 39 } 40 - 41 - console.log('Using confidential OAuth client for production'); 42 - console.log('OAuth Config URLs:', { 43 DEPLOY_PRIME_URL: process.env.DEPLOY_PRIME_URL, 44 URL: process.env.URL, 45 CONTEXT: process.env.CONTEXT, 46 - using: baseUrl 47 }); 48 49 return { 50 clientId: `${baseUrl}/.netlify/functions/client-metadata`, // discoverable client URL 51 redirectUri: `${baseUrl}/.netlify/functions/oauth-callback`, 52 jwksUri: `${baseUrl}/.netlify/functions/jwks`, 53 - clientType: 'discoverable' as const, 54 usePrivateKey: true, 55 }; 56 - }
··· 1 export function getOAuthConfig() { 2 // Check if we have a public URL (production or --live mode) 3 + const baseUrl = 4 + process.env.URL || process.env.DEPLOY_URL || process.env.DEPLOY_PRIME_URL; 5 6 // Development: loopback client for local dev 7 // Check if we're running on localhost (true local dev) 8 + const isLocalhost = 9 + !baseUrl || 10 + baseUrl.includes("localhost") || 11 + baseUrl.includes("127.0.0.1") || 12 + baseUrl.startsWith("http://localhost") || 13 + baseUrl.startsWith("http://127.0.0.1"); 14 + 15 // Use loopback for localhost, production for everything else 16 const isDev = isLocalhost; 17 18 if (isDev) { 19 + const port = process.env.PORT || "8888"; 20 21 // Special loopback client_id format with query params 22 const clientId = `http://localhost?${new URLSearchParams([ 23 + [ 24 + "redirect_uri", 25 + `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 26 + ], 27 + ["scope", "atproto transition:generic"], 28 ])}`; 29 30 + console.log("Using loopback OAuth for local development"); 31 + console.log("Access your app at: http://127.0.0.1:" + port); 32 + 33 return { 34 clientId: clientId, 35 redirectUri: `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 36 jwksUri: undefined, 37 + clientType: "loopback" as const, 38 }; 39 } 40 41 // Production: discoverable client logic 42 if (!baseUrl) { 43 + throw new Error("No public URL available"); 44 } 45 + 46 + console.log("Using confidential OAuth client for production"); 47 + console.log("OAuth Config URLs:", { 48 DEPLOY_PRIME_URL: process.env.DEPLOY_PRIME_URL, 49 URL: process.env.URL, 50 CONTEXT: process.env.CONTEXT, 51 + using: baseUrl, 52 }); 53 54 return { 55 clientId: `${baseUrl}/.netlify/functions/client-metadata`, // discoverable client URL 56 redirectUri: `${baseUrl}/.netlify/functions/oauth-callback`, 57 jwksUri: `${baseUrl}/.netlify/functions/jwks`, 58 + clientType: "discoverable" as const, 59 usePrivateKey: true, 60 }; 61 + }
+65 -46
netlify/functions/oauth-start.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient, atprotoLoopbackClientMetadata } from '@atproto/oauth-client-node'; 3 - import { JoseKey } from '@atproto/jwk-jose'; 4 - import { stateStore, sessionStore } from './oauth-stores-db'; 5 - import { getOAuthConfig } from './oauth-config'; 6 7 interface OAuthStartRequestBody { 8 login_hint?: string; ··· 10 } 11 12 function normalizePrivateKey(key: string): string { 13 - if (!key.includes('\n') && key.includes('\\n')) { 14 - return key.replace(/\\n/g, '\n'); 15 } 16 return key; 17 } 18 19 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 20 try { 21 let loginHint: string | undefined = undefined; 22 - 23 if (event.body) { 24 const parsed: OAuthStartRequestBody = JSON.parse(event.body); 25 loginHint = parsed.login_hint; ··· 28 if (!loginHint) { 29 return { 30 statusCode: 400, 31 - headers: { 'Content-Type': 'application/json' }, 32 - body: JSON.stringify({ error: 'login_hint (handle or DID) is required' }), 33 }; 34 } 35 36 const config = getOAuthConfig(); 37 - const isDev = config.clientType === 'loopback'; 38 39 let client: NodeOAuthClient; 40 41 if (isDev) { 42 // LOOPBACK MODE: Use atprotoLoopbackClientMetadata and NO keyset 43 - console.log('🔧 Using loopback OAuth client for development'); 44 - console.log('Client ID:', config.clientId); 45 - 46 const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 47 - 48 client = new NodeOAuthClient({ 49 clientMetadata: clientMetadata, 50 stateStore: stateStore as any, ··· 52 }); 53 } else { 54 // PRODUCTION MODE: Full confidential client with keyset 55 - console.log('🔐 Using confidential OAuth client for production'); 56 - 57 if (!process.env.OAUTH_PRIVATE_KEY) { 58 - throw new Error('OAUTH_PRIVATE_KEY required for production'); 59 } 60 61 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 62 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 63 64 - const currentHost = process.env.DEPLOY_URL 65 ? new URL(process.env.DEPLOY_URL).host 66 - : (event.headers['x-forwarded-host'] || event.headers.host); 67 68 if (!currentHost) { 69 - throw new Error('Missing host header'); 70 } 71 72 const currentUrl = `https://${currentHost}`; ··· 77 client = new NodeOAuthClient({ 78 clientMetadata: { 79 client_id: clientId, 80 - client_name: 'ATlast', 81 client_uri: currentUrl, 82 redirect_uris: [redirectUri], 83 - scope: 'atproto transition:generic', 84 - grant_types: ['authorization_code', 'refresh_token'], 85 - response_types: ['code'], 86 - application_type: 'web', 87 - token_endpoint_auth_method: 'private_key_jwt', 88 - token_endpoint_auth_signing_alg: 'ES256', 89 dpop_bound_access_tokens: true, 90 jwks_uri: jwksUri, 91 } as any, ··· 96 } 97 98 const authUrl = await client.authorize(loginHint, { 99 - scope: 'atproto transition:generic', 100 }); 101 102 return { 103 statusCode: 200, 104 - headers: { 'Content-Type': 'application/json' }, 105 body: JSON.stringify({ url: authUrl.toString() }), 106 }; 107 } catch (error) { 108 - console.error('OAuth start error:', error); 109 110 // Provide user-friendly error messages 111 - let userMessage = 'Failed to start authentication'; 112 - 113 if (error instanceof Error) { 114 - if (error.message.includes('resolve') || error.message.includes('not found')) { 115 - userMessage = 'Account not found. Please check your handle and try again.'; 116 - } else if (error.message.includes('network') || error.message.includes('timeout')) { 117 - userMessage = 'Network error. Please check your connection and try again.'; 118 - } else if (error.message.includes('Invalid identifier')) { 119 - userMessage = 'Invalid handle format. Please use the format: username.bsky.social'; 120 } 121 } 122 - 123 return { 124 statusCode: 500, 125 - headers: { 'Content-Type': 'application/json' }, 126 - body: JSON.stringify({ 127 error: userMessage, 128 - details: error instanceof Error ? error.message : 'Unknown error', 129 }), 130 }; 131 } 132 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { 3 + NodeOAuthClient, 4 + atprotoLoopbackClientMetadata, 5 + } from "@atproto/oauth-client-node"; 6 + import { JoseKey } from "@atproto/jwk-jose"; 7 + import { stateStore, sessionStore } from "./oauth-stores-db"; 8 + import { getOAuthConfig } from "./oauth-config"; 9 10 interface OAuthStartRequestBody { 11 login_hint?: string; ··· 13 } 14 15 function normalizePrivateKey(key: string): string { 16 + if (!key.includes("\n") && key.includes("\\n")) { 17 + return key.replace(/\\n/g, "\n"); 18 } 19 return key; 20 } 21 22 + export const handler: Handler = async ( 23 + event: HandlerEvent, 24 + ): Promise<HandlerResponse> => { 25 try { 26 let loginHint: string | undefined = undefined; 27 + 28 if (event.body) { 29 const parsed: OAuthStartRequestBody = JSON.parse(event.body); 30 loginHint = parsed.login_hint; ··· 33 if (!loginHint) { 34 return { 35 statusCode: 400, 36 + headers: { "Content-Type": "application/json" }, 37 + body: JSON.stringify({ 38 + error: "login_hint (handle or DID) is required", 39 + }), 40 }; 41 } 42 43 const config = getOAuthConfig(); 44 + const isDev = config.clientType === "loopback"; 45 46 let client: NodeOAuthClient; 47 48 if (isDev) { 49 // LOOPBACK MODE: Use atprotoLoopbackClientMetadata and NO keyset 50 + console.log("🔧 Using loopback OAuth client for development"); 51 + console.log("Client ID:", config.clientId); 52 + 53 const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 54 + 55 client = new NodeOAuthClient({ 56 clientMetadata: clientMetadata, 57 stateStore: stateStore as any, ··· 59 }); 60 } else { 61 // PRODUCTION MODE: Full confidential client with keyset 62 + console.log("🔐 Using confidential OAuth client for production"); 63 + 64 if (!process.env.OAUTH_PRIVATE_KEY) { 65 + throw new Error("OAUTH_PRIVATE_KEY required for production"); 66 } 67 68 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 69 + const privateKey = await JoseKey.fromImportable( 70 + normalizedKey, 71 + "main-key", 72 + ); 73 74 + const currentHost = process.env.DEPLOY_URL 75 ? new URL(process.env.DEPLOY_URL).host 76 + : event.headers["x-forwarded-host"] || event.headers.host; 77 78 if (!currentHost) { 79 + throw new Error("Missing host header"); 80 } 81 82 const currentUrl = `https://${currentHost}`; ··· 87 client = new NodeOAuthClient({ 88 clientMetadata: { 89 client_id: clientId, 90 + client_name: "ATlast", 91 client_uri: currentUrl, 92 redirect_uris: [redirectUri], 93 + scope: "atproto transition:generic", 94 + grant_types: ["authorization_code", "refresh_token"], 95 + response_types: ["code"], 96 + application_type: "web", 97 + token_endpoint_auth_method: "private_key_jwt", 98 + token_endpoint_auth_signing_alg: "ES256", 99 dpop_bound_access_tokens: true, 100 jwks_uri: jwksUri, 101 } as any, ··· 106 } 107 108 const authUrl = await client.authorize(loginHint, { 109 + scope: "atproto transition:generic", 110 }); 111 112 return { 113 statusCode: 200, 114 + headers: { "Content-Type": "application/json" }, 115 body: JSON.stringify({ url: authUrl.toString() }), 116 }; 117 } catch (error) { 118 + console.error("OAuth start error:", error); 119 120 // Provide user-friendly error messages 121 + let userMessage = "Failed to start authentication"; 122 + 123 if (error instanceof Error) { 124 + if ( 125 + error.message.includes("resolve") || 126 + error.message.includes("not found") 127 + ) { 128 + userMessage = 129 + "Account not found. Please check your handle and try again."; 130 + } else if ( 131 + error.message.includes("network") || 132 + error.message.includes("timeout") 133 + ) { 134 + userMessage = 135 + "Network error. Please check your connection and try again."; 136 + } else if (error.message.includes("Invalid identifier")) { 137 + userMessage = 138 + "Invalid handle format. Please use the format: username.bsky.social"; 139 } 140 } 141 + 142 return { 143 statusCode: 500, 144 + headers: { "Content-Type": "application/json" }, 145 + body: JSON.stringify({ 146 error: userMessage, 147 + details: error instanceof Error ? error.message : "Unknown error", 148 }), 149 }; 150 } 151 + };
+8 -6
netlify/functions/oauth-stores-db.ts
··· 1 - import { getDbClient } from './db'; 2 3 interface StateData { 4 dpopKey: any; ··· 17 export class PostgresStateStore { 18 async get(key: string): Promise<StateData | undefined> { 19 const result = await sql` 20 - SELECT data FROM oauth_states 21 WHERE key = ${key} AND expires_at > NOW() 22 `; 23 return (result as Record<string, any>[])[0]?.data as StateData | undefined; ··· 40 export class PostgresSessionStore { 41 async get(key: string): Promise<SessionData | undefined> { 42 const result = await sql` 43 - SELECT data FROM oauth_sessions 44 WHERE key = ${key} AND expires_at > NOW() 45 `; 46 - return (result as Record<string, any>[])[0]?.data as SessionData | undefined; 47 } 48 49 async set(key: string, value: SessionData): Promise<void> { ··· 64 export class PostgresUserSessionStore { 65 async get(sessionId: string): Promise<{ did: string } | undefined> { 66 const result = await sql` 67 - SELECT did FROM user_sessions 68 WHERE session_id = ${sessionId} AND expires_at > NOW() 69 `; 70 const row = (result as Record<string, any>[])[0]; ··· 89 90 export const stateStore = new PostgresStateStore(); 91 export const sessionStore = new PostgresSessionStore(); 92 - export const userSessions = new PostgresUserSessionStore();
··· 1 + import { getDbClient } from "./db"; 2 3 interface StateData { 4 dpopKey: any; ··· 17 export class PostgresStateStore { 18 async get(key: string): Promise<StateData | undefined> { 19 const result = await sql` 20 + SELECT data FROM oauth_states 21 WHERE key = ${key} AND expires_at > NOW() 22 `; 23 return (result as Record<string, any>[])[0]?.data as StateData | undefined; ··· 40 export class PostgresSessionStore { 41 async get(key: string): Promise<SessionData | undefined> { 42 const result = await sql` 43 + SELECT data FROM oauth_sessions 44 WHERE key = ${key} AND expires_at > NOW() 45 `; 46 + return (result as Record<string, any>[])[0]?.data as 47 + | SessionData 48 + | undefined; 49 } 50 51 async set(key: string, value: SessionData): Promise<void> { ··· 66 export class PostgresUserSessionStore { 67 async get(sessionId: string): Promise<{ did: string } | undefined> { 68 const result = await sql` 69 + SELECT did FROM user_sessions 70 WHERE session_id = ${sessionId} AND expires_at > NOW() 71 `; 72 const row = (result as Record<string, any>[])[0]; ··· 91 92 export const stateStore = new PostgresStateStore(); 93 export const sessionStore = new PostgresSessionStore(); 94 + export const userSessions = new PostgresUserSessionStore();
+80 -61
netlify/functions/save-results.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { userSessions } from './oauth-stores-db'; 3 - import cookie from 'cookie'; 4 import { 5 createUpload, 6 bulkCreateSourceAccounts, 7 bulkLinkUserToSourceAccounts, 8 bulkStoreAtprotoMatches, 9 bulkMarkSourceAccountsMatched, 10 - bulkCreateUserMatchStatus 11 - } from './db-helpers'; 12 - import { getDbClient } from './db'; 13 14 interface SearchResult { 15 sourceUser: { ··· 37 results: SearchResult[]; 38 } 39 40 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 41 - 42 - if (event.httpMethod !== 'POST') { 43 return { 44 statusCode: 405, 45 - headers: { 'Content-Type': 'application/json' }, 46 - body: JSON.stringify({ error: 'Method not allowed' }), 47 }; 48 } 49 50 try { 51 // Get session from cookie 52 - const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {}; 53 const sessionId = cookies.atlast_session; 54 55 if (!sessionId) { 56 return { 57 statusCode: 401, 58 - headers: { 'Content-Type': 'application/json' }, 59 - body: JSON.stringify({ error: 'No session cookie' }), 60 }; 61 } 62 ··· 65 if (!userSession) { 66 return { 67 statusCode: 401, 68 - headers: { 'Content-Type': 'application/json' }, 69 - body: JSON.stringify({ error: 'Invalid or expired session' }), 70 }; 71 } 72 73 // Parse request body 74 - const body: SaveResultsRequest = JSON.parse(event.body || '{}'); 75 const { uploadId, sourcePlatform, results } = body; 76 77 if (!uploadId || !sourcePlatform || !Array.isArray(results)) { 78 return { 79 statusCode: 400, 80 - headers: { 'Content-Type': 'application/json' }, 81 - body: JSON.stringify({ error: 'uploadId, sourcePlatform, and results are required' }), 82 }; 83 } 84 ··· 87 88 // Check for recent uploads from this user 89 const recentUpload = await sql` 90 - SELECT upload_id FROM user_uploads 91 - WHERE did = ${userSession.did} 92 AND created_at > NOW() - INTERVAL '5 seconds' 93 ORDER BY created_at DESC 94 LIMIT 1 95 `; 96 97 if ((recentUpload as any[]).length > 0) { 98 - console.log(`User ${userSession.did} already saved within 5 seconds, skipping duplicate`); 99 return { 100 statusCode: 200, 101 - headers: { 'Content-Type': 'application/json' }, 102 - body: JSON.stringify({ success: true, message: 'Recently saved' }), 103 }; 104 } 105 ··· 109 userSession.did, 110 sourcePlatform, 111 results.length, 112 - 0 113 ); 114 115 // BULK OPERATION 1: Create all source accounts at once 116 - const allUsernames = results.map(r => r.sourceUser.username); 117 - const sourceAccountIdMap = await bulkCreateSourceAccounts(sourcePlatform, allUsernames); 118 - 119 // BULK OPERATION 2: Link all users to source accounts 120 - const links = results.map(result => { 121 - const normalized = result.sourceUser.username.toLowerCase().replace(/[._-]/g, ''); 122 - const sourceAccountId = sourceAccountIdMap.get(normalized); 123 - return { 124 - sourceAccountId: sourceAccountId!, 125 - sourceDate: result.sourceUser.date 126 - }; 127 - }).filter(link => link.sourceAccountId !== undefined); 128 - 129 await bulkLinkUserToSourceAccounts(uploadId, userSession.did, links); 130 - 131 // BULK OPERATION 3: Store all atproto matches at once 132 const allMatches: Array<{ 133 sourceAccountId: number; ··· 140 postCount: number; 141 followerCount: number; 142 }> = []; 143 - 144 const matchedSourceAccountIds: number[] = []; 145 - 146 for (const result of results) { 147 - const normalized = result.sourceUser.username.toLowerCase().replace(/[._-]/g, ''); 148 const sourceAccountId = sourceAccountIdMap.get(normalized); 149 - 150 - if (sourceAccountId && result.atprotoMatches && result.atprotoMatches.length > 0) { 151 matchedCount++; 152 matchedSourceAccountIds.push(sourceAccountId); 153 - 154 for (const match of result.atprotoMatches) { 155 allMatches.push({ 156 sourceAccountId, ··· 166 } 167 } 168 } 169 - 170 // Store all matches in one operation 171 let matchIdMap = new Map<string, number>(); 172 if (allMatches.length > 0) { 173 matchIdMap = await bulkStoreAtprotoMatches(allMatches); 174 } 175 - 176 // BULK OPERATION 4: Mark all matched source accounts 177 if (matchedSourceAccountIds.length > 0) { 178 await bulkMarkSourceAccountsMatched(matchedSourceAccountIds); 179 } 180 - 181 // BULK OPERATION 5: Create all user match statuses 182 const statuses: Array<{ 183 did: string; ··· 185 sourceAccountId: number; 186 viewed: boolean; 187 }> = []; 188 - 189 for (const match of allMatches) { 190 const key = `${match.sourceAccountId}:${match.atprotoDid}`; 191 const matchId = matchIdMap.get(key); ··· 194 did: userSession.did, 195 atprotoMatchId: matchId, 196 sourceAccountId: match.sourceAccountId, 197 - viewed: true 198 }); 199 } 200 } 201 - 202 if (statuses.length > 0) { 203 await bulkCreateUserMatchStatus(statuses); 204 } 205 206 // Update upload record with final counts 207 await sql` 208 - UPDATE user_uploads 209 SET matched_users = ${matchedCount}, 210 unmatched_users = ${results.length - matchedCount} 211 WHERE upload_id = ${uploadId} ··· 214 return { 215 statusCode: 200, 216 headers: { 217 - 'Content-Type': 'application/json', 218 - 'Access-Control-Allow-Origin': '*', 219 }, 220 body: JSON.stringify({ 221 success: true, 222 uploadId, 223 totalUsers: results.length, 224 matchedUsers: matchedCount, 225 - unmatchedUsers: results.length - matchedCount 226 }), 227 }; 228 - 229 } catch (error) { 230 - console.error('Save results error:', error); 231 return { 232 statusCode: 500, 233 - headers: { 'Content-Type': 'application/json' }, 234 - body: JSON.stringify({ 235 - error: 'Failed to save results', 236 - details: error instanceof Error ? error.message : 'Unknown error' 237 }), 238 }; 239 } 240 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { userSessions } from "./oauth-stores-db"; 3 + import cookie from "cookie"; 4 import { 5 createUpload, 6 bulkCreateSourceAccounts, 7 bulkLinkUserToSourceAccounts, 8 bulkStoreAtprotoMatches, 9 bulkMarkSourceAccountsMatched, 10 + bulkCreateUserMatchStatus, 11 + } from "./db-helpers"; 12 + import { getDbClient } from "./db"; 13 14 interface SearchResult { 15 sourceUser: { ··· 37 results: SearchResult[]; 38 } 39 40 + export const handler: Handler = async ( 41 + event: HandlerEvent, 42 + ): Promise<HandlerResponse> => { 43 + if (event.httpMethod !== "POST") { 44 return { 45 statusCode: 405, 46 + headers: { "Content-Type": "application/json" }, 47 + body: JSON.stringify({ error: "Method not allowed" }), 48 }; 49 } 50 51 try { 52 // Get session from cookie 53 + const cookies = event.headers.cookie 54 + ? cookie.parse(event.headers.cookie) 55 + : {}; 56 const sessionId = cookies.atlast_session; 57 58 if (!sessionId) { 59 return { 60 statusCode: 401, 61 + headers: { "Content-Type": "application/json" }, 62 + body: JSON.stringify({ error: "No session cookie" }), 63 }; 64 } 65 ··· 68 if (!userSession) { 69 return { 70 statusCode: 401, 71 + headers: { "Content-Type": "application/json" }, 72 + body: JSON.stringify({ error: "Invalid or expired session" }), 73 }; 74 } 75 76 // Parse request body 77 + const body: SaveResultsRequest = JSON.parse(event.body || "{}"); 78 const { uploadId, sourcePlatform, results } = body; 79 80 if (!uploadId || !sourcePlatform || !Array.isArray(results)) { 81 return { 82 statusCode: 400, 83 + headers: { "Content-Type": "application/json" }, 84 + body: JSON.stringify({ 85 + error: "uploadId, sourcePlatform, and results are required", 86 + }), 87 }; 88 } 89 ··· 92 93 // Check for recent uploads from this user 94 const recentUpload = await sql` 95 + SELECT upload_id FROM user_uploads 96 + WHERE did = ${userSession.did} 97 AND created_at > NOW() - INTERVAL '5 seconds' 98 ORDER BY created_at DESC 99 LIMIT 1 100 `; 101 102 if ((recentUpload as any[]).length > 0) { 103 + console.log( 104 + `User ${userSession.did} already saved within 5 seconds, skipping duplicate`, 105 + ); 106 return { 107 statusCode: 200, 108 + headers: { "Content-Type": "application/json" }, 109 + body: JSON.stringify({ success: true, message: "Recently saved" }), 110 }; 111 } 112 ··· 116 userSession.did, 117 sourcePlatform, 118 results.length, 119 + 0, 120 ); 121 122 // BULK OPERATION 1: Create all source accounts at once 123 + const allUsernames = results.map((r) => r.sourceUser.username); 124 + const sourceAccountIdMap = await bulkCreateSourceAccounts( 125 + sourcePlatform, 126 + allUsernames, 127 + ); 128 + 129 // BULK OPERATION 2: Link all users to source accounts 130 + const links = results 131 + .map((result) => { 132 + const normalized = result.sourceUser.username 133 + .toLowerCase() 134 + .replace(/[._-]/g, ""); 135 + const sourceAccountId = sourceAccountIdMap.get(normalized); 136 + return { 137 + sourceAccountId: sourceAccountId!, 138 + sourceDate: result.sourceUser.date, 139 + }; 140 + }) 141 + .filter((link) => link.sourceAccountId !== undefined); 142 + 143 await bulkLinkUserToSourceAccounts(uploadId, userSession.did, links); 144 + 145 // BULK OPERATION 3: Store all atproto matches at once 146 const allMatches: Array<{ 147 sourceAccountId: number; ··· 154 postCount: number; 155 followerCount: number; 156 }> = []; 157 + 158 const matchedSourceAccountIds: number[] = []; 159 + 160 for (const result of results) { 161 + const normalized = result.sourceUser.username 162 + .toLowerCase() 163 + .replace(/[._-]/g, ""); 164 const sourceAccountId = sourceAccountIdMap.get(normalized); 165 + 166 + if ( 167 + sourceAccountId && 168 + result.atprotoMatches && 169 + result.atprotoMatches.length > 0 170 + ) { 171 matchedCount++; 172 matchedSourceAccountIds.push(sourceAccountId); 173 + 174 for (const match of result.atprotoMatches) { 175 allMatches.push({ 176 sourceAccountId, ··· 186 } 187 } 188 } 189 + 190 // Store all matches in one operation 191 let matchIdMap = new Map<string, number>(); 192 if (allMatches.length > 0) { 193 matchIdMap = await bulkStoreAtprotoMatches(allMatches); 194 } 195 + 196 // BULK OPERATION 4: Mark all matched source accounts 197 if (matchedSourceAccountIds.length > 0) { 198 await bulkMarkSourceAccountsMatched(matchedSourceAccountIds); 199 } 200 + 201 // BULK OPERATION 5: Create all user match statuses 202 const statuses: Array<{ 203 did: string; ··· 205 sourceAccountId: number; 206 viewed: boolean; 207 }> = []; 208 + 209 for (const match of allMatches) { 210 const key = `${match.sourceAccountId}:${match.atprotoDid}`; 211 const matchId = matchIdMap.get(key); ··· 214 did: userSession.did, 215 atprotoMatchId: matchId, 216 sourceAccountId: match.sourceAccountId, 217 + viewed: true, 218 }); 219 } 220 } 221 + 222 if (statuses.length > 0) { 223 await bulkCreateUserMatchStatus(statuses); 224 } 225 226 // Update upload record with final counts 227 await sql` 228 + UPDATE user_uploads 229 SET matched_users = ${matchedCount}, 230 unmatched_users = ${results.length - matchedCount} 231 WHERE upload_id = ${uploadId} ··· 234 return { 235 statusCode: 200, 236 headers: { 237 + "Content-Type": "application/json", 238 + "Access-Control-Allow-Origin": "*", 239 }, 240 body: JSON.stringify({ 241 success: true, 242 uploadId, 243 totalUsers: results.length, 244 matchedUsers: matchedCount, 245 + unmatchedUsers: results.length - matchedCount, 246 }), 247 }; 248 } catch (error) { 249 + console.error("Save results error:", error); 250 return { 251 statusCode: 500, 252 + headers: { "Content-Type": "application/json" }, 253 + body: JSON.stringify({ 254 + error: "Failed to save results", 255 + details: error instanceof Error ? error.message : "Unknown error", 256 }), 257 }; 258 } 259 + };
+79 -62
netlify/functions/session.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient, atprotoLoopbackClientMetadata } 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 } ··· 19 const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 20 21 // Tier 2: Session metadata cache (DID -> basic info, faster than full OAuth restore) 22 - const sessionMetadataCache = new Map<string, { 23 - did: string; 24 - lastSeen: number; 25 - profileFetchNeeded: boolean; 26 - }>(); 27 28 - export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 29 try { 30 - const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {}; 31 - const sessionId = event.queryStringParameters?.session || cookies.atlast_session; 32 33 if (!sessionId) { 34 return { 35 statusCode: 401, 36 - headers: { 'Content-Type': 'application/json' }, 37 - body: JSON.stringify({ error: 'No session' }), 38 }; 39 } 40 41 // OPTIMIZATION: Check session metadata cache first (avoids DB query) 42 const cachedMetadata = sessionMetadataCache.get(sessionId); 43 const now = Date.now(); 44 - 45 let did: string; 46 - 47 - if (cachedMetadata && (now - cachedMetadata.lastSeen < 60000)) { 48 // Session seen within last minute, trust the cache 49 did = cachedMetadata.did; 50 - console.log('Session metadata from cache'); 51 } else { 52 // Need to verify session from database 53 const userSession = await userSessions.get(sessionId); ··· 56 sessionMetadataCache.delete(sessionId); 57 return { 58 statusCode: 401, 59 - headers: { 'Content-Type': 'application/json' }, 60 - body: JSON.stringify({ error: 'Invalid or expired session' }), 61 }; 62 } 63 - 64 did = userSession.did; 65 - 66 // Update session metadata cache 67 sessionMetadataCache.set(sessionId, { 68 did, 69 lastSeen: now, 70 - profileFetchNeeded: true 71 }); 72 - 73 // Cleanup: Remove old session metadata entries 74 if (sessionMetadataCache.size > 200) { 75 for (const [sid, meta] of sessionMetadataCache.entries()) { 76 - if (now - meta.lastSeen > 300000) { // 5 minutes 77 sessionMetadataCache.delete(sid); 78 } 79 } ··· 83 // Check profile cache (Tier 1) 84 const cached = profileCache.get(did); 85 if (cached && now - cached.timestamp < PROFILE_CACHE_TTL) { 86 - console.log('Returning cached profile for', did); 87 - 88 // Update session metadata last seen 89 const meta = sessionMetadataCache.get(sessionId); 90 if (meta) { 91 meta.lastSeen = now; 92 } 93 - 94 return { 95 statusCode: 200, 96 headers: { 97 - 'Content-Type': 'application/json', 98 - 'Access-Control-Allow-Origin': '*', 99 - 'Cache-Control': 'private, max-age=300', // Browser can cache for 5 minutes 100 - 'X-Cache-Status': 'HIT' 101 }, 102 body: JSON.stringify(cached.data), 103 }; ··· 106 // Cache miss - fetch full profile 107 try { 108 const config = getOAuthConfig(); 109 - const isDev = config.clientType === 'loopback'; 110 111 let client: NodeOAuthClient; 112 ··· 120 }); 121 } else { 122 // Production with private key 123 - const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 124 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 125 126 client = new NodeOAuthClient({ 127 clientMetadata: { 128 client_id: config.clientId, 129 - client_name: 'ATlast', 130 - client_uri: config.clientId.replace('/client-metadata.json', ''), 131 redirect_uris: [config.redirectUri], 132 - scope: 'atproto transition:generic', 133 - grant_types: ['authorization_code', 'refresh_token'], 134 - response_types: ['code'], 135 - application_type: 'web', 136 - token_endpoint_auth_method: 'private_key_jwt', 137 - token_endpoint_auth_signing_alg: 'ES256', 138 dpop_bound_access_tokens: true, 139 jwks_uri: config.jwksUri, 140 }, ··· 146 147 // Restore OAuth session 148 const oauthSession = await client.restore(did); 149 - 150 // Create agent from OAuth session 151 const agent = new Agent(oauthSession); 152 ··· 186 return { 187 statusCode: 200, 188 headers: { 189 - 'Content-Type': 'application/json', 190 - 'Access-Control-Allow-Origin': '*', 191 - 'Cache-Control': 'private, max-age=300', 192 - 'X-Cache-Status': 'MISS' 193 }, 194 body: JSON.stringify(profileData), 195 }; 196 } catch (error) { 197 - console.error('Profile fetch error:', error); 198 - 199 // If profile fetch fails, return basic session info 200 return { 201 statusCode: 200, 202 headers: { 203 - 'Content-Type': 'application/json', 204 - 'Access-Control-Allow-Origin': '*', 205 - 'X-Cache-Status': 'ERROR' 206 }, 207 body: JSON.stringify({ 208 did: did, ··· 211 }; 212 } 213 } catch (error) { 214 - console.error('Session error:', error); 215 return { 216 statusCode: 500, 217 - headers: { 'Content-Type': 'application/json' }, 218 - body: JSON.stringify({ error: 'Internal server error' }), 219 }; 220 } 221 - };
··· 1 + import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + import { 3 + NodeOAuthClient, 4 + atprotoLoopbackClientMetadata, 5 + } from "@atproto/oauth-client-node"; 6 + import { JoseKey } from "@atproto/jwk-jose"; 7 + import { stateStore, sessionStore, userSessions } from "./oauth-stores-db"; 8 + import { getOAuthConfig } from "./oauth-config"; 9 + import { Agent } from "@atproto/api"; 10 + import cookie from "cookie"; 11 12 function normalizePrivateKey(key: string): string { 13 + if (!key.includes("\n") && key.includes("\\n")) { 14 + return key.replace(/\\n/g, "\n"); 15 } 16 return key; 17 } ··· 22 const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 23 24 // Tier 2: Session metadata cache (DID -> basic info, faster than full OAuth restore) 25 + const sessionMetadataCache = new Map< 26 + string, 27 + { 28 + did: string; 29 + lastSeen: number; 30 + profileFetchNeeded: boolean; 31 + } 32 + >(); 33 34 + export const handler: Handler = async ( 35 + event: HandlerEvent, 36 + ): Promise<HandlerResponse> => { 37 try { 38 + const cookies = event.headers.cookie 39 + ? cookie.parse(event.headers.cookie) 40 + : {}; 41 + const sessionId = 42 + event.queryStringParameters?.session || cookies.atlast_session; 43 44 if (!sessionId) { 45 return { 46 statusCode: 401, 47 + headers: { "Content-Type": "application/json" }, 48 + body: JSON.stringify({ error: "No session" }), 49 }; 50 } 51 52 // OPTIMIZATION: Check session metadata cache first (avoids DB query) 53 const cachedMetadata = sessionMetadataCache.get(sessionId); 54 const now = Date.now(); 55 + 56 let did: string; 57 + 58 + if (cachedMetadata && now - cachedMetadata.lastSeen < 60000) { 59 // Session seen within last minute, trust the cache 60 did = cachedMetadata.did; 61 + console.log("Session metadata from cache"); 62 } else { 63 // Need to verify session from database 64 const userSession = await userSessions.get(sessionId); ··· 67 sessionMetadataCache.delete(sessionId); 68 return { 69 statusCode: 401, 70 + headers: { "Content-Type": "application/json" }, 71 + body: JSON.stringify({ error: "Invalid or expired session" }), 72 }; 73 } 74 + 75 did = userSession.did; 76 + 77 // Update session metadata cache 78 sessionMetadataCache.set(sessionId, { 79 did, 80 lastSeen: now, 81 + profileFetchNeeded: true, 82 }); 83 + 84 // Cleanup: Remove old session metadata entries 85 if (sessionMetadataCache.size > 200) { 86 for (const [sid, meta] of sessionMetadataCache.entries()) { 87 + if (now - meta.lastSeen > 300000) { 88 + // 5 minutes 89 sessionMetadataCache.delete(sid); 90 } 91 } ··· 95 // Check profile cache (Tier 1) 96 const cached = profileCache.get(did); 97 if (cached && now - cached.timestamp < PROFILE_CACHE_TTL) { 98 + console.log("Returning cached profile for", did); 99 + 100 // Update session metadata last seen 101 const meta = sessionMetadataCache.get(sessionId); 102 if (meta) { 103 meta.lastSeen = now; 104 } 105 + 106 return { 107 statusCode: 200, 108 headers: { 109 + "Content-Type": "application/json", 110 + "Access-Control-Allow-Origin": "*", 111 + "Cache-Control": "private, max-age=300", // Browser can cache for 5 minutes 112 + "X-Cache-Status": "HIT", 113 }, 114 body: JSON.stringify(cached.data), 115 }; ··· 118 // Cache miss - fetch full profile 119 try { 120 const config = getOAuthConfig(); 121 + const isDev = config.clientType === "loopback"; 122 123 let client: NodeOAuthClient; 124 ··· 132 }); 133 } else { 134 // Production with private key 135 + const normalizedKey = normalizePrivateKey( 136 + process.env.OAUTH_PRIVATE_KEY!, 137 + ); 138 + const privateKey = await JoseKey.fromImportable( 139 + normalizedKey, 140 + "main-key", 141 + ); 142 143 client = new NodeOAuthClient({ 144 clientMetadata: { 145 client_id: config.clientId, 146 + client_name: "ATlast", 147 + client_uri: config.clientId.replace("/client-metadata.json", ""), 148 redirect_uris: [config.redirectUri], 149 + scope: "atproto transition:generic", 150 + grant_types: ["authorization_code", "refresh_token"], 151 + response_types: ["code"], 152 + application_type: "web", 153 + token_endpoint_auth_method: "private_key_jwt", 154 + token_endpoint_auth_signing_alg: "ES256", 155 dpop_bound_access_tokens: true, 156 jwks_uri: config.jwksUri, 157 }, ··· 163 164 // Restore OAuth session 165 const oauthSession = await client.restore(did); 166 + 167 // Create agent from OAuth session 168 const agent = new Agent(oauthSession); 169 ··· 203 return { 204 statusCode: 200, 205 headers: { 206 + "Content-Type": "application/json", 207 + "Access-Control-Allow-Origin": "*", 208 + "Cache-Control": "private, max-age=300", 209 + "X-Cache-Status": "MISS", 210 }, 211 body: JSON.stringify(profileData), 212 }; 213 } catch (error) { 214 + console.error("Profile fetch error:", error); 215 + 216 // If profile fetch fails, return basic session info 217 return { 218 statusCode: 200, 219 headers: { 220 + "Content-Type": "application/json", 221 + "Access-Control-Allow-Origin": "*", 222 + "X-Cache-Status": "ERROR", 223 }, 224 body: JSON.stringify({ 225 did: did, ··· 228 }; 229 } 230 } catch (error) { 231 + console.error("Session error:", error); 232 return { 233 statusCode: 500, 234 + headers: { "Content-Type": "application/json" }, 235 + body: JSON.stringify({ error: "Internal server error" }), 236 }; 237 } 238 + };