fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
202 lines
7.2 KiB
TypeScript
202 lines
7.2 KiB
TypeScript
/**
|
|
* freemodel2api.ts — sub2api(FreeModel2api 后端)的凭据存储与 HTTP 客户端。
|
|
*
|
|
* 纯模块:不 import electron。safeStorage 加解密与 userData 文件读写由
|
|
* main.ts 注入(同 native-token-store.ts 模式),使本文件可在 vitest
|
|
* `electron` 项目里无 Electron 运行时测试。
|
|
*
|
|
* JWT(access_token)只驻留内存(freemodel2api-ipc.ts 持有),不落盘;
|
|
* 磁盘上只有 safeStorage 加密后的 {baseUrl, email, password}。
|
|
*/
|
|
|
|
export interface FreeModel2ApiCredentials {
|
|
baseUrl: string
|
|
email: string
|
|
password: string
|
|
}
|
|
|
|
export interface FreeModel2ApiStoreIo {
|
|
encrypt: (plaintext: string) => { encoding?: string; value?: string } | null
|
|
decrypt: (secret: any) => string
|
|
readStoreText: () => string
|
|
writeStoreText: (text: string) => void
|
|
}
|
|
|
|
const STORE_KEY = 'freemodel2api'
|
|
const REMEMBERED_STORE_KEY = 'freemodel2api-remembered'
|
|
|
|
function readStore(io: FreeModel2ApiStoreIo): Record<string, any> {
|
|
try {
|
|
const parsed = JSON.parse(io.readStoreText())
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function persistCredentialsAt(storeKey: string, creds: FreeModel2ApiCredentials | null, io: FreeModel2ApiStoreIo): void {
|
|
const store = readStore(io)
|
|
if (creds) {
|
|
const secret = io.encrypt(JSON.stringify(creds))
|
|
if (!secret) {
|
|
throw new Error('Secure storage returned no encrypted payload; refusing to overwrite stored FreeModel2api credentials.')
|
|
}
|
|
store[storeKey] = secret
|
|
} else {
|
|
delete store[storeKey]
|
|
}
|
|
io.writeStoreText(JSON.stringify(store))
|
|
}
|
|
|
|
function loadCredentialsAt(storeKey: string, io: FreeModel2ApiStoreIo): FreeModel2ApiCredentials | null {
|
|
const secret = readStore(io)[storeKey]
|
|
if (!secret) return null
|
|
try {
|
|
const parsed = JSON.parse(io.decrypt(secret))
|
|
if (!parsed || typeof parsed.baseUrl !== 'string' || typeof parsed.email !== 'string' || typeof parsed.password !== 'string') {
|
|
return null
|
|
}
|
|
return { baseUrl: parsed.baseUrl, email: parsed.email, password: parsed.password }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function persistFreeModel2ApiCredentials(creds: FreeModel2ApiCredentials | null, io: FreeModel2ApiStoreIo): void {
|
|
persistCredentialsAt(STORE_KEY, creds, io)
|
|
}
|
|
|
|
export function loadFreeModel2ApiCredentials(io: FreeModel2ApiStoreIo): FreeModel2ApiCredentials | null {
|
|
return loadCredentialsAt(STORE_KEY, io)
|
|
}
|
|
|
|
// 表单"记住登录信息":与自动登录凭据分开存,Disconnect 不影响它
|
|
export function persistRememberedLogin(creds: FreeModel2ApiCredentials | null, io: FreeModel2ApiStoreIo): void {
|
|
persistCredentialsAt(REMEMBERED_STORE_KEY, creds, io)
|
|
}
|
|
|
|
export function loadRememberedLogin(io: FreeModel2ApiStoreIo): FreeModel2ApiCredentials | null {
|
|
return loadCredentialsAt(REMEMBERED_STORE_KEY, io)
|
|
}
|
|
|
|
export interface Sub2ApiKey {
|
|
id: number
|
|
name: string
|
|
key: string
|
|
status: string
|
|
quota: number
|
|
quotaUsed: number
|
|
createdAt: string
|
|
lastUsedAt: string | null
|
|
}
|
|
|
|
export interface Sub2ApiProfile {
|
|
email: string
|
|
username: string
|
|
balance: number
|
|
/** 上游没这个字段时按 0 处理 —— 面板宁可显示 $0.00,也不为它单独加一次请求 */
|
|
frozenBalance: number
|
|
}
|
|
|
|
export interface Sub2ApiLoginResult {
|
|
accessToken: string
|
|
expiresAt: number
|
|
email: string
|
|
balance: number
|
|
username: string
|
|
frozenBalance: number
|
|
}
|
|
|
|
export type FetchLike = (
|
|
url: string,
|
|
init?: { method?: string; headers?: Record<string, string>; body?: string }
|
|
) => Promise<{ ok: boolean; status: number; json: () => Promise<any> }>
|
|
|
|
export class Sub2ApiAuthError extends Error {}
|
|
|
|
export function normalizeSub2ApiBaseUrl(raw: string): string {
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(raw.trim())
|
|
} catch {
|
|
throw new Error('Invalid base URL')
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error('Invalid base URL')
|
|
}
|
|
return parsed.origin + parsed.pathname.replace(/\/+$/, '')
|
|
}
|
|
|
|
async function callSub2Api(baseUrl: string, path: string, init: { method?: string; token?: string; body?: any }, fetcher: FetchLike): Promise<any> {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
if (init.token) headers.Authorization = `Bearer ${init.token}`
|
|
const res = await fetcher(`${normalizeSub2ApiBaseUrl(baseUrl)}${path}`, {
|
|
method: init.method ?? 'GET',
|
|
headers,
|
|
body: init.body === undefined ? undefined : JSON.stringify(init.body)
|
|
})
|
|
const payload = await res.json().catch(() => null)
|
|
if (res.status === 401) {
|
|
throw new Sub2ApiAuthError(payload?.message || 'Unauthorized')
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(payload?.message || `HTTP ${res.status}`)
|
|
}
|
|
if (!payload || payload.code !== 0) {
|
|
throw new Error(payload?.message || 'sub2api request failed')
|
|
}
|
|
return payload.data
|
|
}
|
|
|
|
export async function sub2ApiLogin(baseUrl: string, email: string, password: string, fetcher: FetchLike): Promise<Sub2ApiLoginResult> {
|
|
const data = await callSub2Api(baseUrl, '/api/v1/auth/login', { method: 'POST', body: { email, password } }, fetcher)
|
|
const expiresIn = Number(data?.expires_in) > 0 ? Number(data.expires_in) : 3600
|
|
return {
|
|
accessToken: String(data?.access_token ?? ''),
|
|
// 提前 60s 视为过期,边界请求不踩死线
|
|
expiresAt: Date.now() + expiresIn * 1000 - 60_000,
|
|
email: String(data?.user?.email ?? email),
|
|
balance: Number(data?.user?.balance ?? 0),
|
|
username: String(data?.user?.username ?? ''),
|
|
frozenBalance: Number(data?.user?.frozen_balance ?? 0)
|
|
}
|
|
}
|
|
|
|
export async function sub2ApiFetchProfile(baseUrl: string, token: string, fetcher: FetchLike): Promise<Sub2ApiProfile> {
|
|
const data = await callSub2Api(baseUrl, '/api/v1/user/profile', { token }, fetcher)
|
|
return {
|
|
email: String(data?.email ?? ''),
|
|
username: String(data?.username ?? ''),
|
|
balance: Number(data?.balance ?? 0),
|
|
frozenBalance: Number(data?.frozen_balance ?? 0)
|
|
}
|
|
}
|
|
|
|
/** sub2api 凭 API key 直查余额(`{root}/v1/sub2api/balance`)——不需要会话。
|
|
* 非 sub2api 端点 404 / 响应不含 balance → null,不算错误:这只是块读数。 */
|
|
export async function sub2ApiKeyBalance(baseUrl: string, apiKey: string, fetcher: FetchLike): Promise<number | null> {
|
|
const root = normalizeSub2ApiBaseUrl(baseUrl).replace(/\/v1$/, '')
|
|
const res = await fetcher(`${root}/v1/sub2api/balance`, { headers: { Authorization: `Bearer ${apiKey}` } })
|
|
if (!res.ok) {
|
|
return null
|
|
}
|
|
const payload = await res.json().catch(() => null)
|
|
const value = Number(payload?.balance)
|
|
return payload?.object === 'sub2api.key_balance' && Number.isFinite(value) ? value : null
|
|
}
|
|
|
|
export async function sub2ApiFetchKeys(baseUrl: string, token: string, fetcher: FetchLike): Promise<Sub2ApiKey[]> {
|
|
const data = await callSub2Api(baseUrl, '/api/v1/keys', { token }, fetcher)
|
|
const items = Array.isArray(data?.items) ? data.items : []
|
|
return items.map((item: any) => ({
|
|
id: Number(item.id),
|
|
name: String(item.name ?? ''),
|
|
key: String(item.key ?? ''),
|
|
status: String(item.status ?? ''),
|
|
quota: Number(item.quota ?? 0),
|
|
quotaUsed: Number(item.quota_used ?? 0),
|
|
createdAt: String(item.created_at ?? ''),
|
|
lastUsedAt: item.last_used_at ?? null
|
|
}))
|
|
}
|