Files
csm/web/src/lib/api.ts
iven 60ee38a3c2 feat: 新增补丁管理和异常检测插件及相关功能
feat(protocol): 添加补丁管理和行为指标协议类型
feat(client): 实现补丁管理插件采集功能
feat(server): 添加补丁管理和异常检测API
feat(database): 新增补丁状态和异常检测相关表
feat(web): 添加补丁管理和异常检测前端页面
fix(security): 增强输入验证和防注入保护
refactor(auth): 重构认证检查逻辑
perf(service): 优化Windows服务恢复策略
style: 统一健康评分显示样式
docs: 更新知识库文档
2026-04-11 15:59:53 +08:00

195 lines
5.3 KiB
TypeScript

/**
* Shared API client with cookie-based authentication.
* Tokens are managed via HttpOnly cookies set by the server —
* the frontend never reads or stores JWT tokens.
*/
const API_BASE = import.meta.env.VITE_API_BASE || ''
export interface ApiResult<T> {
success: boolean
data?: T
error?: string
}
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
let refreshPromise: Promise<boolean> | null = null
/** Cached user info from /api/auth/me */
let cachedUser: { id: number; username: string; role: string } | null = null
export function getCachedUser() {
return cachedUser
}
export function clearCachedUser() {
cachedUser = null
}
async function tryRefresh(): Promise<boolean> {
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
try {
const response = await fetch(`${API_BASE}/api/auth/refresh`, {
method: 'POST',
credentials: 'same-origin',
})
if (!response.ok) return false
const result = await response.json()
if (!result.success) return false
// Update cached user from refresh response
if (result.data?.user) {
cachedUser = result.data.user
}
return true
} catch {
return false
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = new Headers(options.headers || {})
if (options.body && typeof options.body === 'string') {
headers.set('Content-Type', 'application/json')
}
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'same-origin',
})
// Handle 401 - try refresh before giving up
if (response.status === 401) {
const refreshed = await tryRefresh()
if (refreshed) {
const retryResponse = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'same-origin',
})
if (retryResponse.status === 401) {
clearCachedUser()
window.location.href = '/login'
throw new ApiError(401, 'UNAUTHORIZED', 'Session expired')
}
const retryContentType = retryResponse.headers.get('content-type')
if (!retryContentType || !retryContentType.includes('application/json')) {
throw new ApiError(retryResponse.status, 'NON_JSON_RESPONSE', `Server returned ${retryResponse.status}`)
}
const retryResult: ApiResult<T> = await retryResponse.json()
if (!retryResult.success) {
throw new ApiError(retryResponse.status, 'API_ERROR', retryResult.error || 'Unknown error')
}
return retryResult.data as T
}
clearCachedUser()
window.location.href = '/login'
throw new ApiError(401, 'UNAUTHORIZED', 'Session expired')
}
// Handle 403 - insufficient permissions
if (response.status === 403) {
throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions')
}
// Handle non-JSON responses (502, 503, etc.)
const contentType = response.headers.get('content-type')
if (!contentType || !contentType.includes('application/json')) {
throw new ApiError(response.status, 'NON_JSON_RESPONSE', `Server returned ${response.status}`)
}
const result: ApiResult<T> = await response.json()
if (!result.success) {
throw new ApiError(response.status, 'API_ERROR', result.error || 'Unknown error')
}
return result.data as T
}
export const api = {
get<T>(path: string): Promise<T> {
return request<T>(path)
},
post<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
})
},
put<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
})
},
delete<T = void>(path: string): Promise<T> {
return request<T>(path, { method: 'DELETE' })
},
/** Login — server sets HttpOnly cookies, we only get user info back */
async login(username: string, password: string): Promise<{ user: { id: number; username: string; role: string } }> {
const response = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ username, password }),
})
const result = await response.json()
if (!result.success) {
throw new ApiError(response.status, 'LOGIN_FAILED', result.error || 'Login failed')
}
cachedUser = result.data.user
return result.data
},
/** Logout — server clears cookies */
async logout(): Promise<void> {
try {
await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
credentials: 'same-origin',
})
} catch {
// Ignore errors during logout
}
clearCachedUser()
},
/** Check current auth status via /api/auth/me */
async me(): Promise<{ user: { id: number; username: string; role: string }; expires_at: string }> {
const result = await request<{ user: { id: number; username: string; role: string }; expires_at: string }>('/api/auth/me')
cachedUser = (result as { user: { id: number; username: string; role: string } }).user
return result
},
}