Files
dsh-web/web/src/api.ts
T
m1saka 9a71772771 Harden image admission, resource recovery and session renewal
Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively.

Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
2026-10-14 13:41:54 +08:00

102 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Session> | 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<Session>('/me', 'GET', undefined, true); }
catch (e) { if (!(e instanceof ApiError) || e.status !== 401) throw e; }
}
try { return await request<Session>('/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<Session>('/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<T>(path: string, method = 'GET', data?: unknown, retried = false): Promise<T> {
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<T>(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<Session>('/me'); csrf = session.csrf_token; return session; },
async login(username: string, password: string) { const session = await request<Session>('/login', 'POST', {username,password}); csrf = session.csrf_token; return session; },
async logout() { await request('/logout', 'POST'); csrf = ''; },
instances: () => request<Instance[]>('/instances'),
images: () => request<Image[]>('/images'),
compatibility: () => request<Compatibility>('/compatibility'),
startBuild: (version: string, experimental = false) => request<Build>('/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<Build[]>(`/builds?limit=${limit}&offset=${offset}`),
build: (id: string) => request<Build>('/builds/' + encodeURIComponent(id)),
usage: (instance?: string) => request<Usage>('/ws/usage' + (instance ? '?instance=' + encodeURIComponent(instance) : '')),
tree: (path: string, instance?: string) => request<Entry[]>('/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);