mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-10 20:53:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff2130f46 | ||
|
|
a05a886d3d | ||
|
|
9fe993f460 |
No files matched your search
@@ -326,7 +326,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
|
||||
从仍使用 `/api/health` 读取版本的旧版网页升级到此版本时,旧页面可能无法自动判断升级完成;服务重启后手动刷新页面即可。新版网页的版本显示和升级轮询均使用 `/api/version`。
|
||||
|
||||
配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。
|
||||
配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE。验证过期时,后台请求会触发当前页面重新验证,保留 ARTEX 登录状态,无需新开标签页;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。
|
||||
|
||||
Docker 和内嵌前端二进制直接支持此功能。`next dev` 的认证检查仅向本机回环后端转发凭据(`localhost`、`127.0.0.1` 或 `::1`);前端与后端应使用同一主机名。远程开发后端未开启验证时仍可使用;开启后请通过内嵌前端访问远程服务,避免跨域验证 Cookie 问题。
|
||||
|
||||
|
||||
@@ -139,7 +139,13 @@ func (s *Server) requireBasicAuth(next http.Handler) http.Handler {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || len(password) > maxPasswordBytes || username != cfg.Username ||
|
||||
bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`)
|
||||
// Background requests use Bearer auth. Challenging them with Basic
|
||||
// can trap the browser in repeated native dialogs. Let the client
|
||||
// reload the current page once to renew only the outer session.
|
||||
w.Header().Set("X-Artex-Auth", "basic")
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/basic-auth/check" || r.URL.Path == "/api/health" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`)
|
||||
}
|
||||
writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -279,8 +279,14 @@ func TestBasicAuthGate(t *testing.T) {
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="Restricted", charset="UTF-8"` {
|
||||
t.Fatal("missing browser challenge")
|
||||
if w.Code == 401 {
|
||||
wantChallenge := !strings.HasPrefix(tc.path, "/api/") || tc.path == "/api/basic-auth/check" || tc.path == "/api/health"
|
||||
if got := w.Header().Get("WWW-Authenticate"); (got != "") != wantChallenge {
|
||||
t.Fatalf("browser challenge=%q, want challenge=%v", got, wantChallenge)
|
||||
}
|
||||
if w.Header().Get("X-Artex-Auth") != "basic" {
|
||||
t.Fatal("missing gate failure marker")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -481,8 +487,8 @@ func TestBasicAuthPostgresLoginFlow(t *testing.T) {
|
||||
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
|
||||
w = httptest.NewRecorder()
|
||||
restarted.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatal("login was not protected after restart")
|
||||
if w.Code != 401 || w.Header().Get("X-Artex-Auth") != "basic" || w.Header().Get("WWW-Authenticate") != "" {
|
||||
t.Fatal("login must fail with a recoverable gate error after restart")
|
||||
}
|
||||
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
|
||||
r.SetBasicAuth("entry", "entry-password")
|
||||
@@ -504,3 +510,66 @@ func TestBasicAuthPostgresLoginFlow(t *testing.T) {
|
||||
t.Fatalf("disable with gate cookie and JWT: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An expired gate must be recoverable without discarding the independent admin
|
||||
// session. API failures must never open native Basic dialogs themselves.
|
||||
func TestBasicAuthExpiredSessionRecovery(t *testing.T) {
|
||||
s, _ := basicAuthTestServer(t)
|
||||
w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"entry-password"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
cfg := s.basicAuth.snapshot()
|
||||
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||||
Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
}).SignedString(s.basicAuthSigningKey())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminJWT, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := s.Handler()
|
||||
for _, path := range []string{"/api/version", "/api/auth/status", "/api/logs/stream", "/api/workspace/upload", "/api/workspace/download"} {
|
||||
t.Run(strings.TrimPrefix(path, "/"), func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+adminJWT)
|
||||
r.AddCookie(&http.Cookie{Name: basicAuthCookie, Value: expired})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != 401 || w.Header().Get("X-Artex-Auth") != "basic" || w.Header().Get("WWW-Authenticate") != "" {
|
||||
t.Fatalf("API must signal recovery without a browser challenge: %d %v", w.Code, w.Header())
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-store" || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatal("gate failure must not cache or change the admin session")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Reloading the current document can use Basic authorization, while the
|
||||
// admin JWT stays in its cookie for subsequent API requests.
|
||||
r := httptest.NewRequest("GET", "/function/tasks/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "artex_token", Value: adminJWT})
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatal("document navigation must still prompt for Basic authentication")
|
||||
}
|
||||
r = httptest.NewRequest("GET", "/api/basic-auth/check", nil)
|
||||
r.SetBasicAuth("entry", "entry-password")
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNoContent || len(w.Result().Cookies()) != 1 {
|
||||
t.Fatalf("reauthentication failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
renewed := w.Result().Cookies()[0]
|
||||
r = httptest.NewRequest("GET", "/api/version", nil)
|
||||
r.AddCookie(renewed)
|
||||
r.Header.Set("Authorization", "Bearer "+adminJWT)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != 200 || w.Header().Get("WWW-Authenticate") != "" {
|
||||
t.Fatalf("original admin session did not recover: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openSSE } from "@/lib/api";
|
||||
import { apiFetch, openSSE } from "@/lib/api";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type { LogLine } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -87,7 +87,7 @@ export default function LogsPage() {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const params = minDbId > 0 ? `?before=${minDbId}&limit=200` : `?limit=200`;
|
||||
const res = await fetch(`/api/logs/history${params}`);
|
||||
const res = await apiFetch(`/api/logs/history${params}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as { items: LogLine[]; has_more: boolean };
|
||||
if (data.items?.length) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react";
|
||||
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
function AuthenticatedImage({
|
||||
@@ -39,7 +40,7 @@ function AuthenticatedImage({
|
||||
|
||||
const token = auth.getToken();
|
||||
setCaptureTimestamp(null);
|
||||
fetch(imageSrc, {
|
||||
apiFetch(imageSrc, {
|
||||
credentials: "same-origin",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { api } from "@/lib/api";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, copyText } from "@/lib/utils";
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
@@ -92,10 +92,9 @@ function TrafficID({ id }: { id: string }) {
|
||||
aria-label={`复制流量 ID ${id}`}
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
if (await copyText(id)) {
|
||||
toast.success("已复制流量 ID");
|
||||
} catch {
|
||||
} else {
|
||||
toast.error("复制失败,请手动选择流量 ID");
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
const ts = require("typescript");
|
||||
|
||||
const compiled = ts.transpileModule(fs.readFileSync(path.join(__dirname, "api.ts"), "utf8"), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
|
||||
function client(fetch) {
|
||||
const exports = {};
|
||||
const state = { token: "original-admin-token", reloads: 0, requests: [], streams: [] };
|
||||
const location = { href: "http://localhost/function/tasks/?page=2", hostname: "localhost", reload: () => state.reloads++ };
|
||||
const document = { cookie: "artex_token=original-admin-token" };
|
||||
class EventSource {
|
||||
constructor(url, options) { this.url = url; this.options = options; this.listeners = {}; state.streams.push(this); }
|
||||
addEventListener(name, listener) { this.listeners[name] = listener; }
|
||||
}
|
||||
vm.runInNewContext(compiled, {
|
||||
exports, URL, URLSearchParams, FormData, EventSource,
|
||||
process: { env: { NODE_ENV: "production" } },
|
||||
window: { location }, document,
|
||||
localStorage: { getItem: () => state.token, removeItem: () => { state.token = null; } },
|
||||
require: (id) => {
|
||||
if (id === "@/lib/mock/enabled") return { MOCK: false };
|
||||
if (id === "@/lib/mock/handler") return { mockHandle: () => assert.fail("unexpected mock request") };
|
||||
throw new Error(`Unexpected import: ${id}`);
|
||||
},
|
||||
fetch: async (...args) => { state.requests.push(args); return fetch(...args); },
|
||||
});
|
||||
return { ...exports, state, location, document };
|
||||
}
|
||||
|
||||
const gateFailure = () => new Response('{"error":"请先完成 HTTP Basic Auth 验证"}', {
|
||||
status: 401, headers: { "X-Artex-Auth": "basic" },
|
||||
});
|
||||
|
||||
function assertSessionPreserved(c) {
|
||||
assert.equal(c.state.token, "original-admin-token");
|
||||
assert.equal(c.document.cookie, "artex_token=original-admin-token");
|
||||
assert.equal(c.location.href, "http://localhost/function/tasks/?page=2");
|
||||
}
|
||||
|
||||
test("expired Basic gate reloads the current tab and preserves the admin session", async () => {
|
||||
const c = client(async () => gateFailure());
|
||||
await assert.rejects(c.http("/version"), /访问验证已过期/);
|
||||
assert.equal(c.state.reloads, 1);
|
||||
assert.equal(c.state.requests[0][1].headers.Authorization, "Bearer original-admin-token");
|
||||
assertSessionPreserved(c);
|
||||
});
|
||||
|
||||
test("concurrent gate failures reload once and stop new polling requests", async () => {
|
||||
let release;
|
||||
const pending = new Promise((resolve) => { release = resolve; });
|
||||
const c = client(async () => { await pending; return gateFailure(); });
|
||||
const requests = [c.http("/version"), c.http("/tasks"), c.http("/settings")];
|
||||
release();
|
||||
const results = await Promise.allSettled(requests);
|
||||
assert.ok(results.every((r) => r.status === "rejected"));
|
||||
assert.equal(c.state.reloads, 1);
|
||||
await assert.rejects(c.http("/tasks"), /访问验证已过期/);
|
||||
assert.equal(c.state.requests.length, 3);
|
||||
assertSessionPreserved(c);
|
||||
});
|
||||
|
||||
test("a late admin 401 cannot clear the token once gate recovery has started", async () => {
|
||||
let release;
|
||||
const late = new Promise((resolve) => { release = resolve; });
|
||||
const c = client(async (url) => {
|
||||
if (url === "/api/tasks") { await late; return new Response("expired", { status: 401 }); }
|
||||
return gateFailure();
|
||||
});
|
||||
const staleRequest = c.http("/tasks");
|
||||
await assert.rejects(c.http("/version"), /访问验证已过期/);
|
||||
release();
|
||||
await assert.rejects(staleRequest, /访问验证已过期/);
|
||||
assert.equal(c.state.reloads, 1);
|
||||
assertSessionPreserved(c);
|
||||
});
|
||||
|
||||
test("an ordinary expired admin JWT still clears the login and opens /login", async () => {
|
||||
const c = client(async () => new Response('{"error":"token 无效或已过期"}', { status: 401 }));
|
||||
await assert.rejects(c.http("/version"), /未授权/);
|
||||
assert.equal(c.state.token, null);
|
||||
assert.equal(c.location.href, "/login");
|
||||
assert.equal(c.state.reloads, 0);
|
||||
});
|
||||
|
||||
test("business errors do not trigger gate recovery", async () => {
|
||||
const c = client(async () => new Response('{"error":"禁止操作"}', { status: 403 }));
|
||||
await assert.rejects(c.http("/tasks"), /禁止操作/);
|
||||
assert.equal(c.state.reloads, 0);
|
||||
assertSessionPreserved(c);
|
||||
});
|
||||
|
||||
test("uploads and downloads also recover the Basic gate", async () => {
|
||||
for (const action of [
|
||||
(c) => c.api.workspaceUpload("", [new File(["test"], "test.txt")]),
|
||||
(c) => c.api.workspaceDownload("test.txt"),
|
||||
(c) => c.api.report(),
|
||||
(c) => c.api.interceptGetToolConfig(),
|
||||
]) {
|
||||
const c = client(async () => gateFailure());
|
||||
await assert.rejects(action(c), /访问验证已过期/);
|
||||
assert.equal(c.state.reloads, 1);
|
||||
assertSessionPreserved(c);
|
||||
}
|
||||
});
|
||||
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test("SSE failures probe the same-origin gate once for concurrent streams", async () => {
|
||||
const c = client(async () => gateFailure());
|
||||
const first = c.openSSE("/api/logs/stream");
|
||||
const second = c.openSSE("/api/update/stream");
|
||||
first.listeners.error();
|
||||
second.listeners.error();
|
||||
await tick();
|
||||
assert.equal(c.state.requests.length, 1);
|
||||
assert.equal(c.state.requests[0][0], "/api/auth/status");
|
||||
assert.equal(c.state.reloads, 1);
|
||||
assertSessionPreserved(c);
|
||||
});
|
||||
|
||||
test("ordinary SSE disconnects and network errors do not reload the page", async () => {
|
||||
for (const fetch of [
|
||||
async () => new Response('{"initialized":true}'),
|
||||
async () => { throw new TypeError("network unavailable"); },
|
||||
]) {
|
||||
const c = client(fetch);
|
||||
const source = c.openSSE("/api/logs/stream");
|
||||
source.listeners.error();
|
||||
await tick();
|
||||
source.listeners.error();
|
||||
await tick();
|
||||
assert.equal(c.state.reloads, 0);
|
||||
assert.equal(c.state.requests.length, 2);
|
||||
assertSessionPreserved(c);
|
||||
}
|
||||
});
|
||||
|
||||
test("a fresh document resumes API requests with the original admin token", async () => {
|
||||
const c = client(async () => new Response('{"version":"test"}'));
|
||||
assert.equal((await c.api.version()).version, "test");
|
||||
assert.equal(c.state.requests[0][1].headers.Authorization, "Bearer original-admin-token");
|
||||
assert.equal(c.state.reloads, 0);
|
||||
});
|
||||
+41
-11
@@ -144,10 +144,28 @@ function getToken(): string | null {
|
||||
return localStorage.getItem("artex_token");
|
||||
}
|
||||
|
||||
let basicAuthReloading = false;
|
||||
let basicAuthProbe: Promise<void> | null = null;
|
||||
|
||||
// Renew the outer gate through a page navigation, where the browser can send
|
||||
// Basic credentials without replacing an API request's Bearer authorization.
|
||||
// Keep the admin token and collapse concurrent failures into one reload.
|
||||
export async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
if (basicAuthReloading) throw new Error("访问验证已过期,正在重新验证");
|
||||
const response = await fetch(input, init);
|
||||
if (basicAuthReloading) throw new Error("访问验证已过期,正在重新验证");
|
||||
if (response.status === 401 && response.headers.get("X-Artex-Auth") === "basic") {
|
||||
basicAuthReloading = true;
|
||||
window.location.reload();
|
||||
throw new Error("访问验证已过期,正在重新验证");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
if (MOCK) return mockHandle<T>(init?.method ?? "GET", path, init?.body ?? null);
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api${path}`, {
|
||||
const r = await apiFetch(`/api${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
@@ -236,7 +254,19 @@ export function openSSE(path: string): EventSource {
|
||||
// Same-origin streams already send cookies. Only opt into cross-port cookies
|
||||
// for the local Next/Go dev pair; keep custom remote SSE endpoints unchanged.
|
||||
const local = ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname);
|
||||
return new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname });
|
||||
const source = new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname });
|
||||
source.addEventListener("error", () => {
|
||||
// EventSource hides the response status. Probe a same-origin auth endpoint
|
||||
// to distinguish an expired gate from an ordinary stream/network failure.
|
||||
if (basicAuthProbe !== null || basicAuthReloading) return;
|
||||
basicAuthProbe = apiFetch("/api/auth/status", { cache: "no-store" })
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
basicAuthProbe = null;
|
||||
});
|
||||
});
|
||||
return source;
|
||||
}
|
||||
|
||||
const get = <T>(p: string) => http<T>(p);
|
||||
@@ -554,7 +584,7 @@ export const api = {
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("file", f);
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, {
|
||||
const r = await apiFetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, {
|
||||
method: "POST",
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: fd,
|
||||
@@ -569,7 +599,7 @@ export const api = {
|
||||
blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" });
|
||||
} else {
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, {
|
||||
const r = await apiFetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, {
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
});
|
||||
if (!r.ok) throw new Error(`下载失败: ${r.status}`);
|
||||
@@ -729,7 +759,7 @@ export const api = {
|
||||
for (const [k, v] of findingFilterParams(opts.filters)) p.set(k, v);
|
||||
}
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, {
|
||||
const r = await apiFetch(`/api/exploration/findings/export?${p.toString()}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!r.ok) throw new Error(`export: ${r.status}`);
|
||||
@@ -807,7 +837,7 @@ export const api = {
|
||||
contextTask?: string,
|
||||
) => {
|
||||
const token = getToken();
|
||||
const response = await fetch(
|
||||
const response = await apiFetch(
|
||||
`/api/exploration/findings/${id}/traffic/${bindingId}/body?side=${side}&download=1${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
);
|
||||
@@ -1081,7 +1111,7 @@ export const api = {
|
||||
report: async (task?: string) => {
|
||||
if (MOCK) return mockReport(task);
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/report${tq(task)}`, {
|
||||
const r = await apiFetch(`/api/report${tq(task)}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!r.ok) throw new Error(`report: ${r.status}`);
|
||||
@@ -1109,7 +1139,7 @@ export const api = {
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("file", f);
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, {
|
||||
const r = await apiFetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, {
|
||||
method: "POST",
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: fd,
|
||||
@@ -1369,7 +1399,7 @@ export const api = {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, {
|
||||
const r = await apiFetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
@@ -1458,7 +1488,7 @@ export const api = {
|
||||
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
|
||||
if (MOCK) return { enabled_tools: ["bash"] };
|
||||
const token = getToken();
|
||||
const r = await fetch("/api/intercept/tool-config", {
|
||||
const r = await apiFetch("/api/intercept/tool-config", {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
@@ -1467,7 +1497,7 @@ export const api = {
|
||||
interceptSetToolConfig: async (enabledTools: string[]): Promise<void> => {
|
||||
if (MOCK) return;
|
||||
const token = getToken();
|
||||
const r = await fetch("/api/intercept/tool-config", {
|
||||
const r = await apiFetch("/api/intercept/tool-config", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
Reference in new issue
Block a user