mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(web): Burp-style sidebar Repeater workspace
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.
This commit is contained in:
1 parent
c440c9d3a3
commit
b495d58315
11 files changed
+1257
-442
No files matched your search
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RepeaterWorkspace } from "@/components/repeater/repeater-workspace";
|
||||
|
||||
export default function RepeaterPage() {
|
||||
return <RepeaterWorkspace />;
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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<string, string[]> | Record<string, string>): 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<RepeaterSendResponse | null>(null);
|
||||
const [history, setHistory] = React.useState<HistoryEntry[]>([]);
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
// Invalidate immediately, before the parent's close render/effect.
|
||||
generation.current += 1;
|
||||
sending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[92vh] w-[min(1150px,96vw)] max-w-none flex-col gap-3 overflow-hidden sm:max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Repeater · 请求重放</DialogTitle>
|
||||
<DialogDescription>
|
||||
手工编辑并发送 HTTP 请求,便于验证漏洞与撰写报告{findingLabel ? ` · ${findingLabel}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="w-28">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">方法</Label>
|
||||
<Select value={method} onValueChange={setMethod} disabled={busy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{METHODS.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-[240px] flex-1">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">URL</Label>
|
||||
<Input
|
||||
disabled={busy}
|
||||
value={url}
|
||||
onChange={(event) => setURL(event.target.value)}
|
||||
placeholder="https://target.example/path"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={busy} onClick={() => void send()}>
|
||||
<SendIcon data-icon="inline-start" />
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<Label className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||||
<Checkbox
|
||||
id="repeater-use-proxy"
|
||||
disabled={busy}
|
||||
checked={useProxy}
|
||||
onCheckedChange={(value) => setUseProxy(value === true)}
|
||||
/>
|
||||
经流量代理
|
||||
</Label>
|
||||
<Label className="flex items-center gap-2" htmlFor="repeater-insecure">
|
||||
<Checkbox
|
||||
id="repeater-insecure"
|
||||
disabled={busy}
|
||||
checked={insecure}
|
||||
onCheckedChange={(value) => setInsecure(value === true)}
|
||||
/>
|
||||
跳过 TLS 校验
|
||||
</Label>
|
||||
{response ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
|
||||
<span>{response.elapsed_ms} ms</span>
|
||||
<span>{response.body_bytes.toLocaleString()} 字节</span>
|
||||
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[180px_1fr_1fr]">
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden rounded-md border p-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">会话历史</div>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">发送后会出现在这里</p>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMethod(entry.method);
|
||||
setURL(entry.url);
|
||||
setHeadersText(entry.headersText);
|
||||
setBody(entry.body);
|
||||
setUseProxy(entry.useProxy);
|
||||
setInsecure(entry.insecure);
|
||||
setResponse(entry.response);
|
||||
}}
|
||||
>
|
||||
<div className="truncate font-mono text-xs">
|
||||
{entry.method} {entry.response.status || "ERR"}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{entry.url}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="text-xs font-medium">请求</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">请求头</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
value={headersText}
|
||||
onChange={(event) => setHeadersText(event.target.value)}
|
||||
placeholder="Header: value"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">请求体</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder="请求正文(可选)"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="text-xs font-medium">响应</div>
|
||||
{response?.error ? (
|
||||
<Alert variant={response.status ? "default" : "destructive"}>
|
||||
<AlertDescription className="break-all font-mono text-xs">{response.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{response ? (
|
||||
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs">
|
||||
{buildRawResponse(response)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="p-3 text-xs text-muted-foreground">发送后显示原始响应</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
|
||||
import { headersFromReqHead } 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";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -50,8 +53,7 @@ export function FindingTrafficPanel({
|
||||
const [error, setError] = React.useState("");
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
|
||||
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial>();
|
||||
const router = useRouter();
|
||||
const [repeaterBusy, setRepeaterBusy] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
@@ -104,13 +106,16 @@ export function FindingTrafficPanel({
|
||||
const detail = await api.findingTrafficDetail(findingId, bindingId, contextTask);
|
||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
||||
setRepeaterInitial({
|
||||
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 || "",
|
||||
});
|
||||
setRepeaterOpen(true);
|
||||
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 || "",
|
||||
},
|
||||
{ findingId, bindingId },
|
||||
);
|
||||
router.push("/function/repeater");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
@@ -129,8 +134,8 @@ export function FindingTrafficPanel({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRepeaterInitial({ method: "GET", url: "", headersText: "", body: "" });
|
||||
setRepeaterOpen(true);
|
||||
seedFromEvidence({ method: "GET", url: "", headersText: "", body: "" });
|
||||
router.push("/function/repeater");
|
||||
}}
|
||||
>
|
||||
打开 Repeater
|
||||
@@ -177,18 +182,18 @@ export function FindingTrafficPanel({
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">证据 #{b.id}</span>
|
||||
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 text-left whitespace-normal"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="text-sm whitespace-pre-wrap">{b.note}</p> : null}
|
||||
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -267,12 +272,6 @@ export function FindingTrafficPanel({
|
||||
contextTask={contextTask}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={`发现 #${findingId}`}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"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 { Field, FieldGroup, 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 { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
import { buildRawResponse, getHostHeader, parseHeadersText, setHostHeader } from "./repeater-http";
|
||||
import { newId, type RepeaterTab } from "./repeater-state";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
||||
|
||||
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
const { method, url, headersText, body, useProxy, insecure, response, history } = tab;
|
||||
const updateTab = useRepeaterStore((state) => state.updateTab);
|
||||
const recordSend = useRepeaterStore((state) => state.recordSend);
|
||||
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 setBody = (body: string) => updateTab(tab.id, { body });
|
||||
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
|
||||
const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure });
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const generation = React.useRef(0);
|
||||
const sending = React.useRef(false);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
generation.current += 1;
|
||||
sending.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
async function send() {
|
||||
if (sending.current) return;
|
||||
const target = url.trim();
|
||||
if (!target) {
|
||||
toast.error("请填写 URL");
|
||||
return;
|
||||
}
|
||||
const requestGeneration = ++generation.current;
|
||||
sending.current = true;
|
||||
setBusy(true);
|
||||
updateTab(tab.id, { response: 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;
|
||||
recordSend(tab.id, {
|
||||
id: newId(),
|
||||
method,
|
||||
url: target,
|
||||
headersText,
|
||||
body,
|
||||
useProxy,
|
||||
insecure,
|
||||
response: result,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestGeneration === generation.current) toast.error((error as Error).message);
|
||||
} finally {
|
||||
if (requestGeneration === generation.current) {
|
||||
sending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
<FieldGroup className="flex-row flex-wrap items-end gap-2">
|
||||
<Field className="w-28">
|
||||
<FieldLabel htmlFor="repeater-method" className="mb-1 block text-muted-foreground text-xs">
|
||||
方法
|
||||
</FieldLabel>
|
||||
<Select value={method} onValueChange={setMethod} disabled={busy}>
|
||||
<SelectTrigger id="repeater-method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{[...new Set([...METHODS, method])].map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="min-w-[240px] flex-1">
|
||||
<FieldLabel htmlFor="repeater-url" className="mb-1 block text-muted-foreground text-xs">
|
||||
URL
|
||||
</FieldLabel>
|
||||
<Input
|
||||
disabled={busy}
|
||||
id="repeater-url"
|
||||
value={url}
|
||||
onChange={(event) => setURL(event.target.value)}
|
||||
placeholder="https://target.example/path"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="min-w-[180px] flex-1">
|
||||
<FieldLabel htmlFor="repeater-host" className="mb-1 block text-muted-foreground text-xs">
|
||||
Host 请求头
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="repeater-host"
|
||||
disabled={busy}
|
||||
value={getHostHeader(headersText)}
|
||||
onChange={(event) => setHeadersText(setHostHeader(headersText, event.target.value))}
|
||||
placeholder="可选,留空使用 URL 主机"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Button disabled={busy} onClick={() => void send()}>
|
||||
<SendIcon data-icon="inline-start" />
|
||||
{busy ? "发送中…" : "发送"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
|
||||
<FieldLegend className="sr-only">发送选项</FieldLegend>
|
||||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||||
<Checkbox
|
||||
id="repeater-use-proxy"
|
||||
disabled={busy}
|
||||
checked={useProxy}
|
||||
onCheckedChange={(value) => setUseProxy(value === true)}
|
||||
/>
|
||||
经流量代理
|
||||
</FieldLabel>
|
||||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-insecure">
|
||||
<Checkbox
|
||||
id="repeater-insecure"
|
||||
disabled={busy}
|
||||
checked={insecure}
|
||||
onCheckedChange={(value) => setInsecure(value === true)}
|
||||
/>
|
||||
跳过 TLS 校验
|
||||
</FieldLabel>
|
||||
{response ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
|
||||
<span>{response.elapsed_ms} ms</span>
|
||||
<span>{response.body_bytes.toLocaleString()} 字节</span>
|
||||
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</FieldSet>
|
||||
<div className="grid min-h-0 flex-1 auto-rows-max gap-3 overflow-y-auto xl:auto-rows-auto xl:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden rounded-md border p-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">标签历史</div>
|
||||
<div className="flex max-h-40 min-h-0 flex-1 flex-col gap-1 overflow-y-auto xl:max-h-none">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">发送后会出现在这里</p>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
onClick={() => {
|
||||
updateTab(tab.id, {
|
||||
method: entry.method,
|
||||
url: entry.url,
|
||||
headersText: entry.headersText,
|
||||
body: entry.body,
|
||||
useProxy: entry.useProxy,
|
||||
insecure: entry.insecure,
|
||||
response: entry.response,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="truncate font-mono text-xs">
|
||||
{entry.method} {entry.response.status || "ERR"}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{entry.url}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[300px] flex-col gap-2 overflow-hidden xl:min-h-0">
|
||||
<div className="font-medium text-xs">请求</div>
|
||||
<FieldGroup className="min-h-0 flex-1 gap-2">
|
||||
<Field className="min-h-0 flex-1 gap-1">
|
||||
<FieldLabel htmlFor="repeater-headers" className="text-muted-foreground text-xs">
|
||||
请求头
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
id="repeater-headers"
|
||||
value={headersText}
|
||||
onChange={(event) => setHeadersText(event.target.value)}
|
||||
placeholder="Header: value"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="min-h-0 flex-1 gap-1">
|
||||
<FieldLabel htmlFor="repeater-body" className="text-muted-foreground text-xs">
|
||||
请求体
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
id="repeater-body"
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder="请求正文(可选)"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div className="flex min-h-[300px] flex-col gap-2 overflow-hidden xl:min-h-0">
|
||||
<div className="font-medium text-xs">响应</div>
|
||||
{response?.error ? (
|
||||
<Alert variant={response.status ? "default" : "destructive"}>
|
||||
<AlertDescription className="break-all font-mono text-xs">{response.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="min-h-[240px] flex-1 overflow-auto rounded-md border">
|
||||
{response ? (
|
||||
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs">{buildRawResponse(response)}</pre>
|
||||
) : (
|
||||
<p className="p-3 text-muted-foreground text-xs">发送后显示原始响应</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { RepeaterSendResponse } from "@/lib/types";
|
||||
|
||||
export type RepeaterInitial = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headersText?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export function parseHeadersText(text: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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<string, string[]> | Record<string, string>): 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 getHostHeader(headersText: string): string {
|
||||
const headers = parseHeadersText(headersText);
|
||||
const name = Object.keys(headers).find((key) => key.toLowerCase() === "host");
|
||||
return name ? headers[name] : "";
|
||||
}
|
||||
|
||||
export function setHostHeader(headersText: string, host: string): string {
|
||||
const lines = headersText.replace(/\r\n?/g, "\n").split("\n");
|
||||
const first = lines.findIndex((line) => /^\s*host\s*:/i.test(line));
|
||||
const value = host.replace(/[\r\n]/g, "").trim();
|
||||
const result = lines.flatMap((line, index) => {
|
||||
if (!/^\s*host\s*:/i.test(line)) return [line];
|
||||
return index === first && value ? [`Host: ${value}`] : [];
|
||||
});
|
||||
if (first < 0 && value) result.unshift(`Host: ${value}`);
|
||||
return result.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { RepeaterSendResponse } from "@/lib/types";
|
||||
|
||||
import type { RepeaterInitial } from "./repeater-http";
|
||||
|
||||
export const STORAGE_KEY = "artex_repeater_workspace_v1";
|
||||
export const MAX_HISTORY = 20;
|
||||
export const MAX_RESPONSE_CHARS = 256 * 1024;
|
||||
export const MAX_STORAGE_BYTES = 2.5 * 1024 * 1024;
|
||||
export const DEFAULT_GROUP_ID = "default";
|
||||
|
||||
export type RepeaterHistoryEntry = {
|
||||
id: string;
|
||||
method: string;
|
||||
url: string;
|
||||
headersText: string;
|
||||
body: string;
|
||||
useProxy: boolean;
|
||||
insecure: boolean;
|
||||
response: RepeaterSendResponse;
|
||||
at: string;
|
||||
};
|
||||
export type RepeaterTab = Omit<RepeaterHistoryEntry, "at" | "response"> & {
|
||||
title: string;
|
||||
groupId: string;
|
||||
response: RepeaterSendResponse | null;
|
||||
history: RepeaterHistoryEntry[];
|
||||
source?: { findingId?: string; bindingId?: string; label?: string };
|
||||
updatedAt: string;
|
||||
};
|
||||
export type RepeaterGroup = { id: string; name: string; collapsed?: boolean };
|
||||
export type RepeaterWorkspace = {
|
||||
version: 1;
|
||||
groups: RepeaterGroup[];
|
||||
tabs: RepeaterTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
export type TabSeed = Partial<
|
||||
Pick<RepeaterTab, "method" | "url" | "headersText" | "body" | "useProxy" | "insecure" | "source">
|
||||
>;
|
||||
export type TabOptions = { groupId?: string; title?: string; activate?: boolean };
|
||||
|
||||
export function newId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function emptyTab(groupId = DEFAULT_GROUP_ID, title = "Tab 1"): RepeaterTab {
|
||||
return {
|
||||
id: newId(),
|
||||
title,
|
||||
groupId,
|
||||
method: "GET",
|
||||
url: "",
|
||||
headersText: "",
|
||||
body: "",
|
||||
useProxy: true,
|
||||
insecure: false,
|
||||
response: null,
|
||||
history: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultWorkspace(): RepeaterWorkspace {
|
||||
const tab = emptyTab();
|
||||
return { version: 1, groups: [{ id: DEFAULT_GROUP_ID, name: "默认" }], tabs: [tab], activeTabId: tab.id };
|
||||
}
|
||||
|
||||
export function addTab(workspace: RepeaterWorkspace, seed: TabSeed = {}, opts: TabOptions = {}): RepeaterWorkspace {
|
||||
const groupId = workspace.groups.some((group) => group.id === opts.groupId) ? opts.groupId : DEFAULT_GROUP_ID;
|
||||
const definedSeed = Object.fromEntries(Object.entries(seed).filter(([, value]) => value !== undefined));
|
||||
const tab = { ...emptyTab(groupId, opts.title?.trim() || `Tab ${workspace.tabs.length + 1}`), ...definedSeed };
|
||||
return {
|
||||
...workspace,
|
||||
groups: workspace.groups.map((group) =>
|
||||
group.id === groupId && opts.activate !== false ? { ...group, collapsed: false } : group,
|
||||
),
|
||||
tabs: [...workspace.tabs, tab],
|
||||
activeTabId: opts.activate === false ? workspace.activeTabId : tab.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function seedWorkspace(
|
||||
workspace: RepeaterWorkspace,
|
||||
initial: RepeaterInitial,
|
||||
meta: RepeaterTab["source"] = {},
|
||||
opts: TabOptions = {},
|
||||
): RepeaterWorkspace {
|
||||
const method = initial.method?.trim().toUpperCase() || "GET";
|
||||
let host = "";
|
||||
try {
|
||||
host = new URL(initial.url ?? "").host;
|
||||
} catch {
|
||||
/* An empty/unfinished URL is a valid draft. */
|
||||
}
|
||||
return addTab(
|
||||
workspace,
|
||||
{ ...initial, method, source: meta },
|
||||
{ ...opts, title: opts.title ?? meta.label ?? `${method} ${host}`.trim() },
|
||||
);
|
||||
}
|
||||
|
||||
export function truncateResponse(response: RepeaterSendResponse, limit = MAX_RESPONSE_CHARS): RepeaterSendResponse {
|
||||
return {
|
||||
...response,
|
||||
body: response.body.slice(0, limit),
|
||||
body_truncated: response.body_truncated || response.body.length > limit,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeWorkspace(workspace: RepeaterWorkspace, maxBytes = MAX_STORAGE_BYTES): string {
|
||||
const saved: RepeaterWorkspace = {
|
||||
...workspace,
|
||||
tabs: workspace.tabs.map((tab) => ({
|
||||
...tab,
|
||||
response: tab.response ? truncateResponse(tab.response) : null,
|
||||
history: tab.history
|
||||
.slice(0, MAX_HISTORY)
|
||||
.map((entry) => ({ ...entry, response: truncateResponse(entry.response) })),
|
||||
})),
|
||||
};
|
||||
let json = JSON.stringify(saved);
|
||||
const size = () => new TextEncoder().encode(json).length;
|
||||
// Remove the oldest send globally, including its request snapshot, first.
|
||||
const oldest = saved.tabs
|
||||
.flatMap((tab) => tab.history.map((entry) => ({ tab, entry })))
|
||||
.sort((a, b) => a.entry.at.localeCompare(b.entry.at));
|
||||
for (const { tab, entry } of oldest) {
|
||||
if (size() <= maxBytes) return json;
|
||||
tab.history = tab.history.filter((item) => item !== entry);
|
||||
json = JSON.stringify(saved);
|
||||
}
|
||||
for (const tab of [...saved.tabs].sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))) {
|
||||
if (size() <= maxBytes) return json;
|
||||
if (tab.response) tab.response = truncateResponse(tab.response, 0);
|
||||
json = JSON.stringify(saved);
|
||||
}
|
||||
// Drafts are never silently discarded, even when drafts alone exceed the soft budget.
|
||||
return json;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
function isResponse(value: unknown): value is RepeaterSendResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
["status", "body_bytes", "elapsed_ms"].every((key) => typeof value[key] === "number") &&
|
||||
["status_text", "proto", "body", "url"].every((key) => typeof value[key] === "string") &&
|
||||
typeof value.body_truncated === "boolean" &&
|
||||
isRecord(value.headers) &&
|
||||
Object.values(value.headers).every(
|
||||
(items) => Array.isArray(items) && items.every((item) => typeof item === "string"),
|
||||
) &&
|
||||
(value.error === undefined || typeof value.error === "string")
|
||||
);
|
||||
}
|
||||
function isRequest(
|
||||
value: unknown,
|
||||
): value is Record<string, unknown> &
|
||||
Pick<RepeaterHistoryEntry, "id" | "method" | "url" | "headersText" | "body" | "useProxy" | "insecure"> {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
["id", "method", "url", "headersText", "body"].every((key) => typeof value[key] === "string") &&
|
||||
typeof value.useProxy === "boolean" &&
|
||||
typeof value.insecure === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
export function deserializeWorkspace(raw: string | null): RepeaterWorkspace {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw ?? "null");
|
||||
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.groups) || !Array.isArray(value.tabs))
|
||||
return defaultWorkspace();
|
||||
const groups: RepeaterGroup[] = [];
|
||||
for (const group of value.groups) {
|
||||
if (
|
||||
!isRecord(group) ||
|
||||
typeof group.id !== "string" ||
|
||||
!group.id ||
|
||||
typeof group.name !== "string" ||
|
||||
!group.name.trim() ||
|
||||
groups.some((item) => item.id === group.id)
|
||||
)
|
||||
return defaultWorkspace();
|
||||
groups.push({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
...(typeof group.collapsed === "boolean" ? { collapsed: group.collapsed } : {}),
|
||||
});
|
||||
}
|
||||
if (!groups.some((group) => group.id === DEFAULT_GROUP_ID)) groups.unshift({ id: DEFAULT_GROUP_ID, name: "默认" });
|
||||
const tabs: RepeaterTab[] = [];
|
||||
for (const tab of value.tabs) {
|
||||
if (
|
||||
!isRequest(tab) ||
|
||||
!isRecord(tab) ||
|
||||
!tab.id ||
|
||||
typeof tab.title !== "string" ||
|
||||
typeof tab.groupId !== "string" ||
|
||||
typeof tab.updatedAt !== "string" ||
|
||||
!Array.isArray(tab.history) ||
|
||||
tabs.some((item) => item.id === tab.id) ||
|
||||
(tab.response !== null && !isResponse(tab.response))
|
||||
)
|
||||
return defaultWorkspace();
|
||||
if (
|
||||
!tab.history.every(
|
||||
(entry) => isRequest(entry) && isRecord(entry) && typeof entry.at === "string" && isResponse(entry.response),
|
||||
)
|
||||
)
|
||||
return defaultWorkspace();
|
||||
const source = isRecord(tab.source)
|
||||
? {
|
||||
findingId: typeof tab.source.findingId === "string" ? tab.source.findingId : undefined,
|
||||
bindingId: typeof tab.source.bindingId === "string" ? tab.source.bindingId : undefined,
|
||||
label: typeof tab.source.label === "string" ? tab.source.label : undefined,
|
||||
}
|
||||
: undefined;
|
||||
tabs.push({
|
||||
...tab,
|
||||
source,
|
||||
groupId: groups.some((group) => group.id === tab.groupId) ? tab.groupId : DEFAULT_GROUP_ID,
|
||||
history: tab.history.slice(0, MAX_HISTORY),
|
||||
} as RepeaterTab);
|
||||
}
|
||||
if (!tabs.length) tabs.push(emptyTab());
|
||||
return {
|
||||
version: 1,
|
||||
groups,
|
||||
tabs,
|
||||
activeTabId: tabs.some((tab) => tab.id === value.activeTabId) ? (value.activeTabId as string) : tabs[0].id,
|
||||
};
|
||||
} catch {
|
||||
return defaultWorkspace();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
|
||||
// Keep the tests runnable on Node 20 as well as versions with native TS support.
|
||||
const require = createRequire(import.meta.url);
|
||||
require("ts-node").register({
|
||||
transpileOnly: true,
|
||||
compilerOptions: { module: "CommonJS", moduleResolution: "Node10" },
|
||||
});
|
||||
const {
|
||||
addTab,
|
||||
defaultWorkspace,
|
||||
deserializeWorkspace,
|
||||
MAX_HISTORY,
|
||||
MAX_RESPONSE_CHARS,
|
||||
MAX_STORAGE_BYTES,
|
||||
seedWorkspace,
|
||||
serializeWorkspace,
|
||||
truncateResponse,
|
||||
} = require("./repeater-state.ts");
|
||||
const {
|
||||
buildRawResponse,
|
||||
getHostHeader,
|
||||
headersFromReqHead,
|
||||
parseHeadersText,
|
||||
setHostHeader,
|
||||
} = require("./repeater-http.ts");
|
||||
|
||||
const response = (body = "ok") => ({
|
||||
status: 200,
|
||||
status_text: "200 OK",
|
||||
proto: "HTTP/1.1",
|
||||
headers: { "Content-Type": ["text/plain"] },
|
||||
body,
|
||||
body_truncated: false,
|
||||
body_bytes: body.length,
|
||||
elapsed_ms: 12,
|
||||
url: "https://example.test/",
|
||||
});
|
||||
const entry = (id, body = "ok") => ({
|
||||
id: String(id),
|
||||
method: "POST",
|
||||
url: "https://example.test/",
|
||||
headersText: "Host: example.test",
|
||||
body: "draft",
|
||||
useProxy: true,
|
||||
insecure: false,
|
||||
response: response(body),
|
||||
at: new Date(id * 1000).toISOString(),
|
||||
});
|
||||
|
||||
test("response body is capped at 256 Ki characters without mutating the live response", () => {
|
||||
const original = response("x".repeat(MAX_RESPONSE_CHARS + 1));
|
||||
const truncated = truncateResponse(original);
|
||||
assert.equal(truncated.body.length, MAX_RESPONSE_CHARS);
|
||||
assert.equal(truncated.body_truncated, true);
|
||||
assert.equal(truncated.body_bytes, MAX_RESPONSE_CHARS + 1);
|
||||
assert.equal(original.body.length, MAX_RESPONSE_CHARS + 1);
|
||||
assert.equal(original.body_truncated, false);
|
||||
assert.equal(truncateResponse(response()).body_truncated, false);
|
||||
});
|
||||
|
||||
test("persisted response bodies and histories are capped; live state is unchanged", () => {
|
||||
const workspace = defaultWorkspace();
|
||||
const tab = workspace.tabs[0];
|
||||
tab.response = response("x".repeat(MAX_RESPONSE_CHARS + 1));
|
||||
tab.history = Array.from({ length: 25 }, (_, index) => entry(25 - index));
|
||||
tab.history[0].response = tab.response;
|
||||
const saved = JSON.parse(serializeWorkspace(workspace));
|
||||
assert.equal(saved.tabs[0].history.length, MAX_HISTORY);
|
||||
assert.equal(saved.tabs[0].history[0].id, "25");
|
||||
assert.equal(saved.tabs[0].response.body.length, MAX_RESPONSE_CHARS);
|
||||
assert.equal(saved.tabs[0].history[0].response.body.length, MAX_RESPONSE_CHARS);
|
||||
assert.equal(tab.history.length, 25);
|
||||
});
|
||||
|
||||
test("serialize/deserialize preserves drafts, groups, active tab, source and response", () => {
|
||||
let workspace = defaultWorkspace();
|
||||
workspace.groups.push({ id: "other", name: "验证", collapsed: true });
|
||||
workspace = seedWorkspace(
|
||||
workspace,
|
||||
{ method: "post", url: "https://example.test/path", body: "payload", headersText: "Host: override" },
|
||||
{ findingId: "42", bindingId: "2", label: "proof" },
|
||||
{ groupId: "other" },
|
||||
);
|
||||
workspace.tabs[1].response = response();
|
||||
workspace.tabs[1].history = [entry(1)];
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(deserializeWorkspace(serializeWorkspace(workspace)))), workspace);
|
||||
});
|
||||
|
||||
test("missing, corrupt and malformed storage recover a default group and empty tab", () => {
|
||||
const malformed = defaultWorkspace();
|
||||
malformed.tabs[0].response = { body: "bad" };
|
||||
for (const raw of [
|
||||
null,
|
||||
"",
|
||||
"{",
|
||||
"null",
|
||||
"[]",
|
||||
'{"version":2}',
|
||||
'{"version":1,"groups":[],"tabs":[{}]}',
|
||||
JSON.stringify(malformed),
|
||||
]) {
|
||||
const recovered = deserializeWorkspace(raw);
|
||||
assert.equal(recovered.version, 1);
|
||||
assert.equal(recovered.groups[0].name, "默认");
|
||||
assert.equal(recovered.tabs.length, 1);
|
||||
assert.equal(recovered.tabs[0].title, "Tab 1");
|
||||
assert.equal(recovered.tabs[0].url, "");
|
||||
assert.equal(recovered.activeTabId, recovered.tabs[0].id);
|
||||
}
|
||||
});
|
||||
|
||||
test("seeding always creates a new active tab in default or specified group", () => {
|
||||
const initial = defaultWorkspace();
|
||||
const seeded = seedWorkspace(initial, { method: "post", url: "https://example.test:8443/path" });
|
||||
assert.equal(initial.tabs.length, 1);
|
||||
assert.equal(seeded.tabs.length, 2);
|
||||
assert.equal(seeded.tabs[1].groupId, "default");
|
||||
assert.equal(seeded.tabs[1].title, "POST example.test:8443");
|
||||
assert.equal(seeded.activeTabId, seeded.tabs[1].id);
|
||||
seeded.groups.push({ id: "custom", name: "验证", collapsed: true });
|
||||
const custom = seedWorkspace(seeded, {}, { label: "证据" }, { groupId: "custom" });
|
||||
assert.equal(custom.tabs[2].groupId, "custom");
|
||||
assert.equal(custom.tabs[2].title, "证据");
|
||||
assert.equal(custom.groups[1].collapsed, false);
|
||||
const background = addTab(custom, {}, { activate: false, groupId: "missing" });
|
||||
assert.equal(background.activeTabId, custom.activeTabId);
|
||||
assert.equal(background.tabs[3].groupId, "default");
|
||||
});
|
||||
|
||||
test("budget removes globally oldest history first then strips responses, preserving drafts", () => {
|
||||
const workspace = addTab(defaultWorkspace());
|
||||
workspace.tabs[0].history = [entry(3), entry(1)];
|
||||
workspace.tabs[1].history = [entry(4), entry(2)];
|
||||
const limit = Buffer.byteLength(serializeWorkspace(workspace)) - Buffer.byteLength(JSON.stringify(entry(1))) - 1;
|
||||
const saved = JSON.parse(serializeWorkspace(workspace, limit));
|
||||
assert.deepEqual(
|
||||
saved.tabs.map((tab) => tab.history.map((item) => item.id)),
|
||||
[["3"], ["4", "2"]],
|
||||
);
|
||||
workspace.tabs[0].response = response("中".repeat(MAX_RESPONSE_CHARS));
|
||||
workspace.tabs[1].response = response("x".repeat(MAX_RESPONSE_CHARS));
|
||||
workspace.tabs[0].body = "keep this request";
|
||||
const stripped = JSON.parse(serializeWorkspace(workspace, 3000));
|
||||
assert.equal(stripped.tabs[0].body, "keep this request");
|
||||
assert.equal(stripped.tabs[0].response.body, "");
|
||||
assert.equal(stripped.tabs[0].response.body_truncated, true);
|
||||
assert.equal(workspace.tabs[0].history.length, 2);
|
||||
let large = defaultWorkspace();
|
||||
for (let id = 0; id < 5; id++) large = addTab(large);
|
||||
for (const tab of large.tabs) {
|
||||
tab.response = response("中".repeat(MAX_RESPONSE_CHARS));
|
||||
tab.history = [entry(1, "中".repeat(MAX_RESPONSE_CHARS))];
|
||||
}
|
||||
assert.ok(Buffer.byteLength(serializeWorkspace(large)) <= MAX_STORAGE_BYTES);
|
||||
});
|
||||
|
||||
test("Host shortcut replaces case-insensitive duplicates and preserves other header lines", () => {
|
||||
const headers = "X-Test: one\r\nhOsT: old.test\r\nCookie: a=1\r\nHost: duplicate.test\r\nX-Test: two";
|
||||
const updated = setHostHeader(headers, "new.test:8080");
|
||||
assert.equal(updated, "X-Test: one\nHost: new.test:8080\nCookie: a=1\nX-Test: two");
|
||||
assert.equal(getHostHeader(updated), "new.test:8080");
|
||||
assert.equal(getHostHeader("HOST: manual.test"), "manual.test");
|
||||
assert.equal(setHostHeader(updated, ""), "X-Test: one\nCookie: a=1\nX-Test: two");
|
||||
assert.equal(setHostHeader("Accept: */*", "new.test"), "Host: new.test\nAccept: */*");
|
||||
assert.equal(getHostHeader(setHostHeader("", "evil\r\nHeader: injected")), "evilHeader: injected");
|
||||
assert.deepEqual(parseHeadersText(updated), { "X-Test": "one, two", Host: "new.test:8080", Cookie: "a=1" });
|
||||
});
|
||||
|
||||
test("migrated HTTP helpers retain raw request and response formatting", () => {
|
||||
assert.equal(headersFromReqHead("\r\nGET / HTTP/1.1\r\nHost: example.test\r\n"), "Host: example.test");
|
||||
assert.equal(buildRawResponse(response()), "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nok");
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
|
||||
import type { RepeaterInitial } from "./repeater-http";
|
||||
import {
|
||||
addTab,
|
||||
DEFAULT_GROUP_ID,
|
||||
defaultWorkspace,
|
||||
deserializeWorkspace,
|
||||
emptyTab,
|
||||
MAX_HISTORY,
|
||||
newId,
|
||||
type RepeaterHistoryEntry,
|
||||
type RepeaterTab,
|
||||
type RepeaterWorkspace,
|
||||
STORAGE_KEY,
|
||||
seedWorkspace,
|
||||
serializeWorkspace,
|
||||
type TabOptions,
|
||||
type TabSeed,
|
||||
} from "./repeater-state";
|
||||
|
||||
export type TabPatch = Partial<
|
||||
Pick<RepeaterTab, "method" | "url" | "headersText" | "body" | "useProxy" | "insecure" | "response">
|
||||
>;
|
||||
type Store = {
|
||||
workspace: RepeaterWorkspace | null;
|
||||
hydrate: () => void;
|
||||
createTab: (seed?: TabSeed, opts?: TabOptions) => string;
|
||||
closeTab: (id: string) => void;
|
||||
renameTab: (id: string, title: string) => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
updateTab: (id: string, patch: TabPatch) => void;
|
||||
recordSend: (id: string, entry: RepeaterHistoryEntry) => void;
|
||||
createGroup: (name?: string) => void;
|
||||
renameGroup: (id: string, name: string) => void;
|
||||
setTabGroup: (tabId: string, groupId: string) => void;
|
||||
toggleGroupCollapsed: (id: string) => void;
|
||||
clearWorkspace: () => void;
|
||||
seedFromEvidence: (initial: RepeaterInitial, meta?: RepeaterTab["source"], opts?: TabOptions) => string;
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
function flush() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
const workspace = useRepeaterStore.getState().workspace;
|
||||
if (workspace) setLocalStorageValue(STORAGE_KEY, serializeWorkspace(workspace));
|
||||
}
|
||||
|
||||
export const useRepeaterStore = create<Store>((set, get) => {
|
||||
function current() {
|
||||
get().hydrate();
|
||||
return get().workspace as RepeaterWorkspace;
|
||||
}
|
||||
function change(workspace: RepeaterWorkspace) {
|
||||
set({ workspace });
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, 300);
|
||||
}
|
||||
function editTab(id: string, patch: Partial<RepeaterTab>) {
|
||||
const workspace = current();
|
||||
if (!workspace.tabs.some((tab) => tab.id === id)) return;
|
||||
change({
|
||||
...workspace,
|
||||
tabs: workspace.tabs.map((tab) =>
|
||||
tab.id === id ? { ...tab, ...patch, updatedAt: new Date().toISOString() } : tab,
|
||||
),
|
||||
});
|
||||
}
|
||||
return {
|
||||
workspace: null,
|
||||
hydrate: () => {
|
||||
if (get().workspace || typeof window === "undefined") return;
|
||||
set({ workspace: deserializeWorkspace(getLocalStorageValue(STORAGE_KEY)) });
|
||||
window.addEventListener("pagehide", flush);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flush();
|
||||
});
|
||||
},
|
||||
createTab: (seed, opts) => {
|
||||
const workspace = addTab(current(), seed, opts);
|
||||
change(workspace);
|
||||
return workspace.tabs[workspace.tabs.length - 1].id;
|
||||
},
|
||||
closeTab: (id) => {
|
||||
const workspace = current();
|
||||
const index = workspace.tabs.findIndex((tab) => tab.id === id);
|
||||
if (index < 0) return;
|
||||
const tabs = workspace.tabs.filter((tab) => tab.id !== id);
|
||||
if (!tabs.length) tabs.push(emptyTab());
|
||||
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,
|
||||
});
|
||||
flush();
|
||||
},
|
||||
renameTab: (id, title) => {
|
||||
if (title.trim()) editTab(id, { title: title.trim() });
|
||||
},
|
||||
setActiveTab: (id) => {
|
||||
const workspace = current();
|
||||
const tab = workspace.tabs.find((item) => item.id === id);
|
||||
if (tab)
|
||||
change({
|
||||
...workspace,
|
||||
activeTabId: id,
|
||||
groups: workspace.groups.map((group) => (group.id === tab.groupId ? { ...group, collapsed: false } : group)),
|
||||
});
|
||||
},
|
||||
updateTab: editTab,
|
||||
recordSend: (id, entry) => {
|
||||
const tab = current().tabs.find((item) => item.id === id);
|
||||
if (tab) editTab(id, { response: entry.response, history: [entry, ...tab.history].slice(0, MAX_HISTORY) });
|
||||
},
|
||||
createGroup: (name) => {
|
||||
const workspace = current();
|
||||
change({
|
||||
...workspace,
|
||||
groups: [...workspace.groups, { id: newId(), name: name?.trim() || `分组 ${workspace.groups.length + 1}` }],
|
||||
});
|
||||
},
|
||||
renameGroup: (id, name) => {
|
||||
if (!name.trim()) return;
|
||||
const workspace = current();
|
||||
change({
|
||||
...workspace,
|
||||
groups: workspace.groups.map((group) => (group.id === id ? { ...group, name: name.trim() } : group)),
|
||||
});
|
||||
},
|
||||
setTabGroup: (tabId, groupId) => {
|
||||
const workspace = current();
|
||||
if (!workspace.groups.some((group) => group.id === groupId)) return;
|
||||
editTab(tabId, { groupId });
|
||||
if (workspace.activeTabId === tabId) get().setActiveTab(tabId);
|
||||
},
|
||||
toggleGroupCollapsed: (id) => {
|
||||
const workspace = current();
|
||||
change({
|
||||
...workspace,
|
||||
groups: workspace.groups.map((group) => (group.id === id ? { ...group, collapsed: !group.collapsed } : group)),
|
||||
});
|
||||
},
|
||||
clearWorkspace: () => {
|
||||
change(defaultWorkspace());
|
||||
flush();
|
||||
},
|
||||
seedFromEvidence: (initial, meta, opts) => {
|
||||
const workspace = seedWorkspace(current(), initial, meta, { groupId: DEFAULT_GROUP_ID, ...opts });
|
||||
change(workspace);
|
||||
flush();
|
||||
return workspace.tabs[workspace.tabs.length - 1].id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function seedFromEvidence(initial: RepeaterInitial, meta?: RepeaterTab["source"], opts?: TabOptions) {
|
||||
return useRepeaterStore.getState().seedFromEvidence(initial, meta, opts);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Pencil, Plus, X } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { useRepeaterStore } from "./repeater-store";
|
||||
|
||||
export function RepeaterWorkspace() {
|
||||
const store = useRepeaterStore();
|
||||
const { workspace, hydrate } = store;
|
||||
const [rename, setRename] = React.useState<{ kind: "tab" | "group"; id: string; name: string } | null>(null);
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
if (!workspace) return <Skeleton className="h-[70vh] w-full" />;
|
||||
const active = workspace.tabs.find((tab) => tab.id === workspace.activeTabId);
|
||||
|
||||
function saveName(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!rename?.name.trim()) return;
|
||||
if (rename.kind === "tab") store.renameTab(rename.id, rename.name);
|
||||
else store.renameGroup(rename.id, rename.name);
|
||||
setRename(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col gap-4 md:h-[calc(100dvh-8rem)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl">Repeater · 请求重放</h1>
|
||||
<p className="text-muted-foreground text-sm">编辑、发送并比较请求 · 草稿与近期响应保存在此浏览器</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => store.createGroup()}>
|
||||
<FolderPlus data-icon="inline-start" />
|
||||
新建分组
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
清空工作区
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清空 Repeater 工作区?</AlertDialogTitle>
|
||||
<AlertDialogDescription>所有分组、标签、请求草稿和历史响应都会被删除。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => store.clearWorkspace()}>
|
||||
确认清空
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
||||
<nav
|
||||
aria-label="Repeater 分组和标签"
|
||||
className="flex max-h-48 shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-2 lg:max-h-none lg:w-56"
|
||||
>
|
||||
{workspace.groups.map((group) => (
|
||||
<div key={group.id} className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`${group.collapsed ? "展开" : "折叠"}分组 ${group.name}`}
|
||||
onClick={() => store.toggleGroupCollapsed(group.id)}
|
||||
>
|
||||
{group.collapsed ? <ChevronRight /> : <ChevronDown />}
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-sm" title={group.name}>
|
||||
{group.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`重命名分组 ${group.name}`}
|
||||
onClick={() => setRename({ kind: "group", id: group.id, name: group.name })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`新建标签 · ${group.name}`}
|
||||
title="新建标签"
|
||||
onClick={() => store.createTab({}, { groupId: group.id })}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
{!group.collapsed
|
||||
? workspace.tabs
|
||||
.filter((tab) => tab.groupId === group.id)
|
||||
.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1 rounded-md",
|
||||
active?.id === tab.id && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-current={active?.id === tab.id ? "page" : undefined}
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
onClick={() => store.setActiveTab(tab.id)}
|
||||
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
title={`${tab.title}\n${tab.url}`}
|
||||
>
|
||||
<span className="truncate">{tab.title}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`重命名标签 ${tab.title}`}
|
||||
onClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`关闭标签 ${tab.title}`}
|
||||
onClick={() => store.closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
{active ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="min-w-0 flex-1 truncate font-medium text-sm" title={active.title}>
|
||||
{active.title}
|
||||
</h2>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<FieldLabel htmlFor="repeater-group">移入分组</FieldLabel>
|
||||
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
|
||||
<SelectTrigger id="repeater-group" className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workspace.groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<RepeaterEditor key={active.id} tab={active} />
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => store.createTab()}>新建标签</Button>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<Dialog
|
||||
open={rename !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRename(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{rename?.kind === "group" ? "重命名分组" : "重命名标签"}</DialogTitle>
|
||||
<DialogDescription>输入便于识别的名称。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={saveName} className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="repeater-name">名称</FieldLabel>
|
||||
<Input
|
||||
id="repeater-name"
|
||||
value={rename?.name ?? ""}
|
||||
onChange={(event) =>
|
||||
setRename((current) => (current ? { ...current, name: event.target.value } : current))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRename(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={!rename?.name.trim()}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { headersFromReqHead } 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";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -28,8 +31,7 @@ export function TrafficEvidenceViewer({
|
||||
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
|
||||
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial>();
|
||||
const router = useRouter();
|
||||
React.useEffect(() => {
|
||||
if (!bindingId) return;
|
||||
let active = true;
|
||||
@@ -69,90 +71,85 @@ export function TrafficEvidenceViewer({
|
||||
if (!detail) return;
|
||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
||||
setRepeaterInitial({
|
||||
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 || "",
|
||||
});
|
||||
setRepeaterOpen(true);
|
||||
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 || "",
|
||||
},
|
||||
{ findingId, bindingId: detail.binding.id },
|
||||
);
|
||||
router.push("/function/repeater");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 pe-8">
|
||||
<div>
|
||||
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{detail ? (
|
||||
<Button variant="outline" size="sm" onClick={openRepeater}>
|
||||
送入 Repeater
|
||||
</Button>
|
||||
) : null}
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 pe-8">
|
||||
<div>
|
||||
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void api
|
||||
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
|
||||
/>
|
||||
{detail[side].truncated && !detail[side].binary ? (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={bindingId ? `证据 #${bindingId}` : undefined}
|
||||
/>
|
||||
</>
|
||||
{detail ? (
|
||||
<Button variant="outline" size="sm" onClick={openRepeater}>
|
||||
送入 Repeater
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void api
|
||||
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
|
||||
/>
|
||||
{detail[side].truncated && !detail[side].binary ? (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import {
|
||||
Activity,
|
||||
Ban,
|
||||
BellRing,
|
||||
CalendarClock,
|
||||
Bot,
|
||||
Brain,
|
||||
Bug,
|
||||
CalendarClock,
|
||||
ClipboardList,
|
||||
FolderOpen,
|
||||
FolderSync,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Network,
|
||||
Plug,
|
||||
Radio,
|
||||
Repeat,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
@@ -73,6 +74,7 @@ export const sidebarItems: NavGroup[] = [
|
||||
{ id: "schedules", title: "计划任务", url: "/function/schedules", icon: CalendarClock },
|
||||
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
|
||||
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
|
||||
{ id: "repeater", title: "Repeater · 请求重放", url: "/function/repeater", icon: Repeat },
|
||||
{ id: "commands", title: "工具执行", url: "/function/commands", icon: Terminal },
|
||||
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
|
||||
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
|
||||
|
||||
Reference in new issue
Block a user