feat(protocol): 添加补丁管理和行为指标协议类型 feat(client): 实现补丁管理插件采集功能 feat(server): 添加补丁管理和异常检测API feat(database): 新增补丁状态和异常检测相关表 feat(web): 添加补丁管理和异常检测前端页面 fix(security): 增强输入验证和防注入保护 refactor(auth): 重构认证检查逻辑 perf(service): 优化Windows服务恢复策略 style: 统一健康评分显示样式 docs: 更新知识库文档
195 lines
5.3 KiB
TypeScript
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
|
|
},
|
|
}
|