diff --git a/server/repeater.go b/server/repeater.go index 3f157d9..435f53f 100644 --- a/server/repeater.go +++ b/server/repeater.go @@ -32,6 +32,7 @@ var repeaterHopByHop = map[string]struct{}{ } type repeaterSendRequest struct { + HTTPVersion string `json:"http_version"` Method *string `json:"method"` URL string `json:"url"` Headers map[string]string `json:"headers"` @@ -89,6 +90,13 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) { return } + switch in.HTTPVersion { + case "", "HTTP/1.1", "HTTP/2.0": + default: + writeErr(w, http.StatusBadRequest, "当前支持 HTTP/1.1 和 HTTP/2.0") + return + } + rawURL := strings.TrimSpace(in.URL) if rawURL == "" { writeErr(w, http.StatusBadRequest, "url 必填") @@ -151,9 +159,30 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) { req.Header.Set(name, value) } + transport := s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify) + if in.HTTPVersion != "" { + tr := transport.(*http.Transport) + // Keep TLS ALPN consistent with the explicit protocol, even when an + // inherited transport previously advertised h2. + if tr.TLSClientConfig == nil { + tr.TLSClientConfig = &tls.Config{} + } else { + tr.TLSClientConfig = tr.TLSClientConfig.Clone() + } + tr.TLSClientConfig.NextProtos = []string{"http/1.1"} + if in.HTTPVersion == "HTTP/2.0" { + tr.TLSClientConfig.NextProtos = []string{"h2"} + } + tr.ForceAttemptHTTP2 = in.HTTPVersion == "HTTP/2.0" + tr.Protocols = new(http.Protocols) + tr.Protocols.SetHTTP1(in.HTTPVersion == "HTTP/1.1") + tr.Protocols.SetHTTP2(in.HTTPVersion == "HTTP/2.0") + tr.Protocols.SetUnencryptedHTTP2(in.HTTPVersion == "HTTP/2.0" && strings.EqualFold(u.Scheme, "http")) + } + client := &http.Client{ Timeout: time.Duration(timeoutMs) * time.Millisecond, - Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify), + Transport: transport, CheckRedirect: func(next *http.Request, via []*http.Request) error { if len(via) >= 10 { return errors.New("重定向次数过多") diff --git a/server/repeater_test.go b/server/repeater_test.go index 608f04d..b721e62 100644 --- a/server/repeater_test.go +++ b/server/repeater_test.go @@ -402,3 +402,41 @@ func TestRepeaterRedactProxyCredentials(t *testing.T) { }) } } + +func TestRepeaterHonorsExplicitHTTPVersion(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + for _, version := range []string{"HTTP/1.1", "HTTP/2.0"} { + t.Run(version, func(t *testing.T) { + code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{ + "method": "GET", "url": srv.URL, "http_version": version, + "insecure_skip_verify": true, "use_recording_proxy": false, + }) + if code != http.StatusOK || out.Error != "" || out.Body != version { + t.Fatalf("version %s: status=%d response=%s", version, code, raw) + } + }) + } + code, _, _ := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/1.0"}) + if code != http.StatusBadRequest { + t.Fatalf("unsupported version status=%d", code) + } +} + +func TestRepeaterExplicitHTTP2Cleartext(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.Proto) })) + srv.Config.Protocols = new(http.Protocols) + srv.Config.Protocols.SetUnencryptedHTTP2(true) + srv.Start() + defer srv.Close() + code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/2.0", "use_recording_proxy": false}) + if code != http.StatusOK || out.Error != "" || out.Body != "HTTP/2.0" { + t.Fatalf("status=%d response=%s", code, raw) + } +} diff --git a/web/src/components/finding-traffic-panel.tsx b/web/src/components/finding-traffic-panel.tsx index 3e6d80a..c326f9c 100644 --- a/web/src/components/finding-traffic-panel.tsx +++ b/web/src/components/finding-traffic-panel.tsx @@ -7,7 +7,7 @@ import { useRouter } from "next/navigation"; import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react"; import { toast } from "sonner"; -import { headersFromReqHead } from "@/components/repeater/repeater-http"; +import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http"; import { seedFromEvidence } from "@/components/repeater/repeater-store"; import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer"; import { TrafficPickerDialog } from "@/components/traffic-picker-dialog"; @@ -107,12 +107,15 @@ export function FindingTrafficPanel({ if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载"); else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载"); seedFromEvidence( - { - method: detail.binding.snapshot.method || "GET", - url: detail.binding.snapshot.url || "", - headersText: headersFromReqHead(detail.binding.snapshot.req_head), - body: detail.request.binary ? "" : detail.request.content || "", - }, + requestFromEvidence( + { + method: detail.binding.snapshot.method || "GET", + url: detail.binding.snapshot.url || "", + headersText: headersFromReqHead(detail.binding.snapshot.req_head), + body: detail.request.binary ? "" : detail.request.content || "", + }, + detail.binding.snapshot.req_head, + ), { findingId, bindingId }, ); router.push("/function/repeater"); diff --git a/web/src/components/repeater/repeater-editor.tsx b/web/src/components/repeater/repeater-editor.tsx index acc7e1e..7f1fc9c 100644 --- a/web/src/components/repeater/repeater-editor.tsx +++ b/web/src/components/repeater/repeater-editor.tsx @@ -9,58 +9,66 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { api } from "@/lib/api"; import { + buildRawRequest, buildRawResponse, - getHostHeader, - joinRequestText, + connectionOrigin, parseHeadersText, - setHostHeader, - splitRequestText, + parseRawRequest, } from "./repeater-http"; import { newId, type RepeaterTab } from "./repeater-state"; import { useRepeaterStore } from "./repeater-store"; import { RepeaterTextPane } from "./repeater-text-pane"; -const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; - export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { - const { method, url, headersText, body, useProxy, insecure, response } = tab; + const { useProxy, insecure, response } = tab; const updateTab = useRepeaterStore((state) => state.updateTab); const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined); - const setMethod = (method: string) => updateTab(tab.id, { method }); - const setURL = (url: string) => updateTab(tab.id, { url }); - const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText }); - const [request, setRequest] = React.useState(() => ({ headersText, body, text: joinRequestText(headersText, body) })); - // Keep partial edits (including a single trailing newline) intact while syncing - // changes made by the Host control or by restoring a history entry. - if (request.headersText !== headersText || request.body !== body) { - setRequest({ headersText, body, text: joinRequestText(headersText, body) }); - } - function changeRequest(text: string) { - const parts = splitRequestText(text); - setRequest({ ...parts, text }); - updateTab(tab.id, parts); + const [targetOpen, setTargetOpen] = React.useState(false); + const [targetDraft, setTargetDraft] = React.useState(""); + const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : ""); + const requestText = buildRawRequest(tab); + function changeRequest(rawRequest: string) { + try { + const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin); + updateTab(tab.id, { ...parts, rawRequest, targetOrigin }); + } catch { + // Preserve incomplete first lines and headers as drafts without rewriting + // the text or moving the caret. Validate only when the user sends. + updateTab(tab.id, { rawRequest, targetOrigin }); + } } const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy }); const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure }); async function send() { const store = useRepeaterStore.getState(); if (store.inflight[tab.id] !== undefined) return; - const target = url.trim(); - if (!target) { - toast.error("请填写 URL"); + let parsed: ReturnType; + try { + parsed = parseRawRequest(requestText, targetOrigin); + } catch (error) { + toast.error((error as Error).message); return; } + const { method, url: target, headersText, body, httpVersion } = parsed; const requestGeneration = store.beginSend(tab.id); if (requestGeneration === undefined) return; try { const result = await api.repeaterSend({ method, + http_version: httpVersion, url: target, headers: parseHeadersText(headersText), body, @@ -73,6 +81,8 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { url: target, headersText, body, + rawRequest: requestText, + targetOrigin, useProxy, insecure, response: result, @@ -87,57 +97,26 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { return (
- - - - 方法 - - - - - - URL - - setURL(event.target.value)} - placeholder="https://target.example/path" - spellCheck={false} - /> - - - - Host 请求头 - - setHeadersText(setHostHeader(headersText, event.target.value))} - placeholder="可选,留空使用 URL 主机" - spellCheck={false} - /> - +
+ + 发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"} + + - +
发送选项 @@ -171,10 +150,10 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
{response?.error ? ( @@ -190,6 +169,50 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { />
+ + + + 修改发送目标 + + 设置连接地址和 HTTP/HTTPS。方法、路径和 Host 均在左侧报文中编辑;Host 可以与连接目标不同。 + + +
{ + event.preventDefault(); + const origin = targetDraft.trim() ? connectionOrigin(targetDraft.trim()) : ""; + if (targetDraft.trim() && !origin) { + toast.error("请填写 http:// 或 https:// 开头的地址"); + return; + } + try { + const { httpVersion: _version, ...parts } = parseRawRequest(requestText, origin); + updateTab(tab.id, { ...parts, rawRequest: requestText, targetOrigin: origin }); + } catch { + updateTab(tab.id, { rawRequest: requestText, targetOrigin: origin }); + } + setTargetOpen(false); + }} + > + + 发送目标 + setTargetDraft(event.target.value)} + /> + + + + + +
+
+
); } diff --git a/web/src/components/repeater/repeater-filter.ts b/web/src/components/repeater/repeater-filter.ts new file mode 100644 index 0000000..5abed92 --- /dev/null +++ b/web/src/components/repeater/repeater-filter.ts @@ -0,0 +1,28 @@ +import { buildRawResponse } from "./repeater-http"; +import type { RepeaterTab } from "./repeater-state"; + +export type RepeaterFilters = { query: string; method: string; path: string; response: string; status: string }; +export const DEFAULT_FILTERS: RepeaterFilters = { query: "", method: "all", path: "", response: "", status: "all" }; + +export function matchesRepeaterFilters(tab: RepeaterTab, filters: RepeaterFilters): boolean { + const includes = (text: string, query: string) => text.toLowerCase().includes(query.trim().toLowerCase()); + if (filters.method !== "all" && tab.method.toUpperCase() !== filters.method) return false; + let path = tab.url; + try { + const url = new URL(tab.url); + path = url.pathname + url.search; + } catch { + /* Incomplete drafts remain searchable. */ + } + if (!includes(path, filters.path)) return false; + const rawResponse = tab.response ? `${buildRawResponse(tab.response)}\n${tab.response.error || ""}` : ""; + if (!includes(rawResponse, filters.response)) return false; + if (filters.status === "pending" && tab.response) return false; + if (filters.status === "error" && (!tab.response?.error || tab.response.status)) return false; + if (/^[1-5]xx$/.test(filters.status) && Math.floor((tab.response?.status || 0) / 100) !== Number(filters.status[0])) + return false; + return includes( + [tab.title, tab.url, tab.rawRequest || "", tab.headersText, tab.body, rawResponse].join("\n"), + filters.query, + ); +} diff --git a/web/src/components/repeater/repeater-history.tsx b/web/src/components/repeater/repeater-history.tsx index 1db17ab..b47f79c 100644 --- a/web/src/components/repeater/repeater-history.tsx +++ b/web/src/components/repeater/repeater-history.tsx @@ -30,6 +30,8 @@ export function RepeaterHistory({ tab }: { tab?: RepeaterTab }) { url: entry.url, headersText: entry.headersText, body: entry.body, + rawRequest: entry.rawRequest, + targetOrigin: entry.targetOrigin, useProxy: entry.useProxy, insecure: entry.insecure, response: entry.response, diff --git a/web/src/components/repeater/repeater-http.ts b/web/src/components/repeater/repeater-http.ts index 6d13819..4a559f7 100644 --- a/web/src/components/repeater/repeater-http.ts +++ b/web/src/components/repeater/repeater-http.ts @@ -5,6 +5,8 @@ export type RepeaterInitial = { url?: string; headersText?: string; body?: string; + rawRequest?: string; + targetOrigin?: string; }; export function parseHeadersText(text: string): Record { @@ -107,10 +109,74 @@ export function requestFromTraffic( } catch { /* Legacy URL uses the indexed host. */ } + const headersText = getHostHeader(request.headersText) + ? request.headersText + : setHostHeader(request.headersText, host); + const rawRequest = `${firstLine[0].trimEnd()}\n${joinRequestText(headersText, request.body)}`; + const parsed = parseRawRequest(rawRequest, connectionOrigin(exchange.url)); return { - method: exchange.method, - url: exchange.url, - headersText: getHostHeader(request.headersText) ? request.headersText : setHostHeader(request.headersText, host), - body: request.body, + method: parsed.method, + url: parsed.url, + headersText: parsed.headersText, + body: parsed.body, + rawRequest, + targetOrigin: connectionOrigin(exchange.url), }; } + +export function connectionOrigin(url: string): string { + try { + const parsed = new URL(url); + return /^https?:$/.test(parsed.protocol) ? parsed.origin : ""; + } catch { + return ""; + } +} + +export function buildRawRequest(request: RepeaterInitial): string { + if (request.rawRequest !== undefined) return request.rawRequest; + let target = "/"; + let host = ""; + try { + const url = new URL(request.url ?? ""); + target = url.pathname + url.search; + host = url.host; + } catch { + /* New empty draft. */ + } + const headersText = request.headersText?.trim() ? request.headersText : `Host: ${host}`; + return `${request.method ?? "GET"} ${target} HTTP/1.1\n${joinRequestText(headersText, request.body ?? "")}`; +} + +export function parseRawRequest( + text: string, + targetOrigin = "", +): { method: string; url: string; headersText: string; body: string; httpVersion: string } { + const newline = text.indexOf("\n"); + const line = (newline < 0 ? text : text.slice(0, newline)).trim(); + const match = /^([A-Za-z]+)\s+(\S+)\s+(HTTP\/(?:1\.[01]|2(?:\.0)?))$/.exec(line); + if (!match) throw new Error("请求首行应为:POST /path HTTP/1.1"); + const parts = splitRequestText(newline < 0 ? "" : text.slice(newline + 1)); + let url: URL; + if (/^https?:\/\//i.test(match[2])) url = new URL(match[2]); + else { + if (!match[2].startsWith("/")) throw new Error("请求路径必须以 / 开头,或填写完整 http/https URL"); + const origin = connectionOrigin(targetOrigin) || connectionOrigin(`https://${getHostHeader(parts.headersText)}`); + if (!origin) throw new Error("请填写 Host 请求头或设置发送目标"); + url = new URL(origin + match[2]); + } + if (url.hash) throw new Error("请求路径不能包含 URL 片段,请使用 %23 表示 #"); + return { + ...parts, + method: match[1].toUpperCase(), + url: url.href, + httpVersion: match[3] === "HTTP/2" ? "HTTP/2.0" : match[3], + }; +} + +export function requestFromEvidence(initial: RepeaterInitial, requestHead?: string): RepeaterInitial { + const firstLine = requestHead?.trimStart().split(/\r?\n/, 1)[0] || ""; + if (!/^[A-Za-z]+\s+\S+\s+HTTP\/\d(?:\.\d)?$/.test(firstLine)) return initial; + const rawRequest = `${firstLine}\n${joinRequestText(initial.headersText ?? "", initial.body ?? "")}`; + return { ...initial, rawRequest, targetOrigin: connectionOrigin(initial.url ?? "") }; +} diff --git a/web/src/components/repeater/repeater-navigator.tsx b/web/src/components/repeater/repeater-navigator.tsx new file mode 100644 index 0000000..17047ce --- /dev/null +++ b/web/src/components/repeater/repeater-navigator.tsx @@ -0,0 +1,350 @@ +"use client"; + +import * as React from "react"; + +import { ChevronDown, ChevronRight, ListFilter, Pencil, Plus, X } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +import { DEFAULT_FILTERS, matchesRepeaterFilters } from "./repeater-filter"; +import { RepeaterHistory } from "./repeater-history"; +import type { RepeaterWorkspace } from "./repeater-state"; +import { useRepeaterStore } from "./repeater-store"; + +type Rename = { kind: "tab" | "group"; id: string; name: string }; + +export function RepeaterNavigator({ + workspace, + onRename, +}: { + workspace: RepeaterWorkspace; + onRename: (value: Rename) => void; +}) { + const store = useRepeaterStore(); + const [filters, setFilters] = React.useState(DEFAULT_FILTERS); + const [expanded, setExpanded] = React.useState(false); + const [selected, setSelected] = React.useState>(() => new Set()); + const [deleting, setDeleting] = React.useState(false); + const visible = React.useMemo( + () => workspace.tabs.filter((tab) => matchesRepeaterFilters(tab, filters)), + [workspace.tabs, filters], + ); + const selectedIds = workspace.tabs.filter((tab) => selected.has(tab.id)).map((tab) => tab.id); + const visibleIds = new Set(visible.map((tab) => tab.id)); + const hiddenSelected = selectedIds.filter((id) => !visibleIds.has(id)).length; + const filtering = Object.entries(filters).some( + ([key, value]) => value !== DEFAULT_FILTERS[key as keyof typeof filters], + ); + const allSelected = visible.length > 0 && visible.every((tab) => selected.has(tab.id)); + const active = workspace.tabs.find((tab) => tab.id === workspace.activeTabId); + const methods = [ + ...new Set([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + ...workspace.tabs.map((tab) => tab.method.toUpperCase()), + ]), + ]; + function toggle(id: string, checked: boolean) { + setSelected((current) => { + const next = new Set(current); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + } + + return ( + + ); +} diff --git a/web/src/components/repeater/repeater-state.ts b/web/src/components/repeater/repeater-state.ts index 5fa8bff..f588e56 100644 --- a/web/src/components/repeater/repeater-state.ts +++ b/web/src/components/repeater/repeater-state.ts @@ -16,6 +16,8 @@ export type RepeaterHistoryEntry = { url: string; headersText: string; body: string; + rawRequest?: string; + targetOrigin?: string; useProxy: boolean; insecure: boolean; response: RepeaterSendResponse; @@ -37,7 +39,10 @@ export type RepeaterWorkspace = { activeTabId: string | null; }; export type TabSeed = Partial< - Pick + Pick< + RepeaterTab, + "method" | "url" | "headersText" | "body" | "rawRequest" | "targetOrigin" | "useProxy" | "insecure" | "source" + > >; export type TabOptions = { groupId?: string; title?: string; activate?: boolean }; @@ -169,6 +174,8 @@ function isRequest( return ( isRecord(value) && ["id", "method", "url", "headersText", "body"].every((key) => typeof value[key] === "string") && + (value.rawRequest === undefined || typeof value.rawRequest === "string") && + (value.targetOrigin === undefined || typeof value.targetOrigin === "string") && typeof value.useProxy === "boolean" && typeof value.insecure === "boolean" ); diff --git a/web/src/components/repeater/repeater-store.test.mjs b/web/src/components/repeater/repeater-store.test.mjs index 0d76131..ca86a83 100644 --- a/web/src/components/repeater/repeater-store.test.mjs +++ b/web/src/components/repeater/repeater-store.test.mjs @@ -21,6 +21,9 @@ const { truncateResponse, } = require("./repeater-state.ts"); const { + requestFromEvidence, + buildRawRequest, + parseRawRequest, joinRequestText, splitRequestText, requestFromTraffic, @@ -30,6 +33,7 @@ const { parseHeadersText, setHostHeader, } = require("./repeater-http.ts"); +const { DEFAULT_FILTERS, matchesRepeaterFilters } = require("./repeater-filter.ts"); const { flush, useRepeaterStore } = require("./repeater-store.ts"); const { toast } = require("sonner"); @@ -413,12 +417,13 @@ test("combined request editing preserves body newlines and splits only the first test("traffic import uses captured metadata, preserves Host overrides and the full body", () => { const flow = { method: "POST", url: "https://example.test:8443/upload?q=1", host: "example.test" }; const raw = "POST /upload?q=1 HTTP/1.1\r\nHost: override.test\r\nCookie: a=b\r\n\r\nfirst\r\n\r\nlast\n"; - assert.deepEqual(requestFromTraffic(raw, flow), { - method: "POST", - url: flow.url, - headersText: "Host: override.test\r\nCookie: a=b", - body: "first\r\n\r\nlast\n", - }); + const imported = requestFromTraffic(raw, flow); + assert.equal(imported.method, "POST"); + assert.equal(imported.url, flow.url); + assert.equal(imported.headersText, "Host: override.test\r\nCookie: a=b"); + assert.equal(imported.body, "first\r\n\r\nlast\n"); + assert.match(imported.rawRequest, /^POST \/upload\?q=1 HTTP\/1.1\n/); + assert.equal(imported.targetOrigin, "https://example.test:8443"); assert.equal( requestFromTraffic("POST /upload HTTP/1.1\nContent-Type: text/plain\n\nhello", flow).headersText, "Host: example.test:8443\nContent-Type: text/plain", @@ -437,3 +442,143 @@ test("traffic import rejects failed loads and blob preview markers rather than s /截断预览/, ); }); + +test("global replay filters combine title, request, response, method, path and status", () => { + const tab = { + ...defaultWorkspace().tabs[0], + title: "上传测试", + method: "POST", + url: "https://example.test/api/upload?q=1", + headersText: "X-Test: RequestSecret", + body: "request-body", + response: response("ResponseSecret"), + }; + const match = (patch) => matchesRepeaterFilters(tab, { ...DEFAULT_FILTERS, ...patch }); + assert.equal(match({ query: "上传" }), true); + assert.equal(match({ query: "requestsecret" }), true); + assert.equal(match({ query: "responseSECRET" }), true); + assert.equal(match({ method: "POST", path: "/api/upload", response: "ResponseSecret", status: "2xx" }), true); + assert.equal(match({ method: "GET" }), false); + assert.equal(match({ path: "example.test" }), false); + assert.equal(match({ status: "4xx" }), false); + assert.equal(match({ response: "missing" }), false); + assert.equal(match({ status: "pending" }), false); + assert.equal(matchesRepeaterFilters({ ...tab, response: null }, { ...DEFAULT_FILTERS, status: "pending" }), true); + assert.equal( + matchesRepeaterFilters( + { ...tab, response: { ...response(), status: 0, error: "connection refused" } }, + { ...DEFAULT_FILTERS, status: "error", response: "refused" }, + ), + true, + ); +}); + +test("bulk move preserves draft, response and inflight requests and persists the whole selection atomically", (t) => { + const { store, writes } = setupStore(t); + const [first, other] = store.workspace.tabs; + store.recordSend(first.id, entry(1, "original response")); + store.createGroup("目标组"); + const groupId = useRepeaterStore.getState().workspace.groups[1].id; + store.toggleGroupCollapsed(groupId); + const generation = store.beginSend(first.id); + const beforeWrites = writes.length; + store.setTabsGroup([first.id, other.id], groupId); + const state = useRepeaterStore.getState(); + assert.equal( + state.workspace.tabs.every((tab) => tab.groupId === groupId), + true, + ); + assert.equal(state.workspace.tabs[0].response.body, "original response"); + assert.equal(state.workspace.activeTabId, other.id); + assert.equal(state.workspace.groups[1].collapsed, false); + assert.equal(store.isCurrentSend(first.id, generation), true); + assert.equal(writes.length, beforeWrites + 1); + store.setTabsGroup([first.id], "missing-group"); + assert.equal(useRepeaterStore.getState().workspace.tabs[0].groupId, groupId); +}); + +test("bulk delete invalidates only removed requests and retains an editable tab after deleting everything", (t) => { + const { store } = setupStore(t); + const [first, other] = store.workspace.tabs; + const thirdId = store.createTab({ body: "keep me" }); + const firstGeneration = store.beginSend(first.id); + const otherGeneration = store.beginSend(other.id); + const thirdGeneration = store.beginSend(thirdId); + store.setActiveTab(first.id); + store.closeTabs([first.id, other.id]); + store.completeSend(first.id, firstGeneration, entry(1, "late")); + store.completeSend(other.id, otherGeneration, entry(2, "late")); + let state = useRepeaterStore.getState(); + assert.deepEqual( + state.workspace.tabs.map((tab) => tab.id), + [thirdId], + ); + assert.equal(state.workspace.activeTabId, thirdId); + assert.equal(state.workspace.tabs[0].body, "keep me"); + assert.deepEqual(state.inflight, { [thirdId]: thirdGeneration }); + store.closeTabs([thirdId]); + state = useRepeaterStore.getState(); + assert.equal(state.workspace.tabs.length, 1); + assert.notEqual(state.workspace.tabs[0].id, thirdId); + assert.equal(state.workspace.tabs[0].body, ""); + assert.equal(state.workspace.activeTabId, state.workspace.tabs[0].id); + assert.deepEqual(state.inflight, {}); +}); + +test("raw HTTP editing keeps the first line, method, path, protocol, Host and body together", () => { + const tab = { + ...defaultWorkspace().tabs[0], + method: "POST", + url: "https://example.test:8443/api/upload?q=1", + headersText: "Host: virtual.test\nContent-Type: text/plain", + body: "first\n\nlast", + }; + const raw = buildRawRequest(tab); + assert.equal(raw, "POST /api/upload?q=1 HTTP/1.1\nHost: virtual.test\nContent-Type: text/plain\n\nfirst\n\nlast"); + const parsed = parseRawRequest( + raw.replace("POST /api/upload?q=1 HTTP/1.1", "PATCH /api/edited?q=2 HTTP/2.0"), + "https://example.test:8443", + ); + assert.deepEqual(parsed, { + method: "PATCH", + url: "https://example.test:8443/api/edited?q=2", + httpVersion: "HTTP/2.0", + headersText: tab.headersText, + body: tab.body, + }); + assert.equal(parseRawRequest("GET /new HTTP/1.1\nHost: new.test\n\n").url, "https://new.test/new"); + assert.equal(parseRawRequest("GET /new HTTP/1.1\nHost: newer.test\n\n").url, "https://newer.test/new"); + assert.equal( + parseRawRequest("GET http://elsewhere.test/path HTTP/1.1\nHost: vhost.test\n\n", "https://old.test").url, + "http://elsewhere.test/path", + ); + assert.throws(() => parseRawRequest("POST /missing-version\nHost: test\n\n"), /首行/); + assert.equal(buildRawRequest({ ...tab, rawRequest: "unfinished HTTP draft" }), "unfinished HTTP draft"); +}); + +test("raw request drafts and connection origins survive persistence and history restoration", (t) => { + const { store } = setupStore(t); + const id = store.workspace.tabs[0].id; + const rawRequest = "POST /unfinished HTTP/1."; + store.updateTab(id, { rawRequest, targetOrigin: "http://example.test:8080" }); + const loaded = deserializeWorkspace(serializeWorkspace(useRepeaterStore.getState().workspace)); + assert.equal(loaded.tabs[0].rawRequest, rawRequest); + assert.equal(loaded.tabs[0].targetOrigin, "http://example.test:8080"); + store.recordSend(id, { + ...entry(1), + rawRequest: "POST /old HTTP/1.1\nHost: example.test\n\nbody", + targetOrigin: "https://example.test", + }); + assert.equal( + useRepeaterStore.getState().workspace.tabs[0].history[0].rawRequest.startsWith("POST /old HTTP/1.1"), + true, + ); +}); + +test("evidence import preserves captured request line and protocol rather than removing it", () => { + const initial = { method: "GET", url: "https://example.test/path", headersText: "Host: example.test", body: "" }; + const imported = requestFromEvidence(initial, "GET /path HTTP/2.0\r\nHost: example.test\r\n"); + assert.equal(imported.rawRequest, "GET /path HTTP/2.0\nHost: example.test\n\n"); + assert.equal(imported.targetOrigin, "https://example.test"); + assert.equal(buildRawRequest(imported), imported.rawRequest); +}); diff --git a/web/src/components/repeater/repeater-store.ts b/web/src/components/repeater/repeater-store.ts index a6001f0..45f17bf 100644 --- a/web/src/components/repeater/repeater-store.ts +++ b/web/src/components/repeater/repeater-store.ts @@ -24,7 +24,10 @@ import { } from "./repeater-state"; export type TabPatch = Partial< - Pick + Pick< + RepeaterTab, + "method" | "url" | "headersText" | "body" | "rawRequest" | "targetOrigin" | "useProxy" | "insecure" | "response" + > >; type Store = { workspace: RepeaterWorkspace | null; @@ -36,6 +39,8 @@ type Store = { hydrate: () => void; createTab: (seed?: TabSeed, opts?: TabOptions) => string; closeTab: (id: string) => void; + closeTabs: (ids: string[]) => void; + setTabsGroup: (ids: string[], groupId: string) => void; renameTab: (id: string, title: string) => void; setActiveTab: (id: string) => void; updateTab: (id: string, patch: TabPatch) => void; @@ -123,23 +128,26 @@ export const useRepeaterStore = create((set, get) => { change(workspace); return workspace.tabs[workspace.tabs.length - 1].id; }, - closeTab: (id) => { + closeTab: (id) => get().closeTabs([id]), + closeTabs: (ids) => { const workspace = current(); - const index = workspace.tabs.findIndex((tab) => tab.id === id); - if (index < 0) return; - const generation = get().inflight[id]; - if (generation !== undefined) get().clearInflight(id, generation); - const tabs = workspace.tabs.filter((tab) => tab.id !== id); + const removed = new Set(ids); + if (!workspace.tabs.some((tab) => removed.has(tab.id))) return; + const inflight = { ...get().inflight }; + for (const id of removed) delete inflight[id]; + set({ inflight }); + const tabs = workspace.tabs.filter((tab) => !removed.has(tab.id)); if (!tabs.length) tabs.push(emptyTab()); + const activeIndex = workspace.tabs.findIndex((tab) => tab.id === workspace.activeTabId); + const active = + tabs.find((tab) => tab.id === workspace.activeTabId) ?? + workspace.tabs.slice(activeIndex + 1).find((tab) => !removed.has(tab.id)) ?? + tabs[tabs.length - 1]; change({ ...workspace, tabs, - groups: workspace.groups.map((group) => - group.id === tabs[Math.min(index, tabs.length - 1)].groupId && workspace.activeTabId === id - ? { ...group, collapsed: false } - : group, - ), - activeTabId: workspace.activeTabId === id ? tabs[Math.min(index, tabs.length - 1)].id : workspace.activeTabId, + activeTabId: active.id, + groups: workspace.groups.map((group) => (group.id === active.groupId ? { ...group, collapsed: false } : group)), }); flush(); }, @@ -179,11 +187,20 @@ export const useRepeaterStore = create((set, get) => { groups: workspace.groups.map((group) => (group.id === id ? { ...group, name: name.trim() } : group)), }); }, - setTabGroup: (tabId, groupId) => { + setTabGroup: (tabId, groupId) => get().setTabsGroup([tabId], groupId), + setTabsGroup: (ids, groupId) => { const workspace = current(); if (!workspace.groups.some((group) => group.id === groupId)) return; - editTab(tabId, { groupId }); - if (workspace.activeTabId === tabId) get().setActiveTab(tabId); + const selected = new Set(ids); + if (!workspace.tabs.some((tab) => selected.has(tab.id))) return; + change({ + ...workspace, + tabs: workspace.tabs.map((tab) => + selected.has(tab.id) ? { ...tab, groupId, updatedAt: new Date().toISOString() } : tab, + ), + groups: workspace.groups.map((group) => (group.id === groupId ? { ...group, collapsed: false } : group)), + }); + flush(); }, toggleGroupCollapsed: (id) => { const workspace = current(); diff --git a/web/src/components/repeater/repeater-workspace.tsx b/web/src/components/repeater/repeater-workspace.tsx index 4e08528..8f6a1b0 100644 --- a/web/src/components/repeater/repeater-workspace.tsx +++ b/web/src/components/repeater/repeater-workspace.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import { ChevronDown, ChevronRight, FolderPlus, Pencil, Plus, X } from "lucide-react"; +import { FolderPlus } from "lucide-react"; import { AlertDialog, @@ -28,10 +28,9 @@ import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; import { RepeaterEditor } from "./repeater-editor"; -import { RepeaterHistory } from "./repeater-history"; +import { RepeaterNavigator } from "./repeater-navigator"; import { useRepeaterStore } from "./repeater-store"; export function RepeaterWorkspace() { @@ -86,89 +85,7 @@ export function RepeaterWorkspace() {
- +
{active ? ( <> diff --git a/web/src/components/traffic-evidence-viewer.tsx b/web/src/components/traffic-evidence-viewer.tsx index a8e9b79..f614f27 100644 --- a/web/src/components/traffic-evidence-viewer.tsx +++ b/web/src/components/traffic-evidence-viewer.tsx @@ -7,7 +7,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { HttpCodeBlock } from "@/components/http-code-block"; -import { headersFromReqHead } from "@/components/repeater/repeater-http"; +import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http"; import { seedFromEvidence } from "@/components/repeater/repeater-store"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -72,12 +72,15 @@ export function TrafficEvidenceViewer({ if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载"); else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载"); seedFromEvidence( - { - method: detail.binding.snapshot.method || "GET", - url: detail.binding.snapshot.url || "", - headersText: headersFromReqHead(detail.binding.snapshot.req_head), - body: detail.request.binary ? "" : detail.request.content || "", - }, + requestFromEvidence( + { + method: detail.binding.snapshot.method || "GET", + url: detail.binding.snapshot.url || "", + headersText: headersFromReqHead(detail.binding.snapshot.req_head), + body: detail.request.binary ? "" : detail.request.content || "", + }, + detail.binding.snapshot.req_head, + ), { findingId, bindingId: detail.binding.id }, ); router.push("/function/repeater"); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c77d3d6..be5a730 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1645,6 +1645,7 @@ export interface FindingTrafficDetail { export interface RepeaterSendRequest { method: string; + http_version?: string; url: string; headers?: Record; body?: string;