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,
    });
  }
}