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

341 lines
13 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 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
}