Spaces:
Running
Running
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" },
),
};
|