File size: 8,500 Bytes
2eb87e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d22337b
2eb87e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import type {
  DisclosureState,
  ProjectCard,
  ScanJobState,
  OverviewStats,
  ProjectListResponse,
  ProjectPublicView,
  Severity,
  SubmitProjectRequest,
  SubmitProjectResponse,
} from "@/shared/types";

export class ApiError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
    public context?: Record<string, unknown>,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

/**
 * Build-time API origin via `VITE_API_BASE_URL` (no trailing slash).
 * Default empty → same-origin relative `/api/...`.
 * Cross-origin deploy example:
 *   VITE_API_BASE_URL=https://openvuln.clouditera.com pnpm --filter @openvuln/web build
 */
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/$/, "");

/** Absolute or root-relative URL for API paths and download links. */
export function apiUrl(path: string): string {
  const p = path.startsWith("/") ? path : `/${path}`;
  return `${API_BASE}${p}`;
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(apiUrl(path), {
    credentials: "include",
    ...init,
    headers: {
      ...(init?.body ? { "content-type": "application/json" } : {}),
      ...init?.headers,
    },
  });

  if (!res.ok) {
    let code = "ERR_UNKNOWN";
    let summary = res.statusText;
    let context: Record<string, unknown> | undefined;
    try {
      const body = (await res.json()) as {
        error?: { code?: string; summary?: string; context?: Record<string, unknown> };
      };
      code = body.error?.code ?? code;
      summary = body.error?.summary ?? summary;
      context = body.error?.context;
    } catch {
      /* ignore */
    }
    throw new ApiError(res.status, code, summary, context);
  }

  if (res.status === 204) return undefined as T;
  return (await res.json()) as T;
}


/* ── Auth + owner self-service (design: docs/auth-owner-selfservice-design.md) ── */

export interface MeResponse {
  authenticated: boolean;
  user: { id: number; login: string; avatar_url: string | null } | null;
}

/** GitHub OAuth login URL (full-page redirect). */
export function loginUrl(returnTo: string = currentReturnTo()): string {
  // 站内相对路径与白名单绝对地址(HF 部署回跳)都放行 —— 白名单校验在后端;
  // 仅协议相对 "//…" 一律拒绝(开放重定向的唯一真风险)。
  const safe = returnTo.startsWith("//") ? "/" : returnTo;
  return apiUrl(`/api/auth/github/login?return_to=${encodeURIComponent(safe)}`);
}

/** 是否在 iframe 嵌套环境(HF Space 嵌入页;GitHub x-frame-options:deny,iframe 内跳 OAuth 必死)。 */
export function isEmbedded(): boolean {
  try {
    return window.self !== window.top;
  } catch {
    return true;
  }
}

/**
 * 当前页作为 OAuth return_to:跨域部署(HF 静态站,API_BASE 与页面不同源)必须给
 * 本站绝对地址(后端白名单已放行),授权完才回得来;同源部署(clouditera)用相对路径。
 */
export function currentReturnTo(): string {
  const rel = window.location.pathname + window.location.search;
  if (API_BASE && new URL(API_BASE).origin !== window.location.origin) {
    return window.location.origin + rel;
  }
  return rel;
}

/**
 * Navigate to OAuth login.
 * - iframe (HF Space hub): open popup window (sandbox blocks top navigation)
 * - direct access (clouditera / HF subdomain): redirect current page
 */
export function navigateToLogin(returnTo: string = currentReturnTo()): void {
  const url = loginUrl(returnTo);
  if (isEmbedded()) {
    // Must call window.open synchronously in click handler to avoid popup blocker
    const popup = window.open(url, "ov-oauth", "width=600,height=700");
    if (!popup) {
      // Popup blocked — fall back to top navigation (may work on some platforms)
      if (window.top) window.top.location.href = url;
    }
  } else {
    window.location.href = url;
  }
}

/** Popup OAuth callback return_to path. */
export const POPUP_CALLBACK_PATH = "/auth/popup-callback";

/** return_to for popup flow: always the API origin + callback path.
 *  The callback page MUST run on the API domain (clouditera), not the HF Space domain,
 *  so that /api/me fetch is first-party (cookie works) rather than third-party (blocked).
 */
export function popupReturnTo(): string {
  return `${API_BASE}${POPUP_CALLBACK_PATH}`;
}

/** Navigate to OAuth login in popup (for embedded/iframe context). */
export function navigateToLoginPopup(): void {
  const url = loginUrl(popupReturnTo());
  window.open(url, "ov-oauth", "width=600,height=700");
}

export interface OwnerFindingSummary {
  id: string;
  finding_key: string;
  severity: Severity;
  title: string;
  cwe: string | null;
  primary_file: string | null;
  disclosure_state: DisclosureState;
  detail_json: unknown;
  report_yaml: string | null;
  cvss_score: number | null;
  poc_status: string | null;
}

export interface OwnerArtifact {
  kind: string;
  rel_path: string;
  file_name: string;
  mime: string | null;
  size_bytes: number;
  truncated: boolean;
  is_binary: boolean;
  has_content: boolean;
}

export interface OwnerFindingDetail extends OwnerFindingSummary {
  report: {
    metadata?: Record<string, unknown>;
    description?: Record<string, unknown>;
    code?: Record<string, unknown>;
    references?: unknown;
  } | null;
  artifacts: OwnerArtifact[];
}


/* ── 站内通知(task-78c9fb3a,契约以 architect 简案为准) ── */

export interface NotificationItem {
  id: string;
  type: string; // v1: "scan_completed"
  payload: {
    project_id: string;
    full_name: string;
    scan_job_id: string;
    counts: { critical: number; high: number; medium: number; low: number };
    no_value: boolean;
  };
  read_at: string | null;
  created_at: string;
}

export interface ScanJobSummary {
  id: string;
  state: ScanJobState;
  commit_sha: string | null;
  git_ref: string | null;
  findings_so_far: number;
  created_at: string;
  finished_at: string | null;
}

export const api = {
  overview: () => request<OverviewStats>("/api/stats/overview"),
  listProjects: (params?: { sort?: string; page?: number; page_size?: number }) => {
    const q = new URLSearchParams();
    if (params?.sort) q.set("sort", params.sort);
    if (params?.page) q.set("page", String(params.page));
    if (params?.page_size) q.set("page_size", String(params.page_size));
    const qs = q.toString();
    return request<ProjectListResponse>(`/api/projects${qs ? `?${qs}` : ""}`);
  },
  getProject: (owner: string, repo: string) =>
    request<ProjectPublicView>(
      `/api/projects/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
    ),
  submitProject: (body: SubmitProjectRequest) =>
    request<SubmitProjectResponse>("/api/projects", {
      method: "POST",
      body: JSON.stringify(body),
    }),
  me: () => request<MeResponse>("/api/me"),
  logout: () => request<void>("/api/auth/logout", { method: "POST" }),
  ownerFindings: (projectId: string, scanJobId?: string) =>
    request<{ project_id: string; findings: OwnerFindingSummary[] }>(
      `/api/projects/${projectId}/findings${scanJobId ? `?scan_job_id=${scanJobId}` : ""}`,
    ),
  ownerFinding: (projectId: string, key: string, scanJobId?: string) =>
    request<{ finding: OwnerFindingDetail }>(
      `/api/projects/${projectId}/findings/${encodeURIComponent(key)}${scanJobId ? `?scan_job_id=${scanJobId}` : ""}`,
    ),
  ownerDisclose: (projectId: string, findingIds: string[]) =>
    request<{ disclosed_count: number }>(`/api/projects/${projectId}/disclose`, {
      method: "POST",
      body: JSON.stringify({ finding_ids: findingIds }),
    }),
  notifications: (limit = 20) =>
    request<{ notifications: NotificationItem[]; unread_count: number }>(
      `/api/notifications?limit=${limit}`,
    ),
  markNotificationsRead: (ids: string[]) =>
    request<void>("/api/notifications/read", { method: "POST", body: JSON.stringify({ ids }) }),
  markAllNotificationsRead: () =>
    request<void>("/api/notifications/read-all", { method: "POST" }),
  myProjects: () => request<{ projects: ProjectCard[] }>("/api/my/projects"),
  projectScans: (projectId: string) =>
    request<{ scans: ScanJobSummary[] }>(`/api/projects/${projectId}/scans`),
  cancelScanJob: (projectId: string, jobId: string) =>
    request<{ ok: true; state: string }>(
      `/api/projects/${projectId}/scan-jobs/${jobId}/cancel`,
      { method: "POST" },
    ),
};