| import { Router } from "express"; |
| import { db, configTable, geminiAccountsTable } from "@workspace/db"; |
| import { eq, asc, isNull, and, notInArray } from "drizzle-orm"; |
| import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto"; |
| import { getTurnstileToken, invalidateTurnstileToken } from "../captcha.js"; |
| import { generateGuardId } from "../guardId.js"; |
|
|
| const router = Router(); |
|
|
| const TOKEN_KEY = "geminigen_bearer_token"; |
| const REFRESH_TOKEN_KEY = "geminigen_refresh_token"; |
| const USERNAME_KEY = "geminigen_username"; |
| const PASSWORD_KEY = "geminigen_password_enc"; |
| const GEMINIGEN_BASE = "https://api.geminigen.ai/api"; |
| const GEMINIGEN_REFRESH_URL = `${GEMINIGEN_BASE}/refresh-token`; |
| const GEMINIGEN_SIGNIN_URL = `${GEMINIGEN_BASE}/login-v2`; |
| const UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1"; |
|
|
| |
| function getEncKey(): Buffer { |
| const secret = process.env.SESSION_SECRET || "starforge-default-secret-change-me"; |
| return scryptSync(secret, "starforge-salt", 32); |
| } |
|
|
| export function encrypt(text: string): string { |
| const iv = randomBytes(12); |
| const cipher = createCipheriv("aes-256-gcm", getEncKey(), iv); |
| const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]); |
| const tag = cipher.getAuthTag(); |
| return iv.toString("hex") + ":" + tag.toString("hex") + ":" + enc.toString("hex"); |
| } |
|
|
| export function decrypt(encoded: string): string { |
| const [ivHex, tagHex, encHex] = encoded.split(":"); |
| const decipher = createDecipheriv("aes-256-gcm", getEncKey(), Buffer.from(ivHex, "hex")); |
| decipher.setAuthTag(Buffer.from(tagHex, "hex")); |
| return decipher.update(Buffer.from(encHex, "hex")).toString("utf8") + decipher.final("utf8"); |
| } |
|
|
| |
| async function getConfig(key: string): Promise<string | null> { |
| const row = await db.select().from(configTable).where(eq(configTable.key, key)).limit(1); |
| return row.length > 0 ? row[0].value : null; |
| } |
|
|
| async function setConfig(key: string, value: string): Promise<void> { |
| await db |
| .insert(configTable) |
| .values({ key, value, updatedAt: new Date() }) |
| .onConflictDoUpdate({ target: configTable.key, set: { value, updatedAt: new Date() } }); |
| } |
|
|
| |
| export async function getStoredToken(): Promise<string | null> { |
| return getConfig(TOKEN_KEY); |
| } |
|
|
| export async function getStoredRefreshToken(): Promise<string | null> { |
| return getConfig(REFRESH_TOKEN_KEY); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function getValidBearerToken(): Promise<string | null> { |
| const rows = await db |
| .select() |
| .from(configTable) |
| .where(eq(configTable.key, TOKEN_KEY)) |
| .limit(1); |
|
|
| if (!rows.length) return null; |
|
|
| const row = rows[0]; |
| const ageMs = Date.now() - new Date(row.updatedAt).getTime(); |
| const fiftyMinutes = 50 * 60 * 1000; |
|
|
| if (ageMs > fiftyMinutes) { |
| console.log("[token] Token age > 50 min, proactively refreshing..."); |
| const fresh = await refreshAccessToken(); |
| if (fresh) return fresh; |
| } |
|
|
| return row.value; |
| } |
|
|
| export async function getStoredCredentials(): Promise<{ username: string; password: string } | null> { |
| const [username, passwordEnc] = await Promise.all([getConfig(USERNAME_KEY), getConfig(PASSWORD_KEY)]); |
| if (!username || !passwordEnc) return null; |
| try { |
| return { username, password: decrypt(passwordEnc) }; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| async function loginWithCredentials(): Promise<{ accessToken: string; refreshToken: string } | null> { |
| const creds = await getStoredCredentials(); |
| if (!creds) return null; |
|
|
| try { |
| console.log("[token-refresh] Trying credential re-login via /login-v2..."); |
| |
| const turnstileToken = await getTurnstileToken(); |
| const resp = await fetch(GEMINIGEN_SIGNIN_URL, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| "User-Agent": UA, |
| "x-guard-id": generateGuardId("/api/login-v2", "post"), |
| }, |
| body: JSON.stringify({ username: creds.username, password: creds.password, turnstile_token: turnstileToken }), |
| }); |
|
|
| const text = await resp.text(); |
| console.log(`[token-refresh] signin β HTTP ${resp.status}: ${text.slice(0, 200)}`); |
|
|
| if (!resp.ok) { |
| invalidateTurnstileToken(); |
| return null; |
| } |
| const data = JSON.parse(text) as { access_token?: string; refresh_token?: string }; |
| if (!data.access_token) return null; |
|
|
| return { accessToken: data.access_token, refreshToken: data.refresh_token || "" }; |
| } catch (e: any) { |
| console.log(`[token-refresh] signin threw: ${e?.message}`); |
| return null; |
| } |
| } |
|
|
| |
| export async function refreshAccessToken(): Promise<string | null> { |
| const refreshToken = await getStoredRefreshToken(); |
|
|
| |
| if (refreshToken) { |
| try { |
| console.log("[token-refresh] Trying refresh_token endpoint..."); |
| const resp = await fetch(GEMINIGEN_REFRESH_URL, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| "User-Agent": UA, |
| "x-guard-id": generateGuardId("/api/refresh-token", "post"), |
| }, |
| body: JSON.stringify({ refresh_token: refreshToken }), |
| }); |
|
|
| const text = await resp.text(); |
| console.log(`[token-refresh] refresh β HTTP ${resp.status}: ${text.slice(0, 200)}`); |
|
|
| if (resp.ok) { |
| const data = JSON.parse(text) as { access_token?: string; refresh_token?: string }; |
| if (data.access_token) { |
| await setConfig(TOKEN_KEY, data.access_token); |
| if (data.refresh_token) await setConfig(REFRESH_TOKEN_KEY, data.refresh_token); |
| console.log("[token-refresh] Success via refresh_token"); |
| return data.access_token; |
| } |
| } |
|
|
| const isExpired = text.includes("REFRESH_TOKEN_EXPIRED") || text.includes("expired"); |
| if (!isExpired) { |
| console.warn("[token-refresh] Refresh failed for unknown reason β won't try credentials"); |
| return null; |
| } |
| console.log("[token-refresh] Refresh token expired β falling back to credential login"); |
| } catch (e: any) { |
| console.log(`[token-refresh] refresh threw: ${e?.message}`); |
| } |
| } else { |
| console.log("[token-refresh] No refresh token stored β trying credential login directly"); |
| } |
|
|
| |
| const result = await loginWithCredentials(); |
| if (!result) { |
| console.warn("[token-refresh] All strategies failed β no credentials stored or login failed"); |
| return null; |
| } |
|
|
| await setConfig(TOKEN_KEY, result.accessToken); |
| if (result.refreshToken) await setConfig(REFRESH_TOKEN_KEY, result.refreshToken); |
| console.log("[token-refresh] Success via credential re-login"); |
| return result.accessToken; |
| } |
|
|
| |
|
|
| const GEMINIGEN_REFRESH = "https://api.geminigen.ai/api/refresh-token"; |
|
|
| |
| export async function getPoolToken(excludeIds: number[] = []): Promise<{ token: string; accountId: number } | null> { |
| const query = db |
| .select() |
| .from(geminiAccountsTable) |
| .where( |
| excludeIds.length > 0 |
| ? and(eq(geminiAccountsTable.isActive, true), notInArray(geminiAccountsTable.id, excludeIds)) |
| : eq(geminiAccountsTable.isActive, true) |
| ) |
| .orderBy(asc(geminiAccountsTable.lastUsedAt)) |
| .limit(1); |
|
|
| const rows = await query; |
| if (!rows.length) return null; |
| const account = rows[0]; |
| return { token: account.bearerToken, accountId: account.id }; |
| } |
|
|
| |
| export async function markAccountUsed(accountId: number): Promise<void> { |
| await db |
| .update(geminiAccountsTable) |
| .set({ lastUsedAt: new Date() }) |
| .where(eq(geminiAccountsTable.id, accountId)); |
| } |
|
|
| |
| export async function tryRefreshPoolAccount(accountId: number): Promise<string | null> { |
| const rows = await db.select().from(geminiAccountsTable).where(eq(geminiAccountsTable.id, accountId)).limit(1); |
| if (!rows.length) return null; |
| const account = rows[0]; |
| if (!account.refreshToken) return null; |
|
|
| try { |
| const resp = await fetch(GEMINIGEN_REFRESH, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| "User-Agent": UA, |
| "x-guard-id": generateGuardId("/api/refresh-token", "post"), |
| }, |
| body: JSON.stringify({ refresh_token: account.refreshToken }), |
| }); |
| if (!resp.ok) return null; |
| const data = await resp.json() as { access_token?: string; refresh_token?: string }; |
| if (!data.access_token) return null; |
|
|
| await db.update(geminiAccountsTable).set({ |
| bearerToken: data.access_token, |
| ...(data.refresh_token ? { refreshToken: data.refresh_token } : {}), |
| }).where(eq(geminiAccountsTable.id, accountId)); |
|
|
| console.log(`[pool] Account ${accountId} token refreshed`); |
| return data.access_token; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| export async function disablePoolAccount(accountId: number): Promise<void> { |
| await db.update(geminiAccountsTable).set({ isActive: false }).where(eq(geminiAccountsTable.id, accountId)); |
| console.warn(`[pool] Account ${accountId} disabled β token unrecoverable`); |
| } |
|
|
| |
| router.get("/token", async (_req, res) => { |
| const token = await getConfig(TOKEN_KEY); |
| if (!token) return res.json({ configured: false, token: null }); |
| return res.json({ configured: true, token: token.substring(0, 10) + "..." }); |
| }); |
|
|
| router.post("/token", async (req, res) => { |
| const { token, refreshToken } = req.body as { token?: string; refreshToken?: string }; |
| if (!token?.trim()) return res.status(400).json({ error: "INVALID_TOKEN", message: "Token is required" }); |
|
|
| await setConfig(TOKEN_KEY, token.trim()); |
| if (refreshToken?.trim()) await setConfig(REFRESH_TOKEN_KEY, refreshToken.trim()); |
|
|
| res.json({ success: true, message: "Token saved successfully" }); |
| }); |
|
|
| router.delete("/token", async (_req, res) => { |
| await db.delete(configTable).where(eq(configTable.key, TOKEN_KEY)); |
| await db.delete(configTable).where(eq(configTable.key, REFRESH_TOKEN_KEY)); |
| res.json({ success: true, message: "Token removed" }); |
| }); |
|
|
| export default router; |
|
|