From b495d58315d4a70db6ab57d3ead3bcff09612fd4 Mon Sep 17 00:00:00 2001 From: ARTEX Bot Date: Mon, 26 Oct 2026 10:48:19 +0800 Subject: [PATCH 1/5] feat(web): Burp-style sidebar Repeater workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /function/repeater with groups, tabs, rename/close, Host header shortcut, and localStorage persistence. Findings "送入/打开 Repeater" now seed a workspace tab and navigate; remove the old Dialog. --- web/src/app/(main)/function/repeater/page.tsx | 7 + .../components/finding-repeater-dialog.tsx | 336 ------------------ web/src/components/finding-traffic-panel.tsx | 43 ++- .../components/repeater/repeater-editor.tsx | 254 +++++++++++++ web/src/components/repeater/repeater-http.ts | 78 ++++ web/src/components/repeater/repeater-state.ts | 236 ++++++++++++ .../repeater/repeater-store.test.mjs | 175 +++++++++ web/src/components/repeater/repeater-store.ts | 168 +++++++++ .../repeater/repeater-workspace.tsx | 235 ++++++++++++ .../components/traffic-evidence-viewer.tsx | 163 +++++---- web/src/navigation/sidebar/sidebar-items.ts | 4 +- 11 files changed, 1257 insertions(+), 442 deletions(-) create mode 100644 web/src/app/(main)/function/repeater/page.tsx delete mode 100644 web/src/components/finding-repeater-dialog.tsx create mode 100644 web/src/components/repeater/repeater-editor.tsx create mode 100644 web/src/components/repeater/repeater-http.ts create mode 100644 web/src/components/repeater/repeater-state.ts create mode 100644 web/src/components/repeater/repeater-store.test.mjs create mode 100644 web/src/components/repeater/repeater-store.ts create mode 100644 web/src/components/repeater/repeater-workspace.tsx diff --git a/web/src/app/(main)/function/repeater/page.tsx b/web/src/app/(main)/function/repeater/page.tsx new file mode 100644 index 0000000..97867eb --- /dev/null +++ b/web/src/app/(main)/function/repeater/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { RepeaterWorkspace } from "@/components/repeater/repeater-workspace"; + +export default function RepeaterPage() { + return ; +} diff --git a/web/src/components/finding-repeater-dialog.tsx b/web/src/components/finding-repeater-dialog.tsx deleted file mode 100644 index 56078a6..0000000 --- a/web/src/components/finding-repeater-dialog.tsx +++ /dev/null @@ -1,336 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { SendIcon } from "lucide-react"; -import { toast } from "sonner"; - -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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; -import { api } from "@/lib/api"; -import type { RepeaterSendResponse } from "@/lib/types"; - -const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const; - -export type RepeaterInitial = { - method?: string; - url?: string; - headersText?: string; - body?: string; -}; - -type HistoryEntry = { - id: number; - method: string; - url: string; - headersText: string; - body: string; - useProxy: boolean; - insecure: boolean; - response: RepeaterSendResponse; -}; - -export function parseHeadersText(text: string): Record { - const result: Record = {}; - for (const line of text.replace(/\r\n?/g, "\n").split("\n")) { - const separator = line.indexOf(":"); - if (separator <= 0) continue; - const name = line.slice(0, separator).trim(); - const value = line.slice(separator + 1).trim(); - if (!name) continue; - // The API accepts one value per name. Cookies use semicolon separators; - // other duplicate headers preserve the common comma-separated semantics. - const existingName = Object.keys(result).find((item) => item.toLowerCase() === name.toLowerCase()); - if (!existingName) { - result[name] = value; - } else { - const separator = existingName.toLowerCase() === "cookie" ? "; " : ", "; - result[existingName] = `${result[existingName]}${separator}${value}`; - } - } - return result; -} - -export function formatHeaders(headers: Record | Record): string { - return Object.entries(headers) - .flatMap(([name, value]) => - Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`], - ) - .join("\n"); -} - -export function buildRawResponse(response: RepeaterSendResponse): string { - if (!response.status) return ""; - const statusLine = `${response.proto || "HTTP/1.1"} ${response.status_text || response.status}`.trim(); - const headers = formatHeaders(response.headers); - return `${statusLine}\n${headers}\n\n${response.body}`; -} - -export function headersFromReqHead(reqHead?: string): string { - if (!reqHead) return ""; - const lines = reqHead.replace(/\r\n?/g, "\n").split("\n"); - // Evidence heads normally start with `METHOD path HTTP/x.y`, but tolerate - // leading blank lines and CR-only captures from older traffic records. - let requestLine = -1; - for (let index = 0; index < lines.length; index += 1) { - const candidate = lines[index].trim(); - if (!candidate) continue; - if (/^(?:[A-Z]+)\s+\S+\s+HTTP\/\d(?:\.\d)?$/i.test(candidate)) requestLine = index; - break; - } - const start = requestLine >= 0 ? requestLine + 1 : 0; - return lines.slice(start).join("\n").replace(/\n+$/, ""); -} - -export function FindingRepeaterDialog({ - open, - onOpenChange, - initial, - findingLabel, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - initial?: RepeaterInitial; - findingLabel?: string; -}) { - const [method, setMethod] = React.useState("GET"); - const [url, setURL] = React.useState(""); - const [headersText, setHeadersText] = React.useState(""); - const [body, setBody] = React.useState(""); - const [useProxy, setUseProxy] = React.useState(true); - const [insecure, setInsecure] = React.useState(false); - const [busy, setBusy] = React.useState(false); - const [response, setResponse] = React.useState(null); - const [history, setHistory] = React.useState([]); - const sequence = React.useRef(0); - const generation = React.useRef(0); - const sending = React.useRef(false); - - React.useEffect(() => { - sending.current = false; - setBusy(false); - if (!open) return; - const nextMethod = (initial?.method ?? "GET").toUpperCase(); - setMethod(METHODS.includes(nextMethod as (typeof METHODS)[number]) ? nextMethod : "GET"); - setURL(initial?.url ?? ""); - setHeadersText(initial?.headersText ?? ""); - setBody(initial?.body ?? ""); - setUseProxy(true); - setInsecure(false); - setResponse(null); - return () => { - // Closing, reseeding, or unmounting makes any pending result obsolete. - generation.current += 1; - sending.current = false; - }; - }, [open, initial?.method, initial?.url, initial?.headersText, initial?.body]); - - async function send() { - // State alone cannot stop a second click before React disables the button. - if (sending.current || !open) return; - const target = url.trim(); - if (!target) { - toast.error("请填写 URL"); - return; - } - const requestGeneration = ++generation.current; - sending.current = true; - setBusy(true); - setResponse(null); - try { - const result = await api.repeaterSend({ - method, - url: target, - headers: parseHeadersText(headersText), - body, - use_recording_proxy: useProxy, - insecure_skip_verify: insecure, - }); - if (requestGeneration !== generation.current) return; - setResponse(result); - const id = ++sequence.current; - setHistory((previous) => - [ - { id, method, url: target, headersText, body, useProxy, insecure, response: result }, - ...previous, - ].slice(0, 20), - ); - } catch (error) { - if (requestGeneration === generation.current) toast.error((error as Error).message); - } finally { - if (requestGeneration === generation.current) { - sending.current = false; - setBusy(false); - } - } - } - - return ( - { - if (!nextOpen) { - // Invalidate immediately, before the parent's close render/effect. - generation.current += 1; - sending.current = false; - setBusy(false); - } - onOpenChange(nextOpen); - }} - > - - - Repeater · 请求重放 - - 手工编辑并发送 HTTP 请求,便于验证漏洞与撰写报告{findingLabel ? ` · ${findingLabel}` : ""} - - -
-
- - -
-
- - setURL(event.target.value)} - placeholder="https://target.example/path" - spellCheck={false} - /> -
- -
-
- - - {response ? ( -
- {response.status ? {response.status_text || response.status} : null} - {response.elapsed_ms} ms - {response.body_bytes.toLocaleString()} 字节 - {response.body_truncated ? 正文已截断 : null} -
- ) : null} -
-
-
-
会话历史
-
- {history.length === 0 ? ( -

发送后会出现在这里

- ) : ( - history.map((entry) => ( - - )) - )} -
-
-
-
请求
-
-
- -