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