20 Commits

Author SHA1 Message Date
Hadi 598455f8d3 Fix SQLite queue
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 15:05:46 +02:00
Hadi 28b070dafc Add flags to history
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:48:15 +02:00
Hadi 6f56e0b26a ui/home is now in the same app
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:34:48 +02:00
Hadi eaa960e6ab edit docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:08:59 +02:00
Hadi f874a70639 edit diff mode
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:01:09 +02:00
Hadi 4643989ab6 Add proxy auth
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:00:57 +02:00
Hadi 7bbc00880a feat: word-level diff highlighting in diff view
- tokenize() splits lines into word-char runs and single non-word bytes
- wordDiff() runs LCS on tokens and renders changed tokens with bold colors
- applyWordDiff() post-processes equal-size removed/added line blocks
- lcsAlignedDiff now stores plainText on removed/added lines for pairing
- Unchanged tokens rendered dim; removed tokens bold-red; added tokens bold-green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:39:13 +02:00
Hadi 385b6e84e0 feat: add GotoTop/Bottom/PrevPage/NextPage navigation keys
- New global keybindings: GotoTop (Home), GotoBottom (G/End), PrevPage ([), NextPage (])
- Wired in history, findings, and intercept update handlers
- Removes duplicate tea.Quit case in intercept/update.go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:39:01 +02:00
Hadi 6a9935ec27 feat: add HTTPie export format in copy-as
- New toHTTPie() function builds an httpie command from raw request
- Added "httpie" case in formatAs() switch
- Uses util.ParseRawRequest; model lists httpie as a selectable format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:50 +02:00
Hadi b490c7a0ac fix: use ParseRawRequest and cap response body in replay
- replay/update.go uses util.ParseRawRequest instead of inline parsing
- Response body capped with io.LimitReader at MaxBodySizeMB
- Uses util.SortedHeaderLines for deterministic header order
- Adds navigation key handling (GotoTop/Bottom/PrevPage/NextPage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:41 +02:00
Hadi 1a1c0cff30 refactor: centralize raw HTTP parsing and header serialization
- Add internal/util/rawhttp.go with ParseRawRequest and SortedHeaderLines
- Refactor intercept/format.go and ui/intercept/helpers.go to use them
- Eliminates duplicated bufio.Reader + textproto parsing spread across 3+ files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:30 +02:00
Hadi 172a77e13b fix: security hardening and code quality
- SQL query mode uses read-only SQLite connection with PRAGMA query_only=ON
- Lua sandbox removes dofile/loadfile/load after OpenBase to block file access
- Plugin manager sorts by priority once at load time; GetPlugins is a plain copy
- Proxy appends [body truncated] marker when body hits size limit
- App startup exits with os.Exit(1) on DB open failure
- tickCmd uses tea.Tick instead of time.Sleep in a goroutine
- ErrMsg with non-nil error shows notification then quits
- DB stores path for use by read-only query connection
- WAL journal mode + NORMAL synchronous set in migrate()
- config.go uses errors.Is(err, os.ErrNotExist)
- main.go uses os.UserHomeDir() and removes racy port pre-check
- findings renderer is cached and rebuilt only on width change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:10 +02:00
Hadi 41c0e489cf QOC
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:51:38 +02:00
Hadi 79128bb865 typo
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:51:27 +02:00
Hadi 48de2a8e10 add runtime version
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:34:35 +02:00
Hadi b4a45a23e5 Add "disable_by_default" flag for plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:18:16 +02:00
Hadi b5e2721aa1 Center lines for asciimoji+text
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:04:52 +02:00
Hadi 0cfba17d3d Edit the config "external_editor" to overwrite $EDITOR
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 10:13:36 +02:00
Hadi a147e8b972 QOL & Security improvement
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 10:09:42 +02:00
Hadi 03260e0947 Init copy as HAR
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 09:39:50 +02:00
48 changed files with 1096 additions and 501 deletions
+24 -28
View File
@@ -2,9 +2,9 @@ package main
import ( import (
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
spilltea "github.com/anotherhadi/spilltea" spilltea "github.com/anotherhadi/spilltea"
@@ -21,6 +21,15 @@ import (
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set. // Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
var version = "dev" var version = "dev"
func init() {
if version != "dev" {
return
}
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
}
func main() { func main() {
var ( var (
flagConfig = flag.StringP("config", "c", "", "path to config file") flagConfig = flag.StringP("config", "c", "", "path to config file")
@@ -46,7 +55,8 @@ func main() {
} }
if *flagAddDefaultPlugins { if *flagAddDefaultPlugins {
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" { if *flagConfig != "" {
cfgPath = *flagConfig cfgPath = *flagConfig
} }
@@ -68,7 +78,8 @@ func main() {
} }
if *flagAddDefaultConfig { if *flagAddDefaultConfig {
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" { if *flagConfig != "" {
cfgPath = *flagConfig cfgPath = *flagConfig
} }
@@ -85,7 +96,8 @@ func main() {
os.Exit(1) os.Exit(1)
} }
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" { if *flagConfig != "" {
cfgPath = *flagConfig cfgPath = *flagConfig
} }
@@ -109,47 +121,31 @@ func main() {
config.Global.App.UpstreamProxy = *flagUpstreamProxy config.Global.App.UpstreamProxy = *flagUpstreamProxy
} }
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
// Check if the proxy port is available before starting the UI.
ln, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintf(os.Stderr, "proxy: cannot bind to %s: %v\n", addr, err)
os.Exit(1)
}
ln.Close()
style.Init(config.Global) style.Init(config.Global)
icons.Init(config.Global) icons.Init(config.Global)
keys.Init(config.Global) keys.Init(config.Global)
projectDir := config.ExpandPath(config.Global.App.ProjectDir) projectDir := config.ExpandPath(config.Global.App.ProjectDir)
// Resolve project: either from --project flag or by running the home UI. // If --project flag is set, skip the home screen entirely.
var project *homeUI.Project
if *flagProject != "" { if *flagProject != "" {
p, err := homeUI.OpenProject(projectDir, *flagProject) project, err := homeUI.OpenProject(projectDir, *flagProject)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "project: %v\n", err) fmt.Fprintf(os.Stderr, "project: %v\n", err)
os.Exit(1) os.Exit(1)
} }
project = p broker := intercept.NewBroker()
} else { m := appUI.New(broker, project.Name, project.Path)
finalModel, err := tea.NewProgram(homeUI.New(projectDir)).Run() if _, err := tea.NewProgram(m).Run(); err != nil {
if err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err) fmt.Fprintf(os.Stderr, "tui: %v\n", err)
os.Exit(1) os.Exit(1)
} }
project = finalModel.(homeUI.Model).Selected()
}
// User quit the home screen without selecting a project.
if project == nil {
return return
} }
broker := intercept.NewBroker() // Run home + app in a single program to avoid a blank flash on transition.
m := appUI.New(broker, project.Name, project.Path) root := rootModel{home: homeUI.New(projectDir)}
if _, err := tea.NewProgram(m).Run(); err != nil { if _, err := tea.NewProgram(root).Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err) fmt.Fprintf(os.Stderr, "tui: %v\n", err)
os.Exit(1) os.Exit(1)
} }
+60
View File
@@ -0,0 +1,60 @@
package main
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/intercept"
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
)
type rootState int
const (
rootStateHome rootState = iota
rootStateApp
)
type rootModel struct {
state rootState
home homeUI.Model
app tea.Model
width int
height int
}
func (m rootModel) Init() tea.Cmd {
return m.home.Init()
}
func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
}
if m.state == rootStateHome {
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
broker := intercept.NewBroker()
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
m.app = app
m.state = rootStateApp
return m, tea.Batch(app.Init(), func() tea.Msg {
return tea.WindowSizeMsg{Width: m.width, Height: m.height}
})
}
updated, cmd := m.home.Update(msg)
m.home = updated.(homeUI.Model)
return m, cmd
}
updated, cmd := m.app.Update(msg)
m.app = updated
return m, cmd
}
func (m rootModel) View() tea.View {
if m.state == rootStateApp {
return m.app.(interface{ View() tea.View }).View()
}
return m.home.View()
}
+1 -1
View File
@@ -5,7 +5,7 @@
- On Chrome: - On Chrome:
- Open your Chrome settings, search for "Certificates" and click on "Security". - Open your Chrome settings, search for "Certificates" and click on "Security".
- In the security settings page, scroll down and click on "Manage certificates". - In the security settings page, scroll down and click on "Manage certificates".
- Select the "Authorities" tab and click on "Import tab and click on "Import". - Select the "Authorities" tab and click on "Import".
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`. - Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
- On Firefox: - On Firefox:
- Open your Firefox settings, search for "Certificates" and click on "View Certificates". - Open your Firefox settings, search for "Certificates" and click on "View Certificates".
+2 -6
View File
@@ -4,9 +4,7 @@ The History page has a built-in search bar with two modes:
**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies. **Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies.
**SQL mode**: press `:` to open it, then `Enter` to run. You can write either a WHERE expression or a full SELECT query against the `entries` table. **SQL mode**: press `:` to open it, then `Enter` to run. Type a WHERE expression: the full `SELECT … FROM entries WHERE` is added automatically.
WHERE expression (the `SELECT` is added automatically):
```sql ```sql
status_code = 404 status_code = 404
@@ -16,10 +14,8 @@ status_code = 404
host LIKE '%.api.%' AND method = 'POST' host LIKE '%.api.%' AND method = 'POST'
``` ```
Full SELECT query:
```sql ```sql
SELECT * FROM entries WHERE response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20 response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20
``` ```
The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`. The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`.
+16 -15
View File
@@ -15,9 +15,10 @@ Every plugin must declare a `Plugin` table and implement the hooks it wants to u
```lua ```lua
Plugin = { Plugin = {
name = "My Plugin", name = "My Plugin",
description = "What this plugin does.", description = "What this plugin does.",
priority = 0, -- higher = runs before other plugins (default: 0) priority = 0, -- higher = runs before other plugins (default: 0)
disable_by_default = true, -- if true, plugin starts disabled on first load (default: false)
-- Declare which hooks you use and whether they are synchronous (default: false). -- Declare which hooks you use and whether they are synchronous (default: false).
-- on_config and on_quit are always sync and do not need to be declared here. -- on_config and on_quit are always sync and do not need to be declared here.
@@ -30,14 +31,14 @@ Plugin = {
### Hook reference ### Hook reference
| Hook | When called | Sync/async | Return value (sync only) | | Hook | When called | Sync/async | Return value (sync only) |
| ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- | | ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
| `on_config(config_text)` | At startup and on config save | always sync | ignored | | `on_config(config_text)` | At startup and on config save | always sync | ignored |
| `on_start()` | Once at startup, after `on_config` | configurable | ignored | | `on_start()` | Once at startup, after `on_config` | configurable | ignored |
| `on_quit()` | When the app exits | always sync | ignored | | `on_quit()` | When the app exits | always sync | ignored |
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` | | `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) | | `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) |
## Request and response objects ## Request and response objects
@@ -140,10 +141,10 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
**`on_history_entry` (sync only):** **`on_history_entry` (sync only):**
| Return value | Effect | | Return value | Effect |
| ------------------- | -------------------------------------- | | ----------------- | --------------------------------- |
| `"skip"` | The entry is not saved to the DB. | | `"skip"` | The entry is not saved to the DB. |
| `"keep"` or `nil` | The entry is saved normally. | | `"keep"` or `nil` | The entry is saved normally. |
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it. Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
+2
View File
@@ -7,3 +7,5 @@ You can install it from the [Google Chrome extension store](https://chromewebsto
2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save". 2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save".
3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button. 3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button.
4. You're all set! You can now use Spilltea. 4. You're all set! You can now use Spilltea.
If `proxy_auth` is set in the config (`user:pass`), enter the same credentials in FoxyProxy under "Username" and "Password" in the proxy settings.
+11 -7
View File
@@ -2,6 +2,7 @@ package config
import ( import (
_ "embed" _ "embed"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -18,12 +19,15 @@ type Config struct {
Version string `mapstructure:"-"` Version string `mapstructure:"-"`
App struct { App struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
CertDir string `mapstructure:"cert_dir"` CertDir string `mapstructure:"cert_dir"`
ProjectDir string `mapstructure:"project_dir"` ProjectDir string `mapstructure:"project_dir"`
PluginsDir string `mapstructure:"plugins_dir"` PluginsDir string `mapstructure:"plugins_dir"`
UpstreamProxy string `mapstructure:"upstream_proxy"` UpstreamProxy string `mapstructure:"upstream_proxy"`
ProxyAuth string `mapstructure:"proxy_auth"`
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
ExternalEditor string `mapstructure:"external_editor"`
} `mapstructure:"app"` } `mapstructure:"app"`
TUI struct { TUI struct {
@@ -65,7 +69,7 @@ func Load(path string) error {
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.SetConfigFile(path) viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
if !os.IsNotExist(err) { if !errors.Is(err, os.ErrNotExist) {
return err return err
} }
} }
+8
View File
@@ -5,6 +5,9 @@ app:
project_dir: ~/.local/share/spilltea project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins plugins_dir: ~/.config/spilltea/plugins
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888 upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled)
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
intercept: intercept:
default_intercept_enabled: true default_intercept_enabled: true
@@ -58,6 +61,10 @@ keybindings:
scroll_up: "pgup" scroll_up: "pgup"
scroll_down: "pgdown" scroll_down: "pgdown"
send_to_diff: "ctrl+d" send_to_diff: "ctrl+d"
goto_top: "home"
goto_bottom: "G,end"
prev_page: "["
next_page: "]"
intercept: intercept:
forward: "f" forward: "f"
@@ -75,6 +82,7 @@ keybindings:
delete_all: "X" delete_all: "X"
sql_query: ":" sql_query: ":"
filter: "/" filter: "/"
flag: "m"
home: home:
open: "enter,l" open: "enter,l"
+5
View File
@@ -16,6 +16,10 @@ type GlobalKeys struct {
ScrollUp string `mapstructure:"scroll_up"` ScrollUp string `mapstructure:"scroll_up"`
ScrollDown string `mapstructure:"scroll_down"` ScrollDown string `mapstructure:"scroll_down"`
SendToDiff string `mapstructure:"send_to_diff"` SendToDiff string `mapstructure:"send_to_diff"`
GotoTop string `mapstructure:"goto_top"`
GotoBottom string `mapstructure:"goto_bottom"`
PrevPage string `mapstructure:"prev_page"`
NextPage string `mapstructure:"next_page"`
} }
type InterceptKeys struct { type InterceptKeys struct {
@@ -35,6 +39,7 @@ type HistoryKeys struct {
DeleteAll string `mapstructure:"delete_all"` DeleteAll string `mapstructure:"delete_all"`
Filter string `mapstructure:"filter"` Filter string `mapstructure:"filter"`
SqlQuery string `mapstructure:"sql_query"` SqlQuery string `mapstructure:"sql_query"`
Flag string `mapstructure:"flag"`
} }
type HomeKeys struct { type HomeKeys struct {
+19 -3
View File
@@ -2,12 +2,15 @@ package db
import ( import (
"database/sql" "database/sql"
"sync"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
type DB struct { type DB struct {
conn *sql.DB conn *sql.DB
path string
dedupMu sync.Mutex
} }
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
@@ -15,7 +18,11 @@ func Open(path string) (*DB, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
d := &DB{conn: conn} // SQLite only supports one concurrent writer; a pool of connections would
// cause SQLITE_BUSY errors when multiple proxy goroutines try to insert
// history entries at the same time.
conn.SetMaxOpenConns(1)
d := &DB{conn: conn, path: path}
if err := d.migrate(); err != nil { if err := d.migrate(); err != nil {
conn.Close() conn.Close()
return nil, err return nil, err
@@ -24,6 +31,9 @@ func Open(path string) (*DB, error) {
} }
func (d *DB) migrate() error { func (d *DB) migrate() error {
if _, err := d.conn.Exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;`); err != nil {
return err
}
_, err := d.conn.Exec(` _, err := d.conn.Exec(`
CREATE TABLE IF NOT EXISTS entries ( CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -33,7 +43,9 @@ func (d *DB) migrate() error {
path TEXT NOT NULL, path TEXT NOT NULL,
status_code INTEGER NOT NULL, status_code INTEGER NOT NULL,
request_raw TEXT NOT NULL, request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL response_raw TEXT NOT NULL,
body_hash TEXT NOT NULL DEFAULT '',
flagged INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS replay_entries ( CREATE TABLE IF NOT EXISTS replay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -65,6 +77,10 @@ CREATE TABLE IF NOT EXISTS replay_entries (
UNIQUE(plugin_name, dedup_key) UNIQUE(plugin_name, dedup_key)
); );
`) `)
if err != nil {
return err
}
_, err = d.conn.Exec(`CREATE INDEX IF NOT EXISTS idx_entries_dedup ON entries(method, host, path, body_hash)`)
return err return err
} }
+58 -40
View File
@@ -1,6 +1,7 @@
package db package db
import ( import (
"crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"strings" "strings"
@@ -16,42 +17,48 @@ type Entry struct {
StatusCode int StatusCode int
RequestRaw string RequestRaw string
ResponseRaw string ResponseRaw string
Flagged bool
}
func bodyHash(body string) string {
sum := sha256.Sum256([]byte(body))
return fmt.Sprintf("%x", sum)
} }
// HasDuplicate returns true if an entry with the same method, host, path and // HasDuplicate returns true if an entry with the same method, host, path and
// request body already exists. Used to implement skip_duplicates filtering. // request body hash already exists.
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) { func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
rows, err := d.conn.Query( hash := bodyHash(body)
`SELECT request_raw FROM entries WHERE method = ? AND host = ? AND path = ?`, var exists int
method, host, path, err := d.conn.QueryRow(
) `SELECT 1 FROM entries WHERE method = ? AND host = ? AND path = ? AND body_hash = ? LIMIT 1`,
if err != nil { method, host, path, hash,
return false, err ).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
} }
defer rows.Close() return err == nil, err
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return false, err
}
parts := strings.SplitN(raw, "\n\n", 2)
entryBody := ""
if len(parts) == 2 {
entryBody = parts[1]
}
if entryBody == body {
return true, nil
}
}
return false, rows.Err()
} }
func (d *DB) InsertEntry(e Entry) (Entry, error) { // InsertIfNotDuplicate atomically checks for a duplicate and inserts if none
// exists. Returns (entry, isDuplicate, error).
func (d *DB) InsertIfNotDuplicate(e Entry, body string) (Entry, bool, error) {
d.dedupMu.Lock()
defer d.dedupMu.Unlock()
dup, err := d.HasDuplicate(e.Method, e.Host, e.Path, body)
if err != nil || dup {
return e, dup, err
}
e, err = d.InsertEntry(e, body)
return e, false, err
}
func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
res, err := d.conn.Exec( res, err := d.conn.Exec(
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw) `INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw, body_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UTC().Format(time.RFC3339), e.Timestamp.UTC().Format(time.RFC3339),
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw, e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw, bodyHash(body),
) )
if err != nil { if err != nil {
return e, err return e, err
@@ -65,10 +72,12 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
for rows.Next() { for rows.Next() {
var e Entry var e Entry
var ts string var ts string
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw); err != nil { var flagged int
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
return nil, err return nil, err
} }
e.Timestamp, _ = time.Parse(time.RFC3339, ts) e.Timestamp, _ = time.Parse(time.RFC3339, ts)
e.Flagged = flagged != 0
entries = append(entries, e) entries = append(entries, e)
} }
return entries, rows.Err() return entries, rows.Err()
@@ -76,7 +85,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
func (d *DB) ListEntries() ([]Entry, error) { func (d *DB) ListEntries() ([]Entry, error) {
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw `SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
FROM entries ORDER BY id DESC`, FROM entries ORDER BY id DESC`,
) )
if err != nil { if err != nil {
@@ -89,7 +98,7 @@ func (d *DB) ListEntries() ([]Entry, error) {
func (d *DB) SearchEntries(term string) ([]Entry, error) { func (d *DB) SearchEntries(term string) ([]Entry, error) {
like := "%" + term + "%" like := "%" + term + "%"
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw `SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
FROM entries FROM entries
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ? WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
ORDER BY id DESC`, ORDER BY id DESC`,
@@ -102,17 +111,21 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
return scanEntries(rows) return scanEntries(rows)
} }
// QueryEntries executes a user-supplied query against the entries table. // QueryEntries runs a WHERE expression supplied by the user against the entries
// If the query does not start with SELECT, it is treated as a WHERE expression // table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
// and wrapped automatically (e.g. "status_code = 404" becomes a full SELECT). // It opens a dedicated read-only connection so that any DML or DDL in the
func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) { // user-supplied expression is rejected by SQLite before it can execute.
q := strings.TrimSpace(rawSQL) func (d *DB) QueryEntries(where string) ([]Entry, error) {
if !strings.HasPrefix(strings.ToUpper(q), "SELECT") { roConn, err := sql.Open("sqlite", d.path)
q = "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw FROM entries WHERE " + q if err != nil {
} else if strings.ContainsAny(strings.ToUpper(q), "INSERTDELETEUPDATEDROP") { return nil, err
return nil, fmt.Errorf("only SELECT queries are allowed")
} }
rows, err := d.conn.Query(q) defer roConn.Close()
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
return nil, err
}
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
rows, err := roConn.Query(q)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -120,6 +133,11 @@ func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
return scanEntries(rows) return scanEntries(rows)
} }
func (d *DB) ToggleFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE entries SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
func (d *DB) DeleteEntry(id int64) error { func (d *DB) DeleteEntry(id int64) error {
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id) _, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
return err return err
+2
View File
@@ -20,6 +20,7 @@ type Icons struct {
New string New string
Temp string Temp string
Project string Project string
Flag string
} }
var I *Icons var I *Icons
@@ -44,6 +45,7 @@ func Init(cfg *config.Config) {
New: "󰐕 ", New: "󰐕 ",
Temp: "󰙨 ", Temp: "󰙨 ",
Project: "󰉋 ", Project: "󰉋 ",
Flag: "󰈻 ",
} }
} else { } else {
I = &Icons{} I = &Icons{}
+31 -18
View File
@@ -1,6 +1,7 @@
package intercept package intercept
import ( import (
"log"
"regexp" "regexp"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -75,9 +76,12 @@ func (b *Broker) SetCaptureResponse(v bool) {
func (b *Broker) SetAutoForwardRegex(patterns []string) { func (b *Broker) SetAutoForwardRegex(patterns []string) {
compiled := make([]*regexp.Regexp, 0, len(patterns)) compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns { for _, p := range patterns {
if r, err := regexp.Compile(p); err == nil { r, err := regexp.Compile(p)
compiled = append(compiled, r) if err != nil {
log.Printf("intercept: invalid auto_forward_regex %q: %v", p, err)
continue
} }
compiled = append(compiled, r)
} }
b.autoFwdMu.Lock() b.autoFwdMu.Lock()
b.autoFwdRegexes = compiled b.autoFwdRegexes = compiled
@@ -164,19 +168,14 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
if path == "" { if path == "" {
path = "/" path = "/"
} }
if config.Global.History.SkipDuplicates { body := string(r.Body)
body := string(r.Body)
if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup {
return
}
}
pending := db.Entry{ pending := db.Entry{
Timestamp: time.Now(), Timestamp: time.Now(),
Method: r.Method, Method: r.Method,
Host: r.URL.Host, Host: r.URL.Host,
Path: path, Path: path,
StatusCode: status, StatusCode: status,
RequestRaw: FormatRawRequest(f), RequestRaw: FormatRawRequest(f),
ResponseRaw: func() string { ResponseRaw: func() string {
if config.Global.History.KeepResponses { if config.Global.History.KeepResponses {
return FormatRawResponse(f) return FormatRawResponse(f)
@@ -189,11 +188,25 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
return return
} }
} }
entry, err := d.InsertEntry(pending) var (
if err == nil { entry db.Entry
if cb := b.onNewEntry; cb != nil { err error
go cb(entry) )
if config.Global.History.SkipDuplicates {
var dup bool
entry, dup, err = d.InsertIfNotDuplicate(pending, body)
if dup {
return
} }
} else {
entry, err = d.InsertEntry(pending, body)
}
if err != nil {
log.Printf("intercept: failed to save history entry: %v", err)
return
}
if cb := b.onNewEntry; cb != nil {
go cb(entry)
} }
} }
+5 -19
View File
@@ -3,9 +3,9 @@ package intercept
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"sort"
"strings" "strings"
"github.com/anotherhadi/spilltea/internal/util"
"github.com/lqqyt2423/go-mitmproxy/proxy" "github.com/lqqyt2423/go-mitmproxy/proxy"
) )
@@ -14,15 +14,8 @@ func FormatRawRequest(f *proxy.Flow) string {
r := f.Request r := f.Request
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto) fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
keys := make([]string, 0, len(r.Header)) for _, line := range util.SortedHeaderLines(r.Header) {
for k := range r.Header { sb.WriteString(line)
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
} }
sb.WriteString("\n") sb.WriteString("\n")
if len(r.Body) > 0 { if len(r.Body) > 0 {
@@ -43,15 +36,8 @@ func FormatRawResponse(f *proxy.Flow) string {
proto = "HTTP/1.1" proto = "HTTP/1.1"
} }
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode)) fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
keys := make([]string, 0, len(r.Header)) for _, line := range util.SortedHeaderLines(r.Header) {
for k := range r.Header { sb.WriteString(line)
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
} }
sb.WriteString("\n") sb.WriteString("\n")
if len(r.Body) > 0 { if len(r.Body) > 0 {
+9
View File
@@ -22,6 +22,10 @@ type GlobalKeyMap struct {
ScrollUp key.Binding ScrollUp key.Binding
ScrollDown key.Binding ScrollDown key.Binding
SendToDiff key.Binding SendToDiff key.Binding
GotoTop key.Binding
GotoBottom key.Binding
PrevPage key.Binding
NextPage key.Binding
} }
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap { func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
@@ -42,6 +46,10 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
ScrollUp: binding(cfg.ScrollUp, "scroll up"), ScrollUp: binding(cfg.ScrollUp, "scroll up"),
ScrollDown: binding(cfg.ScrollDown, "scroll down"), ScrollDown: binding(cfg.ScrollDown, "scroll down"),
SendToDiff: binding(cfg.SendToDiff, "send to diff"), SendToDiff: binding(cfg.SendToDiff, "send to diff"),
GotoTop: binding(cfg.GotoTop, "go to top"),
GotoBottom: binding(cfg.GotoBottom, "go to bottom"),
PrevPage: binding(cfg.PrevPage, "prev page"),
NextPage: binding(cfg.NextPage, "next page"),
} }
} }
@@ -52,6 +60,7 @@ func (g GlobalKeyMap) Bindings() []key.Binding {
g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy, g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
g.SendToReplay, g.SendToDiff, g.SendToReplay, g.SendToDiff,
g.ScrollUp, g.ScrollDown, g.ScrollUp, g.ScrollDown,
g.GotoTop, g.GotoBottom, g.PrevPage, g.NextPage,
} }
} }
+3 -1
View File
@@ -10,6 +10,7 @@ type HistoryKeyMap struct {
DeleteAll key.Binding DeleteAll key.Binding
Filter key.Binding Filter key.Binding
SqlQuery key.Binding SqlQuery key.Binding
Flag key.Binding
} }
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap { func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
@@ -18,9 +19,10 @@ func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
DeleteAll: binding(cfg.DeleteAll, "delete all"), DeleteAll: binding(cfg.DeleteAll, "delete all"),
Filter: binding(cfg.Filter, "filter"), Filter: binding(cfg.Filter, "filter"),
SqlQuery: binding(cfg.SqlQuery, "sql query"), SqlQuery: binding(cfg.SqlQuery, "sql query"),
Flag: binding(cfg.Flag, "flag"),
} }
} }
func (h HistoryKeyMap) Bindings() []key.Binding { func (h HistoryKeyMap) Bindings() []key.Binding {
return []key.Binding{h.DeleteEntry, h.DeleteAll} return []key.Binding{h.DeleteEntry, h.DeleteAll, h.Flag}
} }
+19 -1
View File
@@ -11,7 +11,25 @@ import (
) )
func newLuaState(mgr *Manager, p *Plugin) *lua.LState { func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
L := lua.NewState() L := lua.NewState(lua.Options{SkipOpenLibs: true})
for _, lib := range []struct {
name string
fn lua.LGFunction
}{
{lua.BaseLibName, lua.OpenBase},
{lua.TabLibName, lua.OpenTable},
{lua.StringLibName, lua.OpenString},
{lua.MathLibName, lua.OpenMath},
{lua.CoroutineLibName, lua.OpenCoroutine},
} {
L.Push(L.NewFunction(lib.fn))
L.Push(lua.LString(lib.name))
L.Call(1, 0)
}
// Remove filesystem-access functions to prevent plugins from reading/executing arbitrary files.
for _, name := range []string{"dofile", "loadfile", "load"} {
L.SetGlobal(name, lua.LNil)
}
registerUtilities(L, mgr, p) registerUtilities(L, mgr, p)
return L return L
} }
+52 -76
View File
@@ -81,6 +81,9 @@ func (m *Manager) LoadFromDir(dir string) error {
m.plugins = append(m.plugins, p) m.plugins = append(m.plugins, p)
m.mu.Unlock() m.mu.Unlock()
} }
m.mu.Lock()
sort.Slice(m.plugins, func(i, j int) bool { return m.plugins[i].Priority > m.plugins[j].Priority })
m.mu.Unlock()
return nil return nil
} }
@@ -117,6 +120,10 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Priority = int(n) p.Priority = int(n)
} }
if pluginTable.RawGetString("disable_by_default") == lua.LTrue {
p.Enabled = false
}
// Hooks configurable via the Plugin table (sync field). // Hooks configurable via the Plugin table (sync field).
configurableHooks := map[string]bool{ configurableHooks := map[string]bool{
"on_start": false, // async by default "on_start": false, // async by default
@@ -153,7 +160,6 @@ func (m *Manager) GetPlugins() []*Plugin {
defer m.mu.RUnlock() defer m.mu.RUnlock()
out := make([]*Plugin, len(m.plugins)) out := make([]*Plugin, len(m.plugins))
copy(out, m.plugins) copy(out, m.plugins)
sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority })
return out return out
} }
@@ -270,20 +276,22 @@ func (m *Manager) RunOnQuit() {
} }
} }
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision { // runSyncDecisionForPlugins runs hookName synchronously for all enabled plugins
// that registered it as sync, and returns the first non-Intercept decision.
func (m *Manager) runSyncDecisionForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) intercept.Decision {
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
hc, ok := p.hooks["on_request"] hc, ok := p.hooks[hookName]
if !ok || !hc.Sync { if !ok || !hc.Sync {
continue continue
} }
p.mu.Lock() p.mu.Lock()
result, err := callHook(p, "on_request", pushRequest(p.L, f)) result, err := callHook(p, hookName, argsFor(p)...)
p.mu.Unlock() p.mu.Unlock()
if err != nil { if err != nil {
log.Printf("plugin %s on_request: %v", p.Name, err) log.Printf("plugin %s %s: %v", p.Name, hookName, err)
continue continue
} }
switch result { switch result {
@@ -296,68 +304,49 @@ func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
return intercept.Intercept return intercept.Intercept
} }
// runAsyncForPlugins fires hookName asynchronously for all enabled plugins
// that registered it as async.
func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks[hookName]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, hookName, argsFor(p)...); err != nil {
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
}
p.mu.Unlock()
}(p)
}
}
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
return []lua.LValue{pushRequest(p.L, f)}
})
}
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) { func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
for _, p := range m.GetPlugins() { m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushRequest(p.L, f)}
continue })
}
hc, ok := p.hooks["on_request"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_request", pushRequest(p.L, f)); err != nil {
log.Printf("plugin %s on_request: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision { func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
for _, p := range m.GetPlugins() { return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
continue })
}
hc, ok := p.hooks["on_response"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_response: %v", p.Name, err)
continue
}
switch result {
case "drop":
return intercept.Drop
case "forward":
return intercept.Forward
}
}
return intercept.Intercept
} }
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) { func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
for _, p := range m.GetPlugins() { m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
continue })
}
hc, ok := p.hooks["on_response"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)); err != nil {
log.Printf("plugin %s on_response: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving. // RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
@@ -385,20 +374,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
} }
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) { func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
for _, p := range m.GetPlugins() { m.runAsyncForPlugins("on_history_entry", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushEntry(p.L, e)}
continue })
}
hc, ok := p.hooks["on_history_entry"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_history_entry", pushEntry(p.L, e)); err != nil {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
+44 -2
View File
@@ -1,10 +1,13 @@
package proxy package proxy
import ( import (
"encoding/base64"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"strings"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/config"
@@ -63,7 +66,15 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
func (a *interceptAddon) Response(f *goproxy.Flow) { func (a *interceptAddon) Response(f *goproxy.Flow) {
if f.Response != nil { if f.Response != nil {
if len(f.Response.Body) == 0 && f.Response.BodyReader != nil { if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
body, _ := io.ReadAll(f.Response.BodyReader) limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
body, err := io.ReadAll(io.LimitReader(f.Response.BodyReader, limit))
if err != nil {
log.Printf("proxy: reading response body: %v", err)
}
if int64(len(body)) == limit {
log.Printf("proxy: response body truncated at %dMB for %s", config.Global.App.MaxBodySizeMB, f.Request.URL.Host)
body = append(body, []byte(fmt.Sprintf("\n\n[body truncated at %dMB]", config.Global.App.MaxBodySizeMB))...)
}
f.Response.Body = body f.Response.Body = body
f.Response.BodyReader = nil f.Response.BodyReader = nil
} }
@@ -106,7 +117,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
opts := &goproxy.Options{ opts := &goproxy.Options{
Addr: addr, Addr: addr,
StreamLargeBodies: 1024 * 1024 * 5, StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
CaRootPath: caPath, CaRootPath: caPath,
Upstream: cfg.UpstreamProxy, Upstream: cfg.UpstreamProxy,
} }
@@ -116,10 +127,41 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
return err return err
} }
if cfg.ProxyAuth != "" {
parts := strings.SplitN(cfg.ProxyAuth, ":", 2)
if len(parts) == 2 {
wantUser, wantPass := parts[0], parts[1]
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
if !ok || user != wantUser || pass != wantPass {
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
return false, fmt.Errorf("invalid credentials")
}
return true, nil
})
}
}
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr}) p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
return p.Start() return p.Start()
} }
func parseBasicProxyAuth(header string) (user, pass string, ok bool) {
const prefix = "Basic "
if !strings.HasPrefix(header, prefix) {
return "", "", false
}
decoded, err := base64.StdEncoding.DecodeString(header[len(prefix):])
if err != nil {
return "", "", false
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
func dropResponse() *goproxy.Response { func dropResponse() *goproxy.Response {
return &goproxy.Response{ return &goproxy.Response{
StatusCode: 502, StatusCode: 502,
-2
View File
@@ -46,7 +46,6 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
return ta return ta
} }
// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
func SeverityStyle(sev string) lipgloss.Style { func SeverityStyle(sev string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true) base := lipgloss.NewStyle().Bold(true)
switch sev { switch sev {
@@ -63,7 +62,6 @@ func SeverityStyle(sev string) lipgloss.Style {
} }
} }
// StatusStyle returns a bold lipgloss style coloured by HTTP status code.
func StatusStyle(code, width int) lipgloss.Style { func StatusStyle(code, width int) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(width) base := lipgloss.NewStyle().Bold(true).Width(width)
switch { switch {
-1
View File
@@ -15,7 +15,6 @@ func Paint(c color.Color, s string) string {
return lipgloss.NewStyle().Foreground(c).Render(s) return lipgloss.NewStyle().Foreground(c).Render(s)
} }
// HighlightHTTP highlights a full raw HTTP message (headers + body).
func HighlightHTTP(raw string) string { func HighlightHTTP(raw string) string {
raw = strings.ReplaceAll(raw, "\r\n", "\n") raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n") raw = strings.ReplaceAll(raw, "\r", "\n")
+13 -10
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -31,10 +32,9 @@ const tickInterval = 2 * time.Second
type tickMsg struct{} type tickMsg struct{}
func tickCmd() tea.Cmd { func tickCmd() tea.Cmd {
return func() tea.Msg { return tea.Tick(tickInterval, func(time.Time) tea.Msg {
time.Sleep(tickInterval)
return tickMsg{} return tickMsg{}
} })
} }
var sidebarEntries = pageRegistry var sidebarEntries = pageRegistry
@@ -94,14 +94,17 @@ func New(broker *intercept.Broker, name, path string) Model {
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
} }
if d, err := db.Open(path); err == nil { d, err := db.Open(path)
m.database = d if err != nil {
broker.SetDB(d) fmt.Fprintf(os.Stderr, "db: %v\n", err)
m.history.SetDB(d) os.Exit(1)
m.replay.SetDB(d)
m.findingsPage.SetDB(d)
mgr.SetDB(d)
} }
m.database = d
broker.SetDB(d)
m.history.SetDB(d)
m.replay.SetDB(d)
m.findingsPage.SetDB(d)
mgr.SetDB(d)
pluginsDir := config.ExpandPath(cfg.App.PluginsDir) pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil { if err := mgr.LoadFromDir(pluginsDir); err != nil {
+10
View File
@@ -104,6 +104,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case proxyPkg.ErrMsg: case proxyPkg.ErrMsg:
if msg.Err != nil { if msg.Err != nil {
log.Printf("proxy error: %v", msg.Err) log.Printf("proxy error: %v", msg.Err)
return m, tea.Batch(
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Proxy Error",
Body: msg.Err.Error(),
Kind: notificationsUI.KindError,
}
},
tea.Quit,
)
} }
return m, nil return m, nil
+136 -34
View File
@@ -1,8 +1,12 @@
package copyas package copyas
import ( import (
"encoding/json"
"fmt" "fmt"
"net/url"
"strings" "strings"
"github.com/anotherhadi/spilltea/internal/util"
) )
type header struct{ key, value string } type header struct{ key, value string }
@@ -12,46 +16,22 @@ type parsedRequest struct {
path string path string
host string host string
scheme string scheme string
headers []header headers []header // garder header{key, value} pour compat locale
body string body string
} }
func parseRaw(raw, scheme string) parsedRequest { func parseRaw(raw, scheme string) parsedRequest {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") r := util.ParseRawRequest(raw)
pr := parsedRequest{scheme: scheme} pr := parsedRequest{
if len(lines) == 0 { method: r.Method,
return pr path: r.Path,
host: r.Host,
scheme: scheme,
} }
for _, h := range r.Headers {
parts := strings.SplitN(lines[0], " ", 3) pr.headers = append(pr.headers, header{h.Key, h.Value})
if len(parts) >= 1 {
pr.method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
pr.path = strings.TrimSpace(parts[1])
}
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
pr.headers = append(pr.headers, header{k, v})
if strings.EqualFold(k, "host") {
pr.host = v
}
}
i++
}
if i < len(lines) {
pr.body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
} }
pr.body = r.Body
return pr return pr
} }
@@ -78,10 +58,31 @@ func formatAs(id, raw, scheme string) string {
return toFFUF(pr) return toFFUF(pr)
case "markdown": case "markdown":
return toMarkdown(pr) return toMarkdown(pr)
case "har":
return toHAR(pr)
case "httpie":
return toHTTPie(pr)
} }
return raw return raw
} }
func toHTTPie(pr parsedRequest) string {
var sb strings.Builder
method := strings.ToUpper(pr.method)
fmt.Fprintf(&sb, "http %s '%s'", method, pr.fullURL())
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " \\\n '%s:%s'", h.key, h.value)
}
if pr.body != "" {
// Pass body via stdin hint
fmt.Fprintf(&sb, " \\\n <<< %q", pr.body)
}
return sb.String()
}
func toMarkdown(pr parsedRequest) string { func toMarkdown(pr parsedRequest) string {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL()) fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
@@ -200,3 +201,104 @@ func toFFUF(pr parsedRequest) string {
} }
return sb.String() return sb.String()
} }
func toHAR(pr parsedRequest) string {
type harNameValue struct {
Name string `json:"name"`
Value string `json:"value"`
}
type harPostData struct {
MimeType string `json:"mimeType"`
Text string `json:"text"`
}
type harRequest struct {
Method string `json:"method"`
URL string `json:"url"`
HTTPVersion string `json:"httpVersion"`
Headers []harNameValue `json:"headers"`
QueryString []harNameValue `json:"queryString"`
Cookies []harNameValue `json:"cookies"`
HeadersSize int `json:"headersSize"`
BodySize int `json:"bodySize"`
PostData *harPostData `json:"postData,omitempty"`
}
type harEntry struct {
StartedDateTime string `json:"startedDateTime"`
Time int `json:"time"`
Request harRequest `json:"request"`
Cache struct{} `json:"cache"`
Timings struct {
Send int `json:"send"`
Wait int `json:"wait"`
Receive int `json:"receive"`
} `json:"timings"`
}
type harLog struct {
Version string `json:"version"`
Creator struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"creator"`
Entries []harEntry `json:"entries"`
}
type harRoot struct {
Log harLog `json:"log"`
}
headers := make([]harNameValue, 0, len(pr.headers))
for _, h := range pr.headers {
headers = append(headers, harNameValue{h.key, h.value})
}
var qs []harNameValue
if idx := strings.Index(pr.path, "?"); idx != -1 {
vals, err := url.ParseQuery(pr.path[idx+1:])
if err == nil {
for k, vs := range vals {
for _, v := range vs {
qs = append(qs, harNameValue{k, v})
}
}
}
}
if qs == nil {
qs = []harNameValue{}
}
req := harRequest{
Method: pr.method,
URL: pr.fullURL(),
HTTPVersion: "HTTP/1.1",
Headers: headers,
QueryString: qs,
Cookies: []harNameValue{},
HeadersSize: -1,
BodySize: len(pr.body),
}
if pr.body != "" {
mimeType := "application/octet-stream"
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-type") {
mimeType = h.value
break
}
}
req.PostData = &harPostData{MimeType: mimeType, Text: pr.body}
}
root := harRoot{Log: harLog{
Version: "1.2",
Entries: []harEntry{{
StartedDateTime: "1970-01-01T00:00:00.000Z",
Time: -1,
Request: req,
}},
}}
root.Log.Creator.Name = "spilltea"
b, err := json.MarshalIndent(root, "", " ")
if err != nil {
return ""
}
return string(b)
}
+2
View File
@@ -45,6 +45,8 @@ var allFormats = []list.Item{
formatItem{"go", "Go", "net/http package"}, formatItem{"go", "Go", "net/http package"},
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"}, formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
formatItem{"markdown", "Markdown", "formatted for documentation"}, formatItem{"markdown", "Markdown", "formatted for documentation"},
formatItem{"har", "HAR", "HTTP Archive (JSON)"},
formatItem{"httpie", "HTTPie", "HTTPie command line client"},
} }
type Model struct { type Model struct {
+159 -6
View File
@@ -10,8 +10,159 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
// isWordChar reports whether c belongs to a "word" token (letter, digit, underscore).
func isWordChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}
// tokenize splits s into runs of word characters and individual non-word bytes.
func tokenize(s string) []string {
var out []string
i := 0
for i < len(s) {
if isWordChar(s[i]) {
j := i
for j < len(s) && isWordChar(s[j]) {
j++
}
out = append(out, s[i:j])
i = j
} else {
out = append(out, s[i:i+1])
i++
}
}
return out
}
// wordDiff computes a token-level diff between leftLine and rightLine and
// returns the two rendered strings with changed tokens highlighted.
func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) {
lToks := tokenize(leftLine)
rToks := tokenize(rightLine)
n, m := len(lToks), len(rToks)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := 1; i <= n; i++ {
for j := 1; j <= m; j++ {
if lToks[i-1] == rToks[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] >= dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
type segment struct {
kind int // 0=same, 1=left-only, 2=right-only
tok string
}
segs := make([]segment, 0, n+m)
i, j := n, m
for i > 0 || j > 0 {
switch {
case i > 0 && j > 0 && lToks[i-1] == rToks[j-1]:
segs = append(segs, segment{0, lToks[i-1]})
i--
j--
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
segs = append(segs, segment{2, rToks[j-1]})
j--
default:
segs = append(segs, segment{1, lToks[i-1]})
i--
}
}
for lo, hi := 0, len(segs)-1; lo < hi; lo, hi = lo+1, hi-1 {
segs[lo], segs[hi] = segs[hi], segs[lo]
}
s := style.S
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true)
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true)
dim := lipgloss.NewStyle().Foreground(s.Subtle)
var lb, rb strings.Builder
for _, seg := range segs {
switch seg.kind {
case 0:
lb.WriteString(dim.Render(seg.tok))
rb.WriteString(dim.Render(seg.tok))
case 1:
lb.WriteString(boldErr.Render(seg.tok))
case 2:
rb.WriteString(boldOk.Render(seg.tok))
}
}
return lb.String(), rb.String()
}
// pairAndHighlight collapses adjacent removed/added blocks onto the same rows
// (eliminating the interleaved padding lines) and applies word-level diff
// highlighting to each paired line. Unpaired excess removals/additions keep
// their original single-sided padding row.
func pairAndHighlight(left, right []diffLine) ([]diffLine, []diffLine) {
newLeft := make([]diffLine, 0, len(left))
newRight := make([]diffLine, 0, len(right))
i := 0
for i < len(left) {
if left[i].kind != lineRemoved {
newLeft = append(newLeft, left[i])
newRight = append(newRight, right[i])
i++
continue
}
rStart := i
for i < len(left) && left[i].kind == lineRemoved {
i++
}
rEnd := i
aStart := i
for i < len(left) && left[i].kind == lineAdded {
i++
}
aEnd := i
nRemoved := rEnd - rStart
nAdded := aEnd - aStart
pairs := nRemoved
if nAdded < pairs {
pairs = nAdded
}
for k := 0; k < pairs; k++ {
lLine := left[rStart+k]
rLine := right[aStart+k]
lLine.text, rLine.text = wordDiff(lLine.plainText, rLine.plainText)
newLeft = append(newLeft, lLine)
newRight = append(newRight, rLine)
}
for k := pairs; k < nRemoved; k++ {
newLeft = append(newLeft, left[rStart+k])
newRight = append(newRight, diffLine{kind: lineRemoved})
}
for k := pairs; k < nAdded; k++ {
newLeft = append(newLeft, diffLine{kind: lineAdded})
newRight = append(newRight, right[aStart+k])
}
}
return newLeft, newRight
}
type slot struct { type slot struct {
label string label string
raw string raw string
@@ -38,8 +189,9 @@ const (
) )
type diffLine struct { type diffLine struct {
text string text string // displayed text (highlighted, possibly word-diff decorated)
kind lineKind plainText string // plain text for word-diff pairing (empty for padding lines)
kind lineKind
} }
type Model struct { type Model struct {
@@ -126,6 +278,7 @@ func (m *Model) computeDiff() {
leftHL := hlLines(leftNorm) leftHL := hlLines(leftNorm)
rightHL := hlLines(rightNorm) rightHL := hlLines(rightNorm)
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL) m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
m.leftLines, m.rightLines = pairAndHighlight(m.leftLines, m.rightLines)
} }
func normRaw(s string) string { func normRaw(s string) string {
@@ -149,7 +302,7 @@ func (m *Model) refreshViewports() {
placeholder := lipgloss.Place( placeholder := lipgloss.Place(
m.leftViewport.Width(), m.leftViewport.Height(), m.leftViewport.Width(), m.leftViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
s.Faint.Render(" <(^_^)>\nsend two entries here to compare"), s.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")),
) )
m.leftViewport.SetContent(placeholder) m.leftViewport.SetContent(placeholder)
m.rightViewport.SetContent("") m.rightViewport.SetContent("")
@@ -161,7 +314,7 @@ func (m *Model) refreshViewports() {
placeholder := lipgloss.Place( placeholder := lipgloss.Place(
m.rightViewport.Width(), m.rightViewport.Height(), m.rightViewport.Width(), m.rightViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (・3・)\nwaiting for second entry…"), s.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")),
) )
m.rightViewport.SetContent(placeholder) m.rightViewport.SetContent(placeholder)
return return
@@ -227,10 +380,10 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
j-- j--
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]): case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
left = append(left, diffLine{kind: lineAdded}) left = append(left, diffLine{kind: lineAdded})
right = append(right, diffLine{text: hlB(j - 1), kind: lineAdded}) right = append(right, diffLine{text: hlB(j - 1), plainText: b[j-1], kind: lineAdded})
j-- j--
default: default:
left = append(left, diffLine{text: hlA(i - 1), kind: lineRemoved}) left = append(left, diffLine{text: hlA(i - 1), plainText: a[i-1], kind: lineRemoved})
right = append(right, diffLine{kind: lineRemoved}) right = append(right, diffLine{kind: lineRemoved})
i-- i--
} }
+25 -9
View File
@@ -15,6 +15,7 @@ import (
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
type Model struct { type Model struct {
@@ -27,6 +28,9 @@ type Model struct {
pager paginator.Model pager paginator.Model
help help.Model help help.Model
renderer *glamour.TermRenderer
rendererWidth int
width int width int
height int height int
} }
@@ -76,6 +80,11 @@ func (m *Model) recalcSizes() {
m.bodyViewport.SetWidth(inner) m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH) m.bodyViewport.SetHeight(bodyVH)
if m.rendererWidth != inner {
m.renderer = nil
m.rendererWidth = 0
}
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
} }
@@ -109,14 +118,14 @@ func (m *Model) refreshBody() {
return return
} }
f := m.findings[m.cursor] f := m.findings[m.cursor]
rendered := renderMarkdown(f.Description, m.bodyViewport.Width()) rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered) m.bodyViewport.SetContent(rendered)
m.bodyViewport.GotoTop() m.bodyViewport.GotoTop()
} }
func renderMarkdown(src string, width int) string { func (m *Model) renderMarkdownCached(src string, width int) string {
if src == "" { if src == "" {
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description") return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
} }
tmpl, err := template.New("").Parse(src) tmpl, err := template.New("").Parse(src)
if err != nil { if err != nil {
@@ -129,14 +138,21 @@ func renderMarkdown(src string, width int) string {
if width < 10 { if width < 10 {
width = 80 width = 80
} }
r, err := glamour.NewTermRenderer( // Rebuild renderer if width changed or not yet built.
glamour.WithStyles(style.GlamourStyleConfig(config.Global)), if m.renderer == nil || m.rendererWidth != width {
glamour.WithWordWrap(width), r, err := glamour.NewTermRenderer(
) glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
if err != nil { glamour.WithWordWrap(width),
)
if err == nil {
m.renderer = r
m.rendererWidth = width
}
}
if m.renderer == nil {
return buf.String() return buf.String()
} }
out, err := r.Render(buf.String()) out, err := m.renderer.Render(buf.String())
if err != nil { if err != nil {
return buf.String() return buf.String()
} }
+43
View File
@@ -81,6 +81,49 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
step = 1 step = 1
} }
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step) m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, g.GotoTop):
m.cursor = 0
m.pager.Page = 0
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.GotoBottom):
if len(m.findings) > 0 {
m.cursor = len(m.findings) - 1
m.pager.Page = m.pager.TotalPages - 1
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, g.PrevPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.NextPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor += step
if m.cursor >= len(m.findings) {
m.cursor = len(m.findings) - 1
if m.cursor < 0 {
m.cursor = 0
}
}
m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
+1 -1
View File
@@ -54,7 +54,7 @@ func (m *Model) renderList() string {
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"), s.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
) )
} }
+8 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
type panel int type panel int
@@ -62,7 +63,12 @@ func (m Model) CurrentRaw() string {
return m.entries[m.cursor].RequestRaw return m.entries[m.cursor].RequestRaw
} }
func (m Model) CurrentScheme() string { return "https" } func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https"
}
return util.InferScheme(m.entries[m.cursor].Host)
}
// RefreshCmd returns the appropriate load command given the current search state. // RefreshCmd returns the appropriate load command given the current search state.
// The app model should call this instead of LoadEntriesCmd directly so that // The app model should call this instead of LoadEntriesCmd directly so that
@@ -153,7 +159,7 @@ func (m historyKeyMap) FullHelp() [][]key.Binding {
h := keys.Keys.History h := keys.Keys.History
g := keys.Keys.Global g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs} pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs}
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery} all := []key.Binding{h.Flag, h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
all = append(all, pageGlobals...) all = append(all, pageGlobals...)
all = append(all, g.CommonBindings()...) all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
+56 -1
View File
@@ -230,6 +230,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
case key.Matches(msg, h.Flag):
if len(m.entries) > 0 && m.database != nil {
m.database.ToggleFlag(m.entries[m.cursor].ID)
return m, m.RefreshCmd()
}
case key.Matches(msg, h.DeleteEntry): case key.Matches(msg, h.DeleteEntry):
if len(m.entries) > 0 { if len(m.entries) > 0 {
id := m.entries[m.cursor].ID id := m.entries[m.cursor].ID
@@ -271,6 +277,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Right): case key.Matches(msg, g.Right):
m.bodyViewport.ScrollRight(6) m.bodyViewport.ScrollRight(6)
case key.Matches(msg, g.GotoTop):
m.cursor = 0
m.pager.Page = 0
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.GotoBottom):
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
m.pager.Page = m.pager.TotalPages - 1
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.PrevPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.NextPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor += step
if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
if m.cursor < 0 {
m.cursor = 0
}
}
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, keys.Keys.Global.Help): case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
@@ -307,7 +362,7 @@ func (m *Model) refreshBody() {
} }
if raw == "" { if raw == "" {
w, h := m.bodyViewport.Width(), m.bodyViewport.Height() w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (˘・_・˘)\nno response stored"))) m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored"))))
return return
} }
m.bodyViewport.SetContent(style.HighlightHTTP(raw)) m.bodyViewport.SetContent(style.HighlightHTTP(raw))
+21 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -84,9 +85,9 @@ func (m *Model) renderList() string {
) )
} }
if len(m.entries) == 0 { if len(m.entries) == 0 {
msg := " (⌐■_■)\nno history yet" msg := util.CenterLines("(⌐■_■)", "no history yet")
if m.searchKind != searchKindOff { if m.searchKind != searchKindOff {
msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results" msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
} }
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
@@ -112,7 +113,7 @@ func (m *Model) renderList() string {
w := m.listViewport.Width() w := m.listViewport.Width()
statusStr := fmt.Sprintf("%3d", e.StatusCode) statusStr := fmt.Sprintf("%3d", e.StatusCode)
const fixedW = 2 + 7 + 1 + 3 + 1 + 10 + 1 const fixedW = 2 + 2 + 7 + 1 + 3 + 1 + 10 + 1
hostPathW := w - fixedW hostPathW := w - fixedW
if hostPathW < 0 { if hostPathW < 0 {
hostPathW = 0 hostPathW = 0
@@ -120,12 +121,21 @@ func (m *Model) renderList() string {
ts := e.Timestamp.Format("15:04:05") ts := e.Timestamp.Format("15:04:05")
statusSt := style.StatusStyle(e.StatusCode, 3) statusSt := style.StatusStyle(e.StatusCode, 3)
flagSt := lipgloss.NewStyle().Foreground(s.Primary)
var line string var line string
if selected { if selected {
bg := lipgloss.NewStyle().Background(selBg) bg := lipgloss.NewStyle().Background(selBg)
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
bg.Foreground(s.Primary).Width(2).Render(flagStr),
s.Method(e.Method).Background(selBg).Render(e.Method), s.Method(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""), bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr), statusSt.Background(selBg).Render(statusStr),
@@ -135,8 +145,16 @@ func (m *Model) renderList() string {
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path), bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
) )
} else { } else {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
flagSt.Width(2).Render(flagStr),
s.Method(e.Method).Render(e.Method), s.Method(e.Method).Render(e.Method),
" ", " ",
statusSt.Render(statusStr), statusSt.Render(statusStr),
+6 -5
View File
@@ -142,6 +142,11 @@ type Project struct {
ModTime time.Time ModTime time.Time
} }
// ProjectSelectedMsg is emitted when the user picks a project from the home screen.
type ProjectSelectedMsg struct {
Project *Project
}
type inputMode int type inputMode int
const ( const (
@@ -161,15 +166,11 @@ type Model struct {
list list.Model list list.Model
projectDir string projectDir string
nameInput textinput.Model nameInput textinput.Model
selected *Project
width int width int
height int height int
teapotFrame int teapotFrame int
} }
// Selected returns the project chosen by the user, or nil if the program was
// quit without making a selection.
func (m Model) Selected() *Project { return m.selected }
func New(projectDir string) Model { func New(projectDir string) Model {
projects := loadProjects(projectDir) projects := loadProjects(projectDir)
@@ -332,7 +333,7 @@ func (m Model) renderHelpLine() string {
} }
parts = append(parts, binding(k.Open)) parts = append(parts, binding(k.Open))
parts = append(parts, binding(k.Delete)) parts = append(parts, binding(k.Delete))
parts = append(parts, item("q", "quit")) parts = append(parts, item(keys.Keys.Global.Quit.Help().Key, "quit"))
} }
return strings.Join(parts, sep) return strings.Join(parts, sep)
+6 -6
View File
@@ -76,11 +76,11 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
initProjectFiles(dir) initProjectFiles(dir)
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")} p := &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
return m, tea.Quit return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
default: default:
m.selected = &Project{Name: item.name, Path: item.path} p := &Project{Name: item.name, Path: item.path}
return m, tea.Quit return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
} }
} }
@@ -117,8 +117,8 @@ func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
initProjectFiles(dir) initProjectFiles(dir)
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")} p := &Project{Name: name, Path: filepath.Join(dir, "data.db")}
return m, tea.Quit return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg) m.nameInput, cmd = m.nameInput.Update(msg)
+18 -93
View File
@@ -4,112 +4,37 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"sort"
"strconv" "strconv"
"strings" "strings"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func formatRawRequest(req *intercept.PendingRequest) string {
r := req.Flow.Request
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func formatRawResponse(resp *intercept.PendingResponse) string {
r := resp.Flow.Response
if r == nil {
return "(no response)"
}
var sb strings.Builder
proto := resp.Flow.Request.Proto
if proto == "" {
proto = "HTTP/1.1"
}
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func parseRawRequest(content string, req *intercept.PendingRequest) { func parseRawRequest(content string, req *intercept.PendingRequest) {
parsed := util.ParseRawRequest(content)
r := req.Flow.Request r := req.Flow.Request
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") if parsed.Method != "" {
if len(lines) == 0 { r.Method = parsed.Method
return
} }
if parsed.Path != "" {
parts := strings.SplitN(lines[0], " ", 3) if u, err := url.ParseRequestURI(parsed.Path); err == nil {
if len(parts) >= 1 {
r.Method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
if u, err := url.ParseRequestURI(strings.TrimSpace(parts[1])); err == nil {
r.URL.Path = u.Path r.URL.Path = u.Path
r.URL.RawQuery = u.RawQuery r.URL.RawQuery = u.RawQuery
} }
} }
if len(parts) >= 3 { if parsed.Proto != "" {
r.Proto = strings.TrimSpace(parts[2]) r.Proto = parsed.Proto
} }
r.Header = make(http.Header) r.Header = make(http.Header)
i := 1 for _, h := range parsed.Headers {
for i < len(lines) { r.Header.Set(h.Key, h.Value)
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
}
i++
} }
if parsed.Body != "" {
if i < len(lines) { r.Body = []byte(parsed.Body)
body := strings.Join(lines[i:], "\n") } else {
body = strings.TrimRight(body, "\n") r.Body = nil
if body != "" {
r.Body = []byte(body)
} else {
r.Body = nil
}
} }
} }
@@ -343,7 +268,7 @@ func (m *Model) loadIntoTextarea() {
if edited, ok := m.pendingResponseEdits[resp]; ok { if edited, ok := m.pendingResponseEdits[resp]; ok {
m.textarea.SetValue(edited) m.textarea.SetValue(edited)
} else { } else {
m.textarea.SetValue(formatRawResponse(resp)) m.textarea.SetValue(intercept.FormatRawResponse(resp.Flow))
} }
} else { } else {
if len(m.queue) == 0 { if len(m.queue) == 0 {
@@ -353,7 +278,7 @@ func (m *Model) loadIntoTextarea() {
if edited, ok := m.pendingEdits[req]; ok { if edited, ok := m.pendingEdits[req]; ok {
m.textarea.SetValue(edited) m.textarea.SetValue(edited)
} else { } else {
m.textarea.SetValue(formatRawRequest(req)) m.textarea.SetValue(intercept.FormatRawRequest(req.Flow))
} }
} }
} }
@@ -370,7 +295,7 @@ func (m *Model) refreshBody() {
if edited, ok := m.pendingResponseEdits[resp]; ok { if edited, ok := m.pendingResponseEdits[resp]; ok {
raw = edited raw = edited
} else { } else {
raw = formatRawResponse(resp) raw = intercept.FormatRawResponse(resp.Flow)
} }
} else { } else {
if len(m.queue) == 0 { if len(m.queue) == 0 {
@@ -381,7 +306,7 @@ func (m *Model) refreshBody() {
if edited, ok := m.pendingEdits[req]; ok { if edited, ok := m.pendingEdits[req]; ok {
raw = edited raw = edited
} else { } else {
raw = formatRawRequest(req) raw = intercept.FormatRawRequest(req.Flow)
} }
} }
m.bodyViewport.SetContent(style.HighlightHTTP(raw)) m.bodyViewport.SetContent(style.HighlightHTTP(raw))
+2 -2
View File
@@ -98,7 +98,7 @@ func (m Model) CurrentRaw() string {
if edited, ok := m.pendingResponseEdits[resp]; ok { if edited, ok := m.pendingResponseEdits[resp]; ok {
return edited return edited
} }
return formatRawResponse(resp) return intercept.FormatRawResponse(resp.Flow)
} }
if len(m.queue) == 0 { if len(m.queue) == 0 {
return "" return ""
@@ -107,7 +107,7 @@ func (m Model) CurrentRaw() string {
if edited, ok := m.pendingEdits[req]; ok { if edited, ok := m.pendingEdits[req]; ok {
return edited return edited
} }
return formatRawRequest(req) return intercept.FormatRawRequest(req.Flow)
} }
func (m *Model) SetSize(w, h int) { func (m *Model) SetSize(w, h int) {
+28 -7
View File
@@ -146,9 +146,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
case key.Matches(msg, keys.Keys.Global.Right): case key.Matches(msg, keys.Keys.Global.Right):
m.bodyViewport.ScrollRight(6) m.bodyViewport.ScrollRight(6)
case key.Matches(msg, keys.Keys.Global.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Keys.Intercept.UndoEdits): case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses { if onResponses {
if len(m.responseQueue) > 0 { if len(m.responseQueue) > 0 {
@@ -237,10 +234,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
case key.Matches(msg, keys.Keys.Intercept.EditExternal): case key.Matches(msg, keys.Keys.Intercept.EditExternal):
if !onResponses && len(m.queue) > 0 { if !onResponses && len(m.queue) > 0 {
return m, util.OpenExternalEditor(formatRawRequest(m.queue[m.cursor])) return m, util.OpenExternalEditor(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
} }
if onResponses && len(m.responseQueue) > 0 { if onResponses && len(m.responseQueue) > 0 {
return m, util.OpenExternalEditor(formatRawResponse(m.responseQueue[m.responseCursor])) return m, util.OpenExternalEditor(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
} }
case key.Matches(msg, keys.Keys.Global.SendToReplay): case key.Matches(msg, keys.Keys.Global.SendToReplay):
@@ -268,6 +265,30 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
return diffUI.SendToDiffMsg{Label: label, Raw: raw} return diffUI.SendToDiffMsg{Label: label, Raw: raw}
} }
} }
case key.Matches(msg, keys.Keys.Global.GotoTop):
if onResponses {
m.responseCursor = 0
} else {
m.cursor = 0
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoBottom):
if onResponses {
if len(m.responseQueue) > 0 {
m.responseCursor = len(m.responseQueue) - 1
}
} else {
if len(m.queue) > 0 {
m.cursor = len(m.queue) - 1
}
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
} }
return m, tea.Batch(*cmds...) return m, tea.Batch(*cmds...)
@@ -287,12 +308,12 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
if onResponses { if onResponses {
if len(m.responseQueue) > 0 { if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor]) delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.textarea.SetValue(formatRawResponse(m.responseQueue[m.responseCursor])) m.textarea.SetValue(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
} }
} else { } else {
if len(m.queue) > 0 { if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor]) delete(m.pendingEdits, m.queue[m.cursor])
m.textarea.SetValue(formatRawRequest(m.queue[m.cursor])) m.textarea.SetValue(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
} }
} }
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -104,7 +105,7 @@ func (m *Model) renderStatusBar() string {
func (m *Model) renderList() string { func (m *Model) renderList() string {
if len(m.queue) == 0 { if len(m.queue) == 0 {
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (。◕‿‿◕。)\nwaiting for a request")) return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(。◕‿‿◕。)", "waiting for a request")))
} }
s := style.S s := style.S
@@ -160,7 +161,7 @@ func (m *Model) renderList() string {
func (m *Model) renderResponseList() string { func (m *Model) renderResponseList() string {
if len(m.responseQueue) == 0 { if len(m.responseQueue) == 0 {
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (҂◡_◡)\nno response yet")) return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(҂◡_◡)", "no response yet")))
} }
s := style.S s := style.S
-1
View File
@@ -112,7 +112,6 @@ func (m *Model) recalcSizes() {
m.syncDetailViewport() m.syncDetailViewport()
} }
// Refresh reloads the plugin list from the manager.
func (m *Model) Refresh() { func (m *Model) Refresh() {
if m.manager == nil { if m.manager == nil {
return return
-14
View File
@@ -6,21 +6,7 @@ import (
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
) )
// PluginsChangedMsg is sent when the plugin list should be refreshed.
type PluginsChangedMsg struct{}
// RefreshCmd returns a command that triggers a list refresh.
func RefreshCmd() tea.Cmd {
return func() tea.Msg { return PluginsChangedMsg{} }
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case PluginsChangedMsg:
m.Refresh()
return m, nil
}
// Route non-key messages to textarea when editing so internal // Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly. // textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing { if m.editing {
+4 -3
View File
@@ -11,11 +11,12 @@ import (
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
if m.width == 0 || m.manager == nil { if m.width == 0 || m.manager == nil {
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (._.)~*.'\n no plugins loaded"))) return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded"))))
} }
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4) listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
@@ -131,9 +132,9 @@ func (m *Model) renderStatusBar() string {
func (m *Model) renderList() string { func (m *Model) renderList() string {
s := style.S s := style.S
if len(m.filtered) == 0 { if len(m.filtered) == 0 {
msg := " (ง •̀_•́)ง\nno plugins" msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
if m.filter != "" { if m.filter != "" {
msg = " = _ =\nno results" msg = util.CenterLines("= _ =", "no results")
} }
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
+68 -78
View File
@@ -1,18 +1,17 @@
package replay package replay
import ( import (
"bytes"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
@@ -213,6 +212,47 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoTop):
m.cursor = 0
m.pager.Page = 0
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoBottom):
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
m.pager.Page = m.pager.TotalPages - 1
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, keys.Keys.Global.PrevPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.NextPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor += step
if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
if m.cursor < 0 {
m.cursor = 0
}
}
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
@@ -265,69 +305,46 @@ func (m *Model) refreshBody() {
m.requestViewport.SetXOffset(0) m.requestViewport.SetXOffset(0)
if e.Sending { if e.Sending {
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (ノ◕ヮ◕)ノ*:・゚\n sending..."))) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
} else if e.ResponseRaw != "" { } else if e.ResponseRaw != "" {
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw)) m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
} else { } else {
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" ( •_•)>⌐■\npress send to fire"))) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
} }
m.responseViewport.SetYOffset(0) m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0) m.responseViewport.SetXOffset(0)
} }
func doSend(entry Entry) (responseRaw string, statusCode int, err error) { func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n") parsed := util.ParseRawRequest(entry.RequestRaw)
if len(lines) == 0 { if parsed.Method == "" {
return "", 0, fmt.Errorf("empty request") return "", 0, fmt.Errorf("empty request")
} }
parts := strings.SplitN(lines[0], " ", 3) host := parsed.Host
if len(parts) < 2 { if host == "" {
return "", 0, fmt.Errorf("invalid request line") host = entry.Host
} }
method := strings.TrimSpace(parts[0])
path := strings.TrimSpace(parts[1])
headers := make(http.Header) headers := make(http.Header)
host := entry.Host for _, h := range parsed.Headers {
i := 1 if strings.EqualFold(h.Key, "host") {
for i < len(lines) { continue
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
} }
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 { headers.Add(h.Key, h.Value)
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
if strings.ToLower(k) == "host" {
host = v
} else {
headers.Add(k, v)
}
}
i++
}
var bodyBytes []byte
if i < len(lines) {
b := strings.Join(lines[i:], "\n")
b = strings.TrimRight(b, "\n")
bodyBytes = []byte(b)
} }
scheme := entry.Scheme scheme := entry.Scheme
if scheme == "" { if scheme == "" {
scheme = "https" scheme = "https"
} }
urlStr := scheme + "://" + host + path urlStr := scheme + "://" + host + parsed.Path
var bodyReader io.Reader var bodyReader io.Reader
if len(bodyBytes) > 0 { if parsed.Body != "" {
bodyReader = bytes.NewReader(bodyBytes) bodyReader = strings.NewReader(parsed.Body)
} }
req, err := http.NewRequest(method, urlStr, bodyReader) req, err := http.NewRequest(parsed.Method, urlStr, bodyReader)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
@@ -349,19 +366,13 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)) fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
sortedKeys := make([]string, 0, len(resp.Header)) for _, line := range util.SortedHeaderLines(resp.Header) {
for k := range resp.Header { sb.WriteString(line)
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
for _, k := range sortedKeys {
for _, v := range resp.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
} }
sb.WriteString("\n") sb.WriteString("\n")
sb.Write(respBody) sb.Write(respBody)
@@ -390,7 +401,11 @@ func entryToDB(e Entry) db.ReplayEntry {
} }
func entryFromMsg(msg SendToReplayMsg) Entry { func entryFromMsg(msg SendToReplayMsg) Entry {
method, host, path := parseFirstLine(msg.RequestRaw, msg.Host) parsed := util.ParseRawRequest(msg.RequestRaw)
host := parsed.Host
if host == "" {
host = msg.Host
}
scheme := msg.Scheme scheme := msg.Scheme
if scheme == "" { if scheme == "" {
scheme = util.InferScheme(host) scheme = util.InferScheme(host)
@@ -398,34 +413,9 @@ func entryFromMsg(msg SendToReplayMsg) Entry {
return Entry{ return Entry{
Scheme: scheme, Scheme: scheme,
Host: host, Host: host,
Path: path, Path: parsed.Path,
Method: method, Method: parsed.Method,
OriginalRaw: msg.RequestRaw, OriginalRaw: msg.RequestRaw,
RequestRaw: msg.RequestRaw, RequestRaw: msg.RequestRaw,
} }
} }
func parseFirstLine(raw, fallbackHost string) (method, host, path string) {
host = fallbackHost
path = "/"
lines := strings.SplitN(raw, "\n", 2)
if len(lines) == 0 {
return
}
parts := strings.Fields(lines[0])
if len(parts) >= 1 {
method = parts[0]
}
if len(parts) >= 2 {
path = parts[1]
}
if len(lines) > 1 {
for _, line := range strings.Split(lines[1], "\n") {
if strings.HasPrefix(strings.ToLower(line), "host:") {
host = strings.TrimSpace(line[5:])
break
}
}
}
return
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -75,7 +76,7 @@ func (m *Model) renderList() string {
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
style.S.Faint.Render(" (╥﹏╥)\nsend a request from History or Intercept"), style.S.Faint.Render(util.CenterLines("(╥﹏╥)", "send a request from History or Intercept")),
) )
} }
+6 -1
View File
@@ -5,6 +5,8 @@ import (
"os/exec" "os/exec"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
) )
type EditorFinishedMsg struct { type EditorFinishedMsg struct {
@@ -13,7 +15,10 @@ type EditorFinishedMsg struct {
} }
func OpenExternalEditor(content string) tea.Cmd { func OpenExternalEditor(content string) tea.Cmd {
editor := os.Getenv("EDITOR") editor := config.Global.App.ExternalEditor
if editor == "" {
editor = os.Getenv("EDITOR")
}
if editor == "" { if editor == "" {
editor = "vi" editor = "vi"
} }
+86
View File
@@ -0,0 +1,86 @@
package util
import (
"fmt"
"net/http"
"sort"
"strings"
)
// RawRequest holds a parsed raw HTTP request string.
type RawRequest struct {
Method string
Path string
Proto string
Host string
Headers []RawHeader
Body string
}
// RawHeader is a single header key/value pair preserving insertion order.
type RawHeader struct {
Key string
Value string
}
// ParseRawRequest parses a raw HTTP request string (as produced by
// FormatRawRequest). The Host header, if present, is extracted into Host
// but also kept in Headers.
func ParseRawRequest(raw string) RawRequest {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
var r RawRequest
if len(lines) == 0 {
return r
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 1 {
r.Method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
r.Path = strings.TrimSpace(parts[1])
}
if len(parts) >= 3 {
r.Proto = strings.TrimSpace(parts[2])
}
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
r.Headers = append(r.Headers, RawHeader{k, v})
if strings.EqualFold(k, "host") {
r.Host = v
}
}
i++
}
if i < len(lines) {
r.Body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
}
return r
}
// SortedHeaderLines returns header lines sorted by key name, formatted as
// "Key: Value\n" strings. Useful for deterministic serialisation.
func SortedHeaderLines(h http.Header) []string {
keys := make([]string, 0, len(h))
for k := range h {
keys = append(keys, k)
}
sort.Strings(keys)
var out []string
for _, k := range keys {
for _, v := range h[k] {
out = append(out, fmt.Sprintf("%s: %s\n", k, v))
}
}
return out
}
+20 -1
View File
@@ -1,6 +1,10 @@
package util package util
import "strings" import (
"strings"
"charm.land/lipgloss/v2"
)
func Truncate(s string, max int) string { func Truncate(s string, max int) string {
if len(s) <= max { if len(s) <= max {
@@ -9,6 +13,21 @@ func Truncate(s string, max int) string {
return s[:max-1] + "…" return s[:max-1] + "…"
} }
// CenterLines centers each line horizontally relative to the longest one.
func CenterLines(lines ...string) string {
maxWidth := 0
for _, l := range lines {
if w := lipgloss.Width(l); w > maxWidth {
maxWidth = w
}
}
centered := make([]string, len(lines))
for i, l := range lines {
centered[i] = lipgloss.PlaceHorizontal(maxWidth, lipgloss.Center, l)
}
return strings.Join(centered, "\n")
}
// InferScheme returns "http" for port 80, "https" otherwise. // InferScheme returns "http" for port 80, "https" otherwise.
func InferScheme(host string) string { func InferScheme(host string) string {
if strings.HasSuffix(host, ":80") { if strings.HasSuffix(host, ":80") {
+1
View File
@@ -10,6 +10,7 @@ Checks that the proxy's outbound IP is in an allowed list on startup.
- if no IPs are configured, the check is skipped - if no IPs are configured, the check is skipped
]], ]],
on_start = { sync = false }, on_start = { sync = false },
disable_by_default = true,
} }
local whitelist = {} local whitelist = {}
+1 -1
View File
@@ -32,7 +32,7 @@ mytarget%.com/
!%.png$ !%.png$
``` ```
Example (disable history — h: whitelist never matches any real URL): Example (disable history: whitelist never matches any real URL):
``` ```
h:^$ h:^$
``` ```