341 lines
11 KiB
Go
341 lines
11 KiB
Go
package tui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"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/sitemap"
|
||
)
|
||
|
||
// targetModel — вкладка Target/Site Map: поле ввода regex-правила scope,
|
||
// список уже добавленных правил (include/exclude) с возможностью удалить
|
||
// любое, и построенное из перехваченного трафика дерево путей. Дерево
|
||
// пересобирается сразу при добавлении/удалении правила и при каждой новой
|
||
// записи от Proxy — отдельной кнопки "обновить" не нужно.
|
||
type targetModel struct {
|
||
input textinput.Model
|
||
scope *sitemap.Scope
|
||
|
||
entries []proxy.Entry
|
||
tree *sitemap.Tree
|
||
treeVP viewport.Model
|
||
|
||
cursor int // индекс выбранного правила в scope.Rules()
|
||
focus int // 0 — поле ввода, 1 — список правил, 2 — дерево (скролл)
|
||
|
||
statusMsg string
|
||
errMsg string
|
||
}
|
||
|
||
func newTargetView() targetModel {
|
||
in := textinput.New()
|
||
in.Placeholder = `regex, напр. ^https://example\.com/api/.*`
|
||
in.CharLimit = 512
|
||
in.Focus()
|
||
|
||
return targetModel{
|
||
input: in,
|
||
scope: sitemap.NewScope(),
|
||
treeVP: viewport.New(0, 0),
|
||
}
|
||
}
|
||
|
||
func (m *targetModel) SetSize(width, height int) {
|
||
if width < 0 {
|
||
width = 0
|
||
}
|
||
if height < 0 {
|
||
height = 0
|
||
}
|
||
m.treeVP.Width = width
|
||
// 13 фиксированных строк вокруг treeVP (построчный подсчёт по View(),
|
||
// baseline без правил/ошибок: "Правило scope"-метка+поле+разделитель
|
||
// (3), "Правила (0)"-метка+"(пусто...)"-строка+разделитель (3),
|
||
// разделитель+"Карта сайта"-метка (2), разделитель+5 строк футера
|
||
// (подсказки клавиш разбиты на две строки, отдельно навигация,
|
||
// отдельно примечание про scope) (5) — итого 13. Раньше стояло -10 —
|
||
// недосчитано на 2 строки, плюс сам футер был одной длинной строкой
|
||
// (свыше 190 символов), которая непредсказуемо переносилась терминалом.
|
||
vh := height - 13
|
||
if vh < 3 {
|
||
vh = 3
|
||
}
|
||
m.treeVP.Height = vh
|
||
}
|
||
|
||
// AddEntry добавляет перехваченный запрос и сразу пересобирает дерево.
|
||
func (m *targetModel) AddEntry(e proxy.Entry) {
|
||
m.entries = append(m.entries, e)
|
||
m.rebuildTree()
|
||
}
|
||
|
||
func (m *targetModel) rebuildTree() {
|
||
records := make([]sitemap.Record, 0, len(m.entries))
|
||
for _, e := range m.entries {
|
||
errStr := ""
|
||
if e.Err != nil {
|
||
errStr = e.Err.Error()
|
||
}
|
||
records = append(records, sitemap.Record{
|
||
ID: e.ID,
|
||
Method: e.Method,
|
||
URL: e.URL,
|
||
StatusCode: e.StatusCode,
|
||
Err: errStr,
|
||
})
|
||
}
|
||
m.tree = sitemap.Build(records, m.scope)
|
||
m.refreshTreeView()
|
||
}
|
||
|
||
func (m *targetModel) refreshTreeView() {
|
||
if m.tree == nil || len(m.tree.Hosts) == 0 {
|
||
m.treeVP.SetContent("Карта пуста. Перехвати трафик через Proxy или дождись совпадений со scope.")
|
||
return
|
||
}
|
||
|
||
var b strings.Builder
|
||
hosts := make([]string, 0, len(m.tree.Hosts))
|
||
for h := range m.tree.Hosts {
|
||
hosts = append(hosts, h)
|
||
}
|
||
sort.Strings(hosts)
|
||
|
||
for _, h := range hosts {
|
||
b.WriteString(h + "/\n")
|
||
renderSitemapNode(&b, m.tree.Hosts[h], 1)
|
||
}
|
||
m.treeVP.SetContent(b.String())
|
||
}
|
||
|
||
// renderSitemapNode рекурсивно печатает дерево с отступами по глубине:
|
||
// сначала дочерние сегменты пути в алфавитном порядке, затем эндпоинты
|
||
// на самом узле.
|
||
func renderSitemapNode(b *strings.Builder, n *sitemap.Node, depth int) {
|
||
indent := strings.Repeat(" ", depth)
|
||
|
||
names := make([]string, 0, len(n.Children))
|
||
for name := range n.Children {
|
||
names = append(names, name)
|
||
}
|
||
sort.Strings(names)
|
||
|
||
for _, name := range names {
|
||
b.WriteString(indent + name + "/\n")
|
||
renderSitemapNode(b, n.Children[name], depth+1)
|
||
}
|
||
|
||
for _, ep := range n.Endpoints {
|
||
status := fmt.Sprintf("%d", ep.StatusCode)
|
||
if ep.Err != "" {
|
||
status = "ERR"
|
||
}
|
||
b.WriteString(fmt.Sprintf("%s [%s %s] %s\n", indent, ep.Method, status, ep.URL))
|
||
}
|
||
}
|
||
|
||
func (m targetModel) Update(msg tea.Msg) (targetModel, tea.Cmd) {
|
||
if key, ok := msg.(tea.KeyMsg); ok {
|
||
switch key.String() {
|
||
case "tab":
|
||
m.focus = (m.focus + 1) % 3
|
||
if m.focus == 0 {
|
||
m.input.Focus()
|
||
} else {
|
||
m.input.Blur()
|
||
}
|
||
return m, nil
|
||
|
||
case "enter":
|
||
if m.focus == 0 {
|
||
m.addRule(false)
|
||
return m, m.scopeChangedCmd()
|
||
}
|
||
|
||
case "ctrl+e":
|
||
if m.focus == 0 {
|
||
m.addRule(true)
|
||
return m, m.scopeChangedCmd()
|
||
}
|
||
|
||
case "ctrl+s":
|
||
m.export()
|
||
return m, nil
|
||
|
||
case "ctrl+p":
|
||
return m, m.saveScopeCmd()
|
||
|
||
case "up":
|
||
if m.focus == 1 && m.cursor > 0 {
|
||
m.cursor--
|
||
return m, nil
|
||
}
|
||
|
||
case "down":
|
||
if m.focus == 1 && m.cursor < len(m.scope.Rules())-1 {
|
||
m.cursor++
|
||
return m, nil
|
||
}
|
||
|
||
case "d":
|
||
if m.focus == 1 {
|
||
m.scope.RemoveAt(m.cursor)
|
||
if m.cursor > 0 {
|
||
m.cursor--
|
||
}
|
||
m.rebuildTree()
|
||
return m, m.scopeChangedCmd()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ключи, не перехваченные выше для текущего фокуса (например, "d" при
|
||
// вводе паттерна, или "up"/"down" вне списка правил), уходят как
|
||
// обычный ввод в активный компонент — это и есть провал сквозь свитч,
|
||
// а не двойная обработка.
|
||
var cmd tea.Cmd
|
||
switch m.focus {
|
||
case 0:
|
||
m.input, cmd = m.input.Update(msg)
|
||
case 2:
|
||
m.treeVP, cmd = m.treeVP.Update(msg)
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
// addRule берёт текст из поля ввода, компилирует как regex и добавляет
|
||
// в scope. Регулярка компилируется синхронно (это дёшево), без ухода
|
||
// в tea.Cmd — в отличие от сетевых операций в других вкладках, здесь
|
||
// блокировать TUI попросту нечем.
|
||
func (m *targetModel) addRule(exclude bool) {
|
||
pattern := strings.TrimSpace(m.input.Value())
|
||
if pattern == "" {
|
||
return
|
||
}
|
||
if err := m.scope.Add(pattern, exclude); err != nil {
|
||
m.errMsg = err.Error()
|
||
return
|
||
}
|
||
m.errMsg = ""
|
||
m.statusMsg = ""
|
||
m.input.SetValue("")
|
||
m.rebuildTree()
|
||
}
|
||
|
||
// scopeChangedCmd уведомляет корневую модель, что правила scope
|
||
// изменились — она пересоберёт независимый снапшот и передаст его
|
||
// в живой proxy.Server, чтобы "скип трафика вне scope" в Proxy сразу
|
||
// подхватывал актуальные правила, а не только то, что было на момент
|
||
// запуска TUI. Снимок правил делаем сразу (Rules() — дешёвая копия),
|
||
// а не откладываем на потом: к моменту, когда tea.Cmd выполнится,
|
||
// m.scope мог уже почитаться дальше пользователем.
|
||
func (m targetModel) scopeChangedCmd() tea.Cmd {
|
||
rules := m.scope.Rules()
|
||
return func() tea.Msg {
|
||
return scopeChangedMsg{rules: rules}
|
||
}
|
||
}
|
||
|
||
// saveScopeCmd (ctrl+p) сериализует текущие scope-правила в JSON и
|
||
// отправляет корневой модели — сама targetModel не держит ссылку на
|
||
// project.Store (та живёт только в Model), поэтому запись в БД делает
|
||
// корневой обработчик saveScopeMsg, а не эта функция напрямую.
|
||
// Сериализация здесь синхронная (это просто json.Marshal небольшого
|
||
// среза, не I/O) — в tea.Cmd уходит уже готовая строка.
|
||
func (m targetModel) saveScopeCmd() tea.Cmd {
|
||
data, err := json.Marshal(m.scope.Rules())
|
||
return func() tea.Msg {
|
||
if err != nil {
|
||
return projectErrMsg{fmt.Errorf("сериализация scope-правил: %w", err)}
|
||
}
|
||
return saveScopeMsg{json: string(data)}
|
||
}
|
||
}
|
||
|
||
// export пишет текущее дерево в ~/.burterm/sitemap-<unix-время>.json.
|
||
func (m *targetModel) export() {
|
||
if m.tree == nil || len(m.tree.Hosts) == 0 {
|
||
m.errMsg = "нечего экспортировать — карта пуста"
|
||
return
|
||
}
|
||
path, err := defaultSitemapExportPath()
|
||
if err != nil {
|
||
m.errMsg = err.Error()
|
||
return
|
||
}
|
||
if err := m.tree.ExportJSON(path); err != nil {
|
||
m.errMsg = err.Error()
|
||
return
|
||
}
|
||
m.errMsg = ""
|
||
m.statusMsg = "экспортировано в " + path
|
||
}
|
||
|
||
func defaultSitemapExportPath() (string, error) {
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
|
||
}
|
||
dir := filepath.Join(home, ".burterm")
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
return "", fmt.Errorf("не удалось создать %s: %w", dir, err)
|
||
}
|
||
return filepath.Join(dir, fmt.Sprintf("sitemap-%d.json", time.Now().Unix())), nil
|
||
}
|
||
|
||
func (m targetModel) View() string {
|
||
focusMark := func(i int) string {
|
||
if m.focus == i {
|
||
return "▸ "
|
||
}
|
||
return " "
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(focusMark(0) + "Правило scope (regex)\n")
|
||
b.WriteString(m.input.View())
|
||
b.WriteString("\n\n")
|
||
|
||
rules := m.scope.Rules()
|
||
b.WriteString(focusMark(1) + fmt.Sprintf("Правила (%d)\n", len(rules)))
|
||
if len(rules) == 0 {
|
||
b.WriteString(" (пусто — весь перехваченный трафик в scope)\n")
|
||
}
|
||
for i, r := range rules {
|
||
marker := " "
|
||
if m.focus == 1 && i == m.cursor {
|
||
marker = "▸ "
|
||
}
|
||
kind := "+"
|
||
if r.Exclude {
|
||
kind = "-"
|
||
}
|
||
b.WriteString(fmt.Sprintf("%s[%s] %s\n", marker, kind, r.Pattern))
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
if m.errMsg != "" {
|
||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||
}
|
||
if m.statusMsg != "" {
|
||
b.WriteString(m.statusMsg + "\n")
|
||
}
|
||
|
||
b.WriteString("\n" + focusMark(2) + "Карта сайта\n")
|
||
b.WriteString(m.treeVP.View())
|
||
b.WriteString("\n\ntab — фокус · enter — добавить include · ctrl+e — добавить exclude · d — удалить правило\n")
|
||
b.WriteString("ctrl+s — экспорт JSON · ctrl+p — сохранить scope в проект\n")
|
||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||
b.WriteString("Правила сразу применяются к Proxy: трафик вне scope больше не попадает в историю.\n")
|
||
return b.String()
|
||
}
|