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