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() }