File size: 9,093 Bytes
1140c43 9b403c4 1140c43 9b403c4 1140c43 9b403c4 1140c43 9b403c4 1140c43 9b403c4 1140c43 9b403c4 1140c43 501eb9e 9b403c4 1140c43 9b403c4 1140c43 9b403c4 1140c43 501eb9e 9b403c4 1140c43 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | 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<NativeAuthStatus>;
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<T>(path: string, options: RequestInit = {}, binary = false, secrets: string[] = [], retried = false): Promise<T> {
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<T>(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<T>(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<NativeAuthStatus>('/auth/status', { signal }); }
login(email: string, password: string, signal?: AbortSignal) {
return this.request<NativeAuthSession>('/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<void>('/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<NativeClientKey & { key: string }>(`/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<unknown>(`/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<NativeProfile>(`/profiles/${encodeURIComponent(id)}`, { signal }); }
createProfile(id: string, signal?: AbortSignal) {
return this.request<NativeProfileSummary>('/profiles', { method: 'POST', body: JSON.stringify({ id }), signal });
}
upload(file: File, signal?: AbortSignal) {
const body = new FormData();
body.append('file', file);
return this.request<NativeArtifact>('/catalog', { method: 'POST', body, signal });
}
download(id: string, signal?: AbortSignal) {
return this.request<Blob>(`/catalog/${encodeURIComponent(id)}/download`, { signal }, true);
}
operate(id: string, kind: NativeOperationKind, body: Record<string, unknown> | undefined, key: string) {
return this.request<NativeOperationReceipt>(`/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<NativeOperation>(`/operations/${encodeURIComponent(id)}`, { signal });
}
cancel(id: string) { return this.request<NativeOperation>(`/operations/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); }
call(id: string, name: string, args: Record<string, unknown>, signal?: AbortSignal) {
return this.request<unknown>(`/profiles/${encodeURIComponent(id)}/call`, {
method: 'POST', body: JSON.stringify({ name, arguments: args }), signal,
});
}
}
|