export class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } export interface User { id: string; username: string; admin: boolean; quota_bytes: number; created_at?: string } export interface Session { user: User; csrf_token: string } export interface Instance { id: string; name: string; status: string; image_id: string; version: string; created_at: string } export type AdmissionState = 'recommended' | 'compatible' | 'existing_only' | 'blocked'; export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; admission?: string; admission_reason?: string; selectable?: boolean; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string } export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string } export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true; export interface CompatibilityRule { state: string; build_allowed: boolean; reason: string } export interface Compatibility { recipe_rev: string; platform: string; entries: (CompatibilityRule & {version:string; recipe_rev:string; platform:string})[]; unknown: CompatibilityRule; experimental_admin_only: boolean } export const normalBuildAllowed = (policy: Compatibility, version: string) => policy.entries.some(entry => entry.version === version && entry.recipe_rev === policy.recipe_rev && entry.platform === policy.platform && entry.build_allowed === true); export interface Entry { name: string; path?: string; is_dir: boolean; size: number; mod_time: string } export interface Usage { used_bytes: number; quota_bytes: number } export interface Backup { id: string; created_at: string; size: number } export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string } export function createApi(fetcher: typeof fetch = fetch) { let csrf = ''; let refreshing: Promise | undefined; const csrfCookie = () => typeof document === 'undefined' ? '' : document.cookie.split('; ').find(c => c.startsWith('dsh_csrf='))?.slice(9) || ''; const currentCSRF = () => typeof document === 'undefined' ? csrf : csrfCookie(); async function recoverSession() { const recover = async (checkFirst: boolean) => { if (checkFirst) { try { return await request('/me', 'GET', undefined, true); } catch (e) { if (!(e instanceof ApiError) || e.status !== 401) throw e; } } try { return await request('/refresh', 'POST', undefined, true); } catch (failure) { // Another page may have rotated the shared cookies while this request // was in flight; keep rechecking while a slow sibling tab settles. const retries = [0, 100, 250, 500, 1000, 2000]; for (const delay of retries) { if (delay) await new Promise(resolve => setTimeout(resolve, delay)); try { return await request('/me', 'GET', undefined, true); } catch (e) { if (!(e instanceof ApiError) || e.status !== 401) throw e; if (delay === retries[retries.length - 1]) throw failure instanceof ApiError && failure.status === 403 ? e : failure; } } throw new ApiError(401, '登录已失效,请重新登录。'); } }; return typeof navigator !== 'undefined' && navigator.locks ? navigator.locks.request('dsh-session-refresh', () => recover(true)) : recover(false); } async function request(path: string, method = 'GET', data?: unknown, retried = false): Promise { const headers = new Headers(); const token = currentCSRF(); if (method !== 'GET' && token) headers.set('X-CSRF-Token', token); const multipart = data instanceof FormData; if (data !== undefined && !multipart) headers.set('Content-Type', 'application/json'); let response: Response; try { response = await fetcher('/api' + path, { method, credentials: 'same-origin', headers, body: data === undefined ? undefined : multipart ? data : JSON.stringify(data) }); } catch { throw new ApiError(0, '无法连接服务器,请检查网络或后端服务。'); } if (response.status === 401 && !retried && path !== '/login' && path !== '/refresh' && currentCSRF()) { try { refreshing ??= recoverSession().finally(() => { refreshing = undefined; }); const session = await refreshing; csrf = session.csrf_token; } catch (e) { if (!(e instanceof ApiError) || e.status !== 401) throw e; if (path !== '/me' && typeof window !== 'undefined') window.dispatchEvent(new Event('session-expired')); throw e; } return request(path, method, data, true); } const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : undefined; } catch { payload = undefined; } if (!response.ok) { if (response.status === 401 && path !== '/login' && path !== '/me' && path !== '/refresh' && typeof window !== 'undefined') window.dispatchEvent(new Event('session-expired')); throw new ApiError(response.status, (payload as {error?: string})?.error || (response.status === 401 ? '登录已失效,请重新登录。' : '请求失败(HTTP ' + response.status + ')')); } if (text && payload === undefined) throw new ApiError(response.status, '服务器返回了非 JSON 响应,请检查 API 代理配置。'); return payload as T; } return { request, async me() { const session = await request('/me'); csrf = session.csrf_token; return session; }, async login(username: string, password: string) { const session = await request('/login', 'POST', {username,password}); csrf = session.csrf_token; return session; }, async logout() { await request('/logout', 'POST'); csrf = ''; }, instances: () => request('/instances'), images: () => request('/images'), compatibility: () => request('/compatibility'), startBuild: (version: string, experimental = false) => request('/builds', 'POST', {version, experimental}), admitImage: (image_id: string, state: AdmissionState, evidence: string) => request('/images/admission', 'POST', {image_id, state, evidence}), builds: (limit = 20, offset = 0) => request(`/builds?limit=${limit}&offset=${offset}`), build: (id: string) => request('/builds/' + encodeURIComponent(id)), usage: (instance?: string) => request('/ws/usage' + (instance ? '?instance=' + encodeURIComponent(instance) : '')), tree: (path: string, instance?: string) => request('/ws/tree?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : '')), download: (path: string, instance?: string) => '/api/ws/download?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''), mkdir: (path: string, instance?: string) => request('/ws/mkdir' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path}), rename: (path: string, newPath: string, instance?: string) => request('/ws/rename' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path, new_path: newPath}), delete: (path: string, instance?: string) => request('/ws/delete?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''), 'DELETE'), async upload(path: string, file: File, instance?: string) { const form = new FormData(); form.set('path', path); form.set('file', file); return request('/ws/upload' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', form); }, }; } export const api = createApi(); export const instancePath = (id: string) => '/instances/' + encodeURIComponent(id);