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