341 lines
13 KiB
Go
341 lines
13 KiB
Go
// Package project реализует персистентное хранение сессии burterm —
|
||
// перехваченные и отправленные запросы с полнотекстовым поиском и
|
||
// фильтрацией. Один файл SQLite = один проект, та же модель, что у
|
||
// файлов .burp: открыл, поработал, закрыл, файл можно скопировать
|
||
// или отправить коллеге.
|
||
//
|
||
// Первая внешняя зависимость проекта за пределами bubbletea-стека —
|
||
// modernc.org/sqlite (чистый Go, без cgo). См. комментарий в go.mod
|
||
// про то, что я не мог скачать/скомпилировать её в этом окружении.
|
||
package project
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
_ "modernc.org/sqlite"
|
||
|
||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||
)
|
||
|
||
// ColorLabel — пользовательская цветовая метка запроса, независимая от
|
||
// автоматической раскраски по HTTP-статусу в TUI (см. internal/tui/style.go).
|
||
// Пустая строка — метки нет.
|
||
type ColorLabel string
|
||
|
||
const (
|
||
ColorNone ColorLabel = ""
|
||
ColorRed ColorLabel = "red"
|
||
ColorOrange ColorLabel = "orange"
|
||
ColorYellow ColorLabel = "yellow"
|
||
ColorGreen ColorLabel = "green"
|
||
ColorCyan ColorLabel = "cyan"
|
||
ColorBlue ColorLabel = "blue"
|
||
ColorPurple ColorLabel = "purple"
|
||
)
|
||
|
||
// AllColorLabels — для циклического обзора в TUI (клавиша "c" на
|
||
// выбранной записи), в порядке цикла.
|
||
var AllColorLabels = []ColorLabel{
|
||
ColorNone, ColorRed, ColorOrange, ColorYellow, ColorGreen, ColorCyan, ColorBlue, ColorPurple,
|
||
}
|
||
|
||
// NextColor возвращает следующую метку по кругу — используется для
|
||
// циклического переключения по одной клавише вместо выбора из списка.
|
||
func NextColor(c ColorLabel) ColorLabel {
|
||
for i, v := range AllColorLabels {
|
||
if v == c {
|
||
return AllColorLabels[(i+1)%len(AllColorLabels)]
|
||
}
|
||
}
|
||
return AllColorLabels[0]
|
||
}
|
||
|
||
// Record — одна сохранённая транзакция запрос/ответ.
|
||
type Record struct {
|
||
ID int64
|
||
Source string // "proxy" | "repeater" | "manual" — откуда запись попала в проект
|
||
Host string
|
||
Method string
|
||
URL string
|
||
Status int
|
||
ContentLength int64
|
||
ColorLabel ColorLabel
|
||
RawRequest string
|
||
RawResponse string
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
// schema создаёт таблицу запросов, индексы под самые частые фильтры
|
||
// (host/method/status/цвет) и FTS5-индекс с триггерами синхронизации.
|
||
// "content='requests', content_rowid='id'" делает FTS5-таблицу внешней
|
||
// (external content) — сам текст хранится один раз в requests, а FTS5
|
||
// держит только инвертированный индекс, что экономит место на больших
|
||
// проектах по сравнению с обычной (дублирующей контент) FTS5-таблицей.
|
||
const schema = `
|
||
CREATE TABLE IF NOT EXISTS requests (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
source TEXT NOT NULL DEFAULT '',
|
||
host TEXT NOT NULL DEFAULT '',
|
||
method TEXT NOT NULL DEFAULT '',
|
||
url TEXT NOT NULL DEFAULT '',
|
||
status INTEGER NOT NULL DEFAULT 0,
|
||
content_length INTEGER NOT NULL DEFAULT 0,
|
||
color_label TEXT NOT NULL DEFAULT '',
|
||
raw_request TEXT NOT NULL DEFAULT '',
|
||
raw_response TEXT NOT NULL DEFAULT '',
|
||
created_at INTEGER NOT NULL,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_requests_host ON requests(host);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_method ON requests(method);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_color ON requests(color_label);
|
||
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS requests_fts USING fts5(
|
||
raw_request, raw_response,
|
||
content='requests', content_rowid='id'
|
||
);
|
||
|
||
CREATE TRIGGER IF NOT EXISTS requests_ai AFTER INSERT ON requests BEGIN
|
||
INSERT INTO requests_fts(rowid, raw_request, raw_response)
|
||
VALUES (new.id, new.raw_request, new.raw_response);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS requests_ad AFTER DELETE ON requests BEGIN
|
||
INSERT INTO requests_fts(requests_fts, rowid, raw_request, raw_response)
|
||
VALUES('delete', old.id, old.raw_request, old.raw_response);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS requests_au AFTER UPDATE ON requests BEGIN
|
||
INSERT INTO requests_fts(requests_fts, rowid, raw_request, raw_response)
|
||
VALUES('delete', old.id, old.raw_request, old.raw_response);
|
||
INSERT INTO requests_fts(rowid, raw_request, raw_response)
|
||
VALUES (new.id, new.raw_request, new.raw_response);
|
||
END;
|
||
|
||
CREATE TABLE IF NOT EXISTS meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
`
|
||
|
||
// Store — открытый файл проекта.
|
||
type Store struct {
|
||
db *sql.DB
|
||
Path string
|
||
}
|
||
|
||
// Open открывает (или создаёт, если не существует) файл проекта и
|
||
// применяет схему. MaxOpenConns намеренно зажат до 1: SQLite не любит
|
||
// конкурентных писателей (без явного включения WAL-режима это быстро
|
||
// кончается ошибками "database is locked"), а для личного инструмента
|
||
// с редкими записями последовательный доступ через один коннекшен —
|
||
// самый простой способ этого избежать, без необходимости объяснять
|
||
// пользователю про журналирование.
|
||
func Open(path string) (*Store, error) {
|
||
db, err := sql.Open("sqlite", path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("открытие %s: %w", path, err)
|
||
}
|
||
db.SetMaxOpenConns(1)
|
||
|
||
if err := db.Ping(); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("подключение к %s: %w", path, err)
|
||
}
|
||
if _, err := db.Exec(schema); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("применение схемы: %w", err)
|
||
}
|
||
return &Store{db: db, Path: path}, nil
|
||
}
|
||
|
||
// Close закрывает файл проекта.
|
||
func (s *Store) Close() error {
|
||
return s.db.Close()
|
||
}
|
||
|
||
// Insert сохраняет новую запись и возвращает её ID.
|
||
func (s *Store) Insert(r Record) (int64, error) {
|
||
now := time.Now().Unix()
|
||
res, err := s.db.Exec(
|
||
`INSERT INTO requests (source, host, method, url, status, content_length, color_label, raw_request, raw_response, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
r.Source, r.Host, r.Method, r.URL, r.Status, r.ContentLength, string(r.ColorLabel), r.RawRequest, r.RawResponse, now, now,
|
||
)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("вставка записи: %w", err)
|
||
}
|
||
return res.LastInsertId()
|
||
}
|
||
|
||
// SetColorLabel обновляет только цветовую метку записи.
|
||
func (s *Store) SetColorLabel(id int64, color ColorLabel) error {
|
||
_, err := s.db.Exec(`UPDATE requests SET color_label = ?, updated_at = ? WHERE id = ?`, string(color), time.Now().Unix(), id)
|
||
if err != nil {
|
||
return fmt.Errorf("обновление метки: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Delete удаляет запись из проекта.
|
||
func (s *Store) Delete(id int64) error {
|
||
if _, err := s.db.Exec(`DELETE FROM requests WHERE id = ?`, id); err != nil {
|
||
return fmt.Errorf("удаление записи: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SaveMeta сохраняет произвольную пару ключ/значение в таблицу meta —
|
||
// используется, в частности, для scope-правил вкладки Target, чтобы они
|
||
// переживали закрытие и повторное открытие проекта. UPSERT через
|
||
// ON CONFLICT, а не отдельные INSERT/UPDATE — меньше кода, та же цель.
|
||
func (s *Store) SaveMeta(key, value string) error {
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||
key, value,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("сохранение meta[%s]: %w", key, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LoadMeta возвращает значение по ключу и false, если ключа ещё нет —
|
||
// это не ошибка (новый проект просто ещё ничего не сохранял), поэтому
|
||
// отдельного sql.ErrNoRows наружу не выпускаем.
|
||
func (s *Store) LoadMeta(key string) (string, bool, error) {
|
||
var v string
|
||
err := s.db.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return "", false, nil
|
||
}
|
||
if err != nil {
|
||
return "", false, fmt.Errorf("чтение meta[%s]: %w", key, err)
|
||
}
|
||
return v, true, nil
|
||
}
|
||
|
||
// Count возвращает общее число записей в проекте.
|
||
func (s *Store) Count() (int, error) {
|
||
var n int
|
||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM requests`).Scan(&n); err != nil {
|
||
return 0, fmt.Errorf("подсчёт записей: %w", err)
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// Filter описывает критерии поиска. Пустое/нулевое поле — "не
|
||
// фильтровать по этому критерию". Status использует тот же синтаксис,
|
||
// что и engine.ParseStatusFilter ("200", "200,404", "200-299") — чтобы
|
||
// в приложении был один язык фильтров по статусу, а не два разных.
|
||
type Filter struct {
|
||
Host string // подстрока хоста
|
||
Method string // точное совпадение, регистронезависимо
|
||
Status string
|
||
MinLength int64
|
||
MaxLength int64 // 0 — без верхней границы
|
||
ColorLabel ColorLabel
|
||
FTSQuery string // полнотекстовый запрос по raw_request/raw_response
|
||
Limit, Offset int
|
||
}
|
||
|
||
// Search ищет записи по Filter. Host/Method/ColorLabel/длина и FTS-часть
|
||
// выполняются в самом SQL (используют индексы и FTS5), а Status —
|
||
// постфактум в Go через engine.ParseStatusFilter, чтобы переиспользовать
|
||
// уже проверенный парсер диапазонов/списков статусов вместо того, чтобы
|
||
// заново транслировать тот же синтаксис в SQL.
|
||
func (s *Store) Search(f Filter) ([]Record, error) {
|
||
cols := `r.id, r.source, r.host, r.method, r.url, r.status, r.content_length, r.color_label, r.raw_request, r.raw_response, r.created_at, r.updated_at`
|
||
|
||
var query string
|
||
var args []any
|
||
var where []string
|
||
|
||
if f.FTSQuery != "" {
|
||
query = `SELECT ` + cols + ` FROM requests_fts f JOIN requests r ON r.id = f.rowid`
|
||
where = append(where, `requests_fts MATCH ?`)
|
||
args = append(args, f.FTSQuery)
|
||
} else {
|
||
query = `SELECT ` + cols + ` FROM requests r`
|
||
}
|
||
|
||
if f.Host != "" {
|
||
where = append(where, `r.host LIKE ?`)
|
||
args = append(args, "%"+f.Host+"%")
|
||
}
|
||
if f.Method != "" {
|
||
where = append(where, `r.method = ? COLLATE NOCASE`)
|
||
args = append(args, f.Method)
|
||
}
|
||
if f.ColorLabel != "" {
|
||
where = append(where, `r.color_label = ?`)
|
||
args = append(args, string(f.ColorLabel))
|
||
}
|
||
if f.MinLength > 0 {
|
||
where = append(where, `r.content_length >= ?`)
|
||
args = append(args, f.MinLength)
|
||
}
|
||
if f.MaxLength > 0 {
|
||
where = append(where, `r.content_length <= ?`)
|
||
args = append(args, f.MaxLength)
|
||
}
|
||
|
||
if len(where) > 0 {
|
||
query += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
query += " ORDER BY r.id DESC"
|
||
|
||
limit := f.Limit
|
||
if limit <= 0 {
|
||
limit = 200
|
||
}
|
||
query += fmt.Sprintf(" LIMIT %d OFFSET %d", limit, f.Offset)
|
||
|
||
rows, err := s.db.Query(query, args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("поиск: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Record
|
||
for rows.Next() {
|
||
var r Record
|
||
var color string
|
||
var createdAt, updatedAt int64
|
||
if err := rows.Scan(&r.ID, &r.Source, &r.Host, &r.Method, &r.URL, &r.Status, &r.ContentLength, &color, &r.RawRequest, &r.RawResponse, &createdAt, &updatedAt); err != nil {
|
||
return nil, fmt.Errorf("чтение строки: %w", err)
|
||
}
|
||
r.ColorLabel = ColorLabel(color)
|
||
r.CreatedAt = time.Unix(createdAt, 0)
|
||
r.UpdatedAt = time.Unix(updatedAt, 0)
|
||
out = append(out, r)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("итерация результатов: %w", err)
|
||
}
|
||
|
||
if f.Status != "" {
|
||
pred, err := engine.ParseStatusFilter(f.Status)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("фильтр по статусу: %w", err)
|
||
}
|
||
if pred != nil {
|
||
filtered := out[:0]
|
||
for _, r := range out {
|
||
if pred(r.Status) {
|
||
filtered = append(filtered, r)
|
||
}
|
||
}
|
||
out = filtered
|
||
}
|
||
}
|
||
|
||
return out, nil
|
||
}
|