230 lines
7.7 KiB
Go
230 lines
7.7 KiB
Go
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()
|
||
}
|