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

230 lines
7.7 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 (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/proxy"
"github.com/r3g1tpr0cs/burterm/internal/spider"
)
// spiderModel — вкладка краулера: два поля ввода (стартовый URL, глубина)
// и прокручиваемый лог найденных страниц/форм, пополняемый по мере
// поступления из потока spider.Page. Одновременно поддерживается только
// один активный обход — в отличие от Intruder-атак, тут это осознанное
// упрощение (краулинг одного сайта редко имеет смысл дублировать).
//
// В отличие от Intruder/Scanner, у краулинга нет заранее известного
// общего числа страниц (сайт не размечен, пока его не обойдёшь) — поэтому
// вместо N/Total здесь просто анимированный спиннер, честно говорящий
// "процесс идёт", а не выдуманный процент.
type spiderModel struct {
startURL textinput.Model
maxDepth textinput.Model
viewport viewport.Model
spinner spinner.Model
pages []spider.Page
count int
running bool
errMsg string
// sessionEntries — зеркало истории Proxy, обновляется тем же
// proxyEntryMsg, что и proxyView/targetView (см. model.go). Нужно
// только для авто-обнаружения Cookie/Authorization перед стартом
// обхода — сама вкладка их не показывает и не редактирует.
sessionEntries []proxy.Entry
sessionDetected bool // сессия была подхвачена автоматически при последнем запуске
focus int // 0 — startURL, 1 — maxDepth
}
func newSpiderView() spiderModel {
su := textinput.New()
su.Placeholder = "https://example.com"
su.CharLimit = 512
su.Focus()
md := textinput.New()
md.Placeholder = "3"
md.CharLimit = 3
sp := spinner.New()
sp.Spinner = spinner.Dot
return spiderModel{
startURL: su,
maxDepth: md,
viewport: viewport.New(0, 0),
spinner: sp,
}
}
func (m *spiderModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
m.viewport.Width = width
// 11 фиксированных строк (построчный подсчёт: "Стартовый URL"-метка+
// поле+разделитель (3), "Макс. глубина"-метка+поле+разделитель (3),
// строка статуса (1), разделитель перед viewport (1), разделитель+
// футер(две строки) (3) — итого 11). Раньше стояло -6 — недосчитано
// с самого начала, безотносительно разбиения футера.
vh := height - 11
if vh < 0 {
vh = 0
}
m.viewport.Height = vh
}
// AddPage дописывает найденную страницу (и её формы) в лог.
func (m *spiderModel) AddPage(p spider.Page) {
m.pages = append(m.pages, p)
m.count++
m.refresh()
m.viewport.GotoBottom()
}
// AddProxyEntry зеркалит перехваченный запрос из Proxy — используется
// только для последующего извлечения сессионных заголовков, на экране
// не отображается.
func (m *spiderModel) AddProxyEntry(e proxy.Entry) {
m.sessionEntries = append(m.sessionEntries, e)
}
func (m *spiderModel) Finish() {
m.running = false
}
func (m *spiderModel) refresh() {
var b strings.Builder
for i, p := range m.pages {
b.WriteString(fmt.Sprintf("#%-4d %-3d %s\n", i+1, p.Status, p.URL))
for _, f := range p.Forms {
fields := strings.Join(f.Fields, ", ")
b.WriteString(fmt.Sprintf(" форма %s %s (поля: %s)\n", f.Method, f.Action, fields))
}
}
m.viewport.SetContent(b.String())
}
func (m spiderModel) Update(msg tea.Msg) (spiderModel, 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.startURL.Focus()
m.maxDepth.Blur()
} else {
m.maxDepth.Focus()
m.startURL.Blur()
}
return m, nil
case "ctrl+r":
if m.running {
// Обход уже идёт — второй запуск проигнорирован, чтобы не
// плодить параллельные краулеры по одному и тому же scope
// с общим (нулевым) состоянием visited.
return m, nil
}
m.errMsg = ""
return m, m.startCmd()
}
}
var fieldCmd tea.Cmd
if m.focus == 0 {
m.startURL, fieldCmd = m.startURL.Update(msg)
} else {
m.maxDepth, fieldCmd = m.maxDepth.Update(msg)
}
var vpCmd tea.Cmd
m.viewport, vpCmd = m.viewport.Update(msg)
return m, tea.Batch(fieldCmd, vpCmd)
}
// startCmd валидирует URL и глубину, создаёт Spider (с авто-подхваченными
// сессионными заголовками под целевой хост, если такие нашлись в истории
// Proxy) и запускает обход. Возвращает spiderStartedMsg с каналом страниц
// и cancel'ом, либо spiderErrMsg с причиной отказа.
func (m spiderModel) startCmd() tea.Cmd {
rawURL := strings.TrimSpace(m.startURL.Value())
depthStr := m.maxDepth.Value()
sessionEntries := m.sessionEntries // срез читается только на чтение в горутине — без гонки
return func() tea.Msg {
if rawURL == "" {
return spiderErrMsg{fmt.Errorf("укажи стартовый URL")}
}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return spiderErrMsg{fmt.Errorf("нужен полный URL со схемой, например https://example.com")}
}
depth, _ := strconv.Atoi(depthStr)
if depth <= 0 {
depth = 3
}
sp := spider.New(u, depth)
sessionHeaders := proxy.ExtractSessionHeaders(sessionEntries, u.Host)
sessionDetected := len(sessionHeaders) > 0
if sessionDetected {
sp.SetHeaders(sessionHeaders)
}
ctx, cancel := context.WithCancel(context.Background())
ch := sp.Crawl(ctx, rawURL)
return spiderStartedMsg{ch: ch, cancel: cancel, sessionDetected: sessionDetected}
}
}
func (m spiderModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
status := "готов"
if m.running {
status = m.spinner.View() + " краулинг… (esc — остановить)"
if m.sessionDetected {
status += " · сессия подхвачена из Proxy History"
}
}
var b strings.Builder
b.WriteString(focusMark(0) + "Стартовый URL\n")
b.WriteString(m.startURL.View())
b.WriteString("\n")
b.WriteString(focusMark(1) + "Макс. глубина\n")
b.WriteString(m.maxDepth.View())
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("Статус: %s · страниц: %d\n", status, m.count))
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
b.WriteString("\n" + m.viewport.View())
b.WriteString("\n\ntab — между полями · ctrl+r — запустить · ctrl+c — выход\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}