269 lines
9.2 KiB
Go
269 lines
9.2 KiB
Go
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()
|
||
}
|