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.
This commit is contained in:
+49
-8
@@ -4,8 +4,13 @@ export class ApiError extends Error {
|
||||
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 interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
|
||||
export interface Build { id: string; version: string; status: string; image_id?: string; logs?: string; error?: 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 }
|
||||
@@ -14,20 +19,52 @@ 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();
|
||||
if (method !== 'GET' && (csrf || csrfCookie())) headers.set('X-CSRF-Token', csrf || csrfCookie());
|
||||
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' && (csrf || csrfCookie())) {
|
||||
if (response.status === 401 && !retried && path !== '/login' && path !== '/refresh' && currentCSRF()) {
|
||||
try {
|
||||
refreshing ??= request<Session>('/refresh', 'POST', undefined, true).finally(() => { refreshing = undefined; });
|
||||
refreshing ??= recoverSession().finally(() => { refreshing = undefined; });
|
||||
const session = await refreshing; csrf = session.csrf_token;
|
||||
return request<T>(path, method, data, true);
|
||||
} catch { /* The original response determines session expiration handling. */ }
|
||||
} 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;
|
||||
@@ -46,7 +83,11 @@ export function createApi(fetcher: typeof fetch = fetch) {
|
||||
async logout() { await request('/logout', 'POST'); csrf = ''; },
|
||||
instances: () => request<Instance[]>('/instances'),
|
||||
images: () => request<Image[]>('/images'),
|
||||
builds: () => request<Build[]>('/builds'),
|
||||
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) : ''),
|
||||
|
||||
Reference in New Issue
Block a user