๐Ÿ“ฆ EqualifyEverything / equalify-hub

๐Ÿ“„ auth.ts ยท 202 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202import { event } from './event';

const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || '';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '';

// Parse cookies from request headers
export function parseCookies(): Record<string, string> {
    const cookies: Record<string, string> = {};
    
    // Lambda function URLs send cookies as an array
    if (Array.isArray((event as any).cookies)) {
        (event as any).cookies.forEach((cookie: string) => {
            const [name, ...rest] = cookie.split('=');
            if (name) {
                cookies[name] = rest.join('=');
            }
        });
        return cookies;
    }
    
    // Fallback to header parsing for API Gateway
    const cookieHeader = event.headers?.cookie || event.headers?.Cookie || '';
    cookieHeader.split(';').forEach(cookie => {
        const [name, ...rest] = cookie.trim().split('=');
        if (name) {
            cookies[name] = rest.join('=');
        }
    });
    
    return cookies;
}

// Get GitHub token from cookie
export function getGitHubToken(): string | null {
    const cookies = parseCookies();
    return cookies['gh_token'] || null;
}

// Get current user info from cookie
export function getCurrentUser(): { login: string; avatar_url: string; isPro?: boolean } | null {
    const cookies = parseCookies();
    const userCookie = cookies['gh_user'];
    if (!userCookie) return null;
    
    try {
        return JSON.parse(decodeURIComponent(userCookie));
    } catch {
        return null;
    }
}

// Check if user has pro status (survives logout)
export function hasProStatus(): boolean {
    const cookies = parseCookies();
    return cookies['gc_pro'] === '1';
}

// Create secure cookie string
export function createCookie(name: string, value: string, maxAge: number = 86400 * 30): string {
    return `${name}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
}

// Create cookie deletion string
export function deleteCookie(name: string): string {
    return `${name}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
}

// GitHub OAuth URLs - pro users get private repo access
export function getGitHubAuthUrl(isPro: boolean = false): string {
    // Pro users get repo + read:org scope for private repo and org access
    // GitHub accepts space-separated scopes (URL encoded as %20)
    const scopes = isPro ? 'read:user repo read:org' : 'read:user';
    return `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&scope=${encodeURIComponent(scopes)}`;
}

// Get pro auth URL (for re-auth after subscribing)
export function getGitHubProAuthUrl(): string {
    return getGitHubAuthUrl(true);
}

// Exchange code for token
export async function exchangeCodeForToken(code: string): Promise<{ access_token: string; error?: string }> {
    const response = await fetch('https://github.com/login/oauth/access_token', {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            client_id: GITHUB_CLIENT_ID,
            client_secret: GITHUB_CLIENT_SECRET,
            code,
        }),
    });
    
    return response.json();
}

// Fallback tokens for unauthenticated users (5000 req/hour each)
const GITHUB_FALLBACK_TOKENS = [
    process.env.GITHUB_FALLBACK_TOKEN_1,
    process.env.GITHUB_FALLBACK_TOKEN_2,
    process.env.GITHUB_FALLBACK_TOKEN_3,
].filter(Boolean) as string[];

// Start from the first working token โ€” bumped forward when tokens fail.
// Persists across requests in the same Lambda container (warm starts).
let firstWorkingTokenIndex = 0;

import { getGitHubCache, getStaleGitHubCache, setGitHubCache } from './db';

// Fetch from GitHub with optional auth and 60-min caching
// Falls back to stale cached data if all API attempts fail
export async function fetchGitHub(url: string, token?: string | null, skipCache = false) {
    // Check cache first (only for non-user-specific requests)
    if (!skipCache) {
        const cached = await getGitHubCache(url);
        if (cached) {
            return cached;
        }
    }
    
    // Try fetching fresh data
    const freshResult = await fetchGitHubFresh(url, token, skipCache);
    
    // If we got good data, return it
    if (freshResult && !freshResult.message && !freshResult.error) {
        return freshResult;
    }
    
    // Fresh fetch failed โ€” try stale cache as last resort
    if (!skipCache) {
        const stale = await getStaleGitHubCache(url);
        if (stale) {
            return stale;
        }
    }
    
    // Nothing worked โ€” return whatever we got (error or empty)
    return freshResult;
}

// Attempt a fresh fetch from GitHub API with token cycling
async function fetchGitHubFresh(url: string, token?: string | null, skipCache = false) {
    // If user has a token, use it directly
    if (token) {
        return fetchWithToken(url, token, skipCache);
    }
    
    // Start from the first known-working token
    for (let i = firstWorkingTokenIndex; i < GITHUB_FALLBACK_TOKENS.length; i++) {
        const result = await fetchWithToken(url, GITHUB_FALLBACK_TOKENS[i], skipCache);
        
        // If we got any error response, permanently skip this token
        if (result?.message) {
            console.log(`[GITHUB] Token ${i + 1} failed: ${result.message} โ€” skipping permanently`);
            // Bump the starting index so ALL concurrent requests skip this token immediately
            if (i === firstWorkingTokenIndex) {
                firstWorkingTokenIndex = i + 1;
            }
            continue;
        }
        
        return result;
    }
    
    // All tokens exhausted or none configured โ€” try public (no auth, 60 req/hour)
    console.log(`[GITHUB] All tokens failed, falling back to public API for ${url}`);
    return fetchWithToken(url, null, skipCache);
}

async function fetchWithToken(url: string, token: string | null, skipCache: boolean) {
    const headers: Record<string, string> = {
        'Accept': 'application/vnd.github.v3+json',
        'User-Agent': 'EqualifyOpenSource'
    };
    
    if (token) {
        headers['Authorization'] = `Bearer ${token}`;
    }
    
    const response = await fetch(url, { headers });
    const data = await response.json();
    
    // Only cache successful responses with actual data
    // Don't cache: errors, empty arrays, or rate limit responses
    const isError = data?.message || data?.error;
    const isEmpty = Array.isArray(data) && data.length === 0;
    const shouldCache = !skipCache && response.ok && !url.includes('/user') && !isError && !isEmpty;
    
    if (shouldCache) {
        await setGitHubCache(url, data);
    }
    
    return data;
}

// Fetch current user from GitHub (skip cache - user-specific)
export async function fetchCurrentUser(token: string) {
    return fetchGitHub('https://api.github.com/user', token, true);
}