+77
-60
netlify/functions/batch-follow-users.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
};