burterm/internal/tui/repeater.go
2026-09-14 10:55:07 +03:00

269 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tui
import (
"crypto/tls"
"fmt"
"net/http"
"net/http/httputil"
neturl "net/url"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/comparer"
"github.com/r3g1tpr0cs/burterm/internal/engine"
"github.com/r3g1tpr0cs/burterm/internal/project"
)
// repeaterModel — аналог Burp Repeater: один редактируемый запрос,
// который можно поправить и отправить сколько угодно раз подряд,
// разглядывая сырой ответ. В отличие от Request Builder, здесь не
// нужны §маркеры§ — если они всё же остались после вставки запроса
// из Proxy, при отправке они просто схлопываются в пустые значения
// (см. sendCmd), а не участвуют в переборе.
type repeaterModel struct {
request textarea.Model
response viewport.Model
focus int // 0 — запрос, 1 — ответ
scheme string // "https" | "http" — BuildRequest по умолчанию всегда ставит https
insecure bool
sending bool
sentCount int
lastDuration time.Duration
lastStatus string
errMsg string
statusMsg string // результат последнего ctrl+p ("сохранено в проект" и т.п.)
lastSentRaw string // содержимое request на момент последнего ctrl+r — для звёздочки на вкладке и для ctrl+d diff
// lastResponseRaw/lastStatusCode — последний РЕАЛЬНЫЙ ответ сервера,
// отдельно от того, что сейчас показывает m.response.View(): ctrl+d
// временно подменяет содержимое viewport на diff, а сохранение
// в проект (ctrl+p) должно брать настоящий ответ, а не diff-текст.
lastResponseRaw string
lastStatusCode int
}
func newRepeater() repeaterModel {
ta := textarea.New()
ta.Placeholder = "GET /path HTTP/1.1\nHost: example.com\n\n"
ta.ShowLineNumbers = true
ta.Focus()
return repeaterModel{
request: ta,
response: viewport.New(0, 0),
scheme: "https",
}
}
// SetSize делит доступную высоту пополам между окном запроса и ответа.
func (m *repeaterModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
reqHeight := height / 2
m.request.SetWidth(width)
m.request.SetHeight(reqHeight)
m.response.Width = width
// 8 фиксированных строк вокруг response-viewport (построчный подсчёт
// по View(): "Запрос"-метка 1, разделитель 1, строка статуса 1,
// разделитель+"Ответ"-метка 2, разделитель+футер(две строки) 3 —
// итого 8). Раньше стояло -2 — критически недосчитано с самого
// начала, безотносительно разбиения футера.
m.response.Height = height - reqHeight - 8
}
func (m repeaterModel) Update(msg tea.Msg) (repeaterModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 2
if m.focus == 0 {
m.request.Focus()
} else {
m.request.Blur()
}
return m, nil
case "ctrl+h":
if m.scheme == "https" {
m.scheme = "http"
} else {
m.scheme = "https"
}
return m, nil
case "ctrl+t":
m.insecure = !m.insecure
return m, nil
case "ctrl+r":
m.errMsg = ""
m.sending = true
m.lastSentRaw = m.request.Value()
return m, m.sendCmd()
case "ctrl+d":
m.showDiff()
return m, nil
case "ctrl+p":
return m, m.saveToProjectCmd()
}
}
var cmd tea.Cmd
if m.focus == 0 {
m.request, cmd = m.request.Update(msg)
} else {
m.response, cmd = m.response.Update(msg)
}
return m, cmd
}
// showDiff (ctrl+d) сравнивает текущий черновик с последней реально
// отправленной версией через тот же токенный LCS-diff, что и на вкладке
// Comparer (renderDiffChunks там же, в comparerview.go — общий пакет tui,
// импорт не нужен). Результат временно занимает окно ответа; следующий
// реальный ответ (repeaterRespMsg) его естественным образом перезапишет,
// поэтому отдельного "режима" держать не нужно.
func (m *repeaterModel) showDiff() {
if m.lastSentRaw == "" {
m.errMsg = "ещё не было ни одной отправки — сравнивать не с чем"
return
}
chunks := comparer.Diff(m.lastSentRaw, m.request.Value())
m.response.SetContent("Diff: последняя отправка → текущий черновик ([-удалено-] / [+добавлено+])\n\n" + renderDiffChunks(chunks))
m.errMsg = ""
}
// IsModified — черновик отличается от последней реально отправленной
// версии? Используется корневой моделью для звёздочки в [f4].
func (m repeaterModel) IsModified() bool {
return m.request.Value() != m.lastSentRaw
}
// saveToProjectCmd (ctrl+p) сохраняет текущий запрос + последний реальный
// ответ в открытый проект. Method/URL достаются переиспользованием того
// же ParseMarkers+BuildRequest, что и sendCmd — вместо ручного парсинга
// сырого текста запроса с нуля.
func (m repeaterModel) saveToProjectCmd() tea.Cmd {
raw := m.request.Value()
respRaw := m.lastResponseRaw
statusCode := m.lastStatusCode
return func() tea.Msg {
method, urlStr := "", ""
if clean, points, err := engine.ParseMarkers(raw); err == nil {
payloads := make([]string, len(points))
if req, err2 := engine.BuildRequest(clean, points, payloads); err2 == nil {
method = req.Method
urlStr = req.URL.String()
}
}
host := ""
if u, err := neturl.Parse(urlStr); err == nil {
host = u.Host
}
rec := project.Record{
Source: "repeater",
Host: host,
Method: method,
URL: urlStr,
Status: statusCode,
ContentLength: int64(len(respRaw)),
RawRequest: raw,
RawResponse: respRaw,
}
return saveToProjectMsg{origin: viewRepeater, records: []project.Record{rec}}
}
}
// sendCmd отправляет запрос как есть, без фаззинга: ParseMarkers +
// BuildRequest с пустыми payload'ами переиспользуют тот же парсер сырого
// HTTP, что и Intruder-движок, просто без перебора значений.
func (m repeaterModel) sendCmd() tea.Cmd {
raw := m.request.Value()
scheme := m.scheme
insecure := m.insecure
return func() tea.Msg {
clean, points, err := engine.ParseMarkers(raw)
if err != nil {
return repeaterErrMsg{fmt.Errorf("маркеры §...§ (в Repeater не обязательны): %w", err)}
}
payloads := make([]string, len(points))
req, err := engine.BuildRequest(clean, points, payloads)
if err != nil {
return repeaterErrMsg{err}
}
req.URL.Scheme = scheme
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
},
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return repeaterErrMsg{err}
}
defer resp.Body.Close()
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
return repeaterErrMsg{fmt.Errorf("чтение ответа: %w", err)}
}
return repeaterRespMsg{
response: string(dump),
status: resp.Status,
statusCode: resp.StatusCode,
duration: time.Since(start),
}
}
}
func (m repeaterModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
status := fmt.Sprintf("Схема: %s (ctrl+h) · Insecure TLS: %v (ctrl+t) · отправлено: %d", m.scheme, m.insecure, m.sentCount)
if m.sending {
status += " · отправка…"
}
if m.lastStatus != "" {
status += fmt.Sprintf(" · последний ответ: %s за %s", m.lastStatus, m.lastDuration.Round(time.Millisecond))
}
var b strings.Builder
b.WriteString(focusMark(0) + "Запрос\n")
b.WriteString(m.request.View())
b.WriteString("\n\n")
b.WriteString(status + "\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
if m.statusMsg != "" {
b.WriteString(m.statusMsg + "\n")
}
b.WriteString("\n" + focusMark(1) + "Ответ\n")
b.WriteString(m.response.View())
b.WriteString("\n\ntab — между окнами · ctrl+r — отправить · ctrl+d — diff · ctrl+p — сохранить в проект\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}