import type { NativeArtifact, NativeOperation, NativeOperationKind, NativeOperationReceipt, NativeProfile, NativeProfileSummary, NativeAuthStatus, NativeAuthSession, NativeClientKey, NativeAuditEvent, } from '@typings/native'; export class NativeApiError extends Error { constructor(public readonly status: number, message: string) { super(message); this.name = 'NativeApiError'; } } /** HttpOnly 会话 cookie 仅由服务器设置;CSRF 只放在内存,不写入浏览器存储。 */ export class NativeApi { private refreshing?: Promise; private loggingOut = false; constructor(private csrfToken?: string) {} private refreshSession() { // 多个并行管理请求遇到过期 access cookie 时共用一次恢复,避免刷新凭据互相覆盖。 this.refreshing ??= this.authStatus().then(status => { if (status.authenticated && status.csrf_token) this.csrfToken = status.csrf_token; return status; }).finally(() => { this.refreshing = undefined; }); return this.refreshing; } private async request(path: string, options: RequestInit = {}, binary = false, secrets: string[] = [], retried = false): Promise { const attemptedCsrf = this.csrfToken; const controller = new AbortController(); const abort = () => controller.abort(); options.signal?.addEventListener('abort', abort, { once: true }); if (options.signal?.aborted) controller.abort(); const timer = setTimeout(abort, 30000); try { const headers = new Headers(options.headers); // 状态变更需要同时提交会话 cookie 和 CSRF,绝不发送管理员 bearer token。 if (options.method && !['GET', 'HEAD', 'OPTIONS'].includes(options.method.toUpperCase())) { if (!this.csrfToken) throw new NativeApiError(403, '缺少会话校验信息,请刷新页面后重试'); headers.set('X-CSRF-Token', this.csrfToken); } if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json'); const response = await fetch(`/api/native${path}`, { ...options, headers, signal: controller.signal, credentials: 'same-origin', cache: 'no-store', }); // 此标记只由读取请求体之前的CSRF门禁生成;原请求尚未产生副作用,最多重试一次。 if (response.status === 403 && response.headers.get('X-Message-Error') === 'csrf' && !retried) { const status = await this.authStatus(options.signal ?? undefined); if (!status.csrf_token) throw new NativeApiError(403, '无法取得登录校验信息,请重新打开独立应用页面'); this.csrfToken = status.csrf_token; await this.ensureCsrf(options.signal ?? undefined); return this.request(path, options, binary, secrets, true); } // 仅重试鉴权层拒绝的 401;网络失败和业务错误绝不自动重放写操作。 if (response.status === 401 && !retried && !this.loggingOut && !['/auth/status', '/auth/login', '/auth/recover', '/auth/password'].includes(path)) { const status = this.csrfToken !== attemptedCsrf ? { authenticated: true, csrf_token: this.csrfToken } : await this.refreshSession(); if (status.authenticated && status.csrf_token && !controller.signal.aborted) { this.csrfToken = status.csrf_token; return this.request(path, options, binary, secrets, true); } } if (!response.ok) { let detail = `请求失败(HTTP ${response.status})`; try { const body: unknown = await response.json(); if (body && typeof body === 'object' && 'detail' in body && typeof body.detail === 'string') detail = body.detail; } catch { /* 错误页不一定是 JSON,保留状态码即可诊断。 */ } // 即使错误响应意外回显密码或 CSRF,也不在界面中展示。 for (const secret of [this.csrfToken, attemptedCsrf, ...secrets]) { if (secret) detail = detail.split(secret).join('[已隐藏凭据]'); } throw new NativeApiError(response.status, detail); } if (response.status === 204) return undefined as T; return (binary ? await response.blob() : await response.json()) as T; } catch (error) { if (error instanceof NativeApiError) throw error; if (controller.signal.aborted) throw new NativeApiError(0, '请求已取消或超时;操作可能已被服务器接收,请刷新历史确认'); throw new NativeApiError(0, '网络请求失败,请检查连接并重试;操作结果以服务器记录为准'); } finally { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); } } async ensureCsrf(signal?: AbortSignal) { const probe = () => this.request<{ ready: boolean }>('/auth/csrf', { signal, headers: { 'X-CSRF-Token': this.csrfToken ?? '' } }); try { return await probe(); } catch (error) { if (!(error instanceof NativeApiError) || error.status !== 409) throw error; // 多窗口或并发状态响应可使页面CSRF过时,只重取校验,不重发邮件或密码。 const status = await this.authStatus(signal); this.csrfToken = status.csrf_token; return probe(); } } authStatus(signal?: AbortSignal) { return this.request('/auth/status', { signal }); } login(email: string, password: string, signal?: AbortSignal) { return this.request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), signal, }, false, [password]); } recover(email: string, signal?: AbortSignal) { return this.request<{ message?: string }>('/auth/recover', { method: 'POST', body: JSON.stringify({ email }), signal, }); } updatePassword(password: string, signal?: AbortSignal) { return this.request<{ message?: string }>('/auth/password', { method: 'POST', body: JSON.stringify({ password }), signal, }, false, [password]); } async logout() { // 已启动的刷新先结束,再撤销服务器会话,避免较晚的刷新响应覆盖退出 cookie。 this.loggingOut = true; try { await this.refreshing; return await this.request('/auth/logout', { method: 'POST' }); } finally { this.loggingOut = false; } } keys(profile: string, signal?: AbortSignal) { return this.request<{ items: NativeClientKey[] }>(`/profiles/${encodeURIComponent(profile)}/keys`, { signal }); } createKey(profile: string, name: string, expiresInDays: number, signal?: AbortSignal) { return this.request(`/profiles/${encodeURIComponent(profile)}/keys`, { method: 'POST', body: JSON.stringify({ name, expires_in_days: expiresInDays }), signal, }); } revokeKey(profile: string, keyId: string, signal?: AbortSignal) { return this.request(`/profiles/${encodeURIComponent(profile)}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', signal }); } audit(signal?: AbortSignal) { return this.request<{ items: NativeAuditEvent[] }>('/auth/audit', { signal }); } catalog(signal?: AbortSignal) { return this.request<{ items: NativeArtifact[] }>('/catalog', { signal }); } profiles(signal?: AbortSignal) { return this.request<{ items: NativeProfileSummary[] }>('/profiles', { signal }); } profile(id: string, signal?: AbortSignal) { return this.request(`/profiles/${encodeURIComponent(id)}`, { signal }); } createProfile(id: string, signal?: AbortSignal) { return this.request('/profiles', { method: 'POST', body: JSON.stringify({ id }), signal }); } upload(file: File, signal?: AbortSignal) { const body = new FormData(); body.append('file', file); return this.request('/catalog', { method: 'POST', body, signal }); } download(id: string, signal?: AbortSignal) { return this.request(`/catalog/${encodeURIComponent(id)}/download`, { signal }, true); } operate(id: string, kind: NativeOperationKind, body: Record | undefined, key: string) { return this.request(`/profiles/${encodeURIComponent(id)}/${kind}`, { method: 'POST', body: body ? JSON.stringify(body) : undefined, headers: { 'Idempotency-Key': key }, }); } operations(profile: string, signal?: AbortSignal) { return this.request<{ items: NativeOperation[] }>(`/operations?profile_id=${encodeURIComponent(profile)}`, { signal }); } operation(id: string, signal?: AbortSignal) { return this.request(`/operations/${encodeURIComponent(id)}`, { signal }); } cancel(id: string) { return this.request(`/operations/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); } call(id: string, name: string, args: Record, signal?: AbortSignal) { return this.request(`/profiles/${encodeURIComponent(id)}/call`, { method: 'POST', body: JSON.stringify({ name, arguments: args }), signal, }); } }