mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Compare commits
4 Commits
v0.0.2
..
dbea0ab0f2
| Author | SHA1 | Date | |
|---|---|---|---|
| dbea0ab0f2 | |||
| a6bd5c1071 | |||
| 7879720d07 | |||
| 329216c082 |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "🐞 Bug Report"
|
||||
about: Report a reproducible error
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of the issue.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. See error
|
||||
|
||||
**Expected Behavior**
|
||||
|
||||
What should have happened.
|
||||
|
||||
**Environment**
|
||||
|
||||
- OS:
|
||||
- Version (`spilltea -v`):
|
||||
|
||||
**Additional Context**
|
||||
|
||||
Add any logs or screenshots here.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "🚀 Feature Request"
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE] "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem?**
|
||||
|
||||
A description of what the problem is (e.g. I'm always frustrated when...).
|
||||
|
||||
**Describe the solution**
|
||||
|
||||
A clear description of what you want to happen.
|
||||
|
||||
**Describe alternatives**
|
||||
|
||||
Any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
|
||||
Add any other context or mockups here.
|
||||
@@ -1,19 +0,0 @@
|
||||
## Scopes
|
||||
|
||||
Scopes let you control which requests Spilltea intercepts. Patterns are Go regular expressions matched against `host/path` (e.g. `api.example.com/v1/users`).
|
||||
|
||||
- **Whitelist**: if non-empty, only matching requests are intercepted.
|
||||
- **Blacklist**: matching requests are always ignored, even if whitelisted.
|
||||
|
||||
When both lists are set, a request must pass the whitelist _and_ not be in the blacklist.
|
||||
|
||||
### Examples
|
||||
|
||||
| Pattern | Matches |
|
||||
| -------------------------- | ----------------------------------- |
|
||||
| `example\.com` | any request to `example.com` |
|
||||
| `^api\.example\.com` | only the `api` subdomain |
|
||||
| `example\.com/api/v2` | a specific path prefix |
|
||||
| `\.(js\|css\|png\|woff2?)` | static assets (useful in blacklist) |
|
||||
| `googleapis\.com` | all Google API traffic |
|
||||
| `/graphql$` | any host with a `/graphql` endpoint |
|
||||
@@ -25,7 +25,6 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key
|
||||
- **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding.
|
||||
- **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history.
|
||||
- **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration
|
||||
- **Scopes**: Keep your history clean by white/blacklisting domains or specific paths.
|
||||
- **HTTPS Support** (using go-mitmproxy under the hood)
|
||||
- Built-in Integrations:
|
||||
- **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly.
|
||||
@@ -52,6 +51,16 @@ For a full reference and examples, see the [plugin documentation](./.github/docs
|
||||
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
|
||||
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
|
||||
|
||||
## CLI Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| ---- | ----- | ----------- |
|
||||
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
|
||||
| `--host` | | Proxy host, overrides config |
|
||||
| `--port` | `-p` | Proxy port, overrides config |
|
||||
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session |
|
||||
| `--version` | `-v` | Print version and exit |
|
||||
|
||||
## Deployment
|
||||
|
||||
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
|
||||
|
||||
@@ -33,8 +33,9 @@ type Config struct {
|
||||
} `mapstructure:"tui"`
|
||||
|
||||
Intercept struct {
|
||||
DefaultAutoForward bool `mapstructure:"default_auto_forward"`
|
||||
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
||||
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
||||
} `mapstructure:"intercept"`
|
||||
|
||||
Replay struct {
|
||||
|
||||
@@ -6,8 +6,10 @@ app:
|
||||
plugins_dir: ~/.config/spilltea/plugins
|
||||
|
||||
intercept:
|
||||
default_auto_forward: false
|
||||
default_intercept_enabled: true
|
||||
default_capture_response: false
|
||||
auto_forward_regex:
|
||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||
|
||||
replay:
|
||||
switch_to_page_on_send: false
|
||||
@@ -59,7 +61,7 @@ keybindings:
|
||||
forward_all: "F"
|
||||
drop: "d"
|
||||
drop_all: "D"
|
||||
auto_forward: "a"
|
||||
toggle_intercept: "a"
|
||||
capture_response: "r"
|
||||
undo_edits: "ctrl+z"
|
||||
edit: "e,enter"
|
||||
|
||||
@@ -22,7 +22,7 @@ type InterceptKeys struct {
|
||||
ForwardAll string `mapstructure:"forward_all"`
|
||||
Drop string `mapstructure:"drop"`
|
||||
DropAll string `mapstructure:"drop_all"`
|
||||
AutoForward string `mapstructure:"auto_forward"`
|
||||
ToggleIntercept string `mapstructure:"toggle_intercept"`
|
||||
CaptureResponse string `mapstructure:"capture_response"`
|
||||
UndoEdits string `mapstructure:"undo_edits"`
|
||||
Edit string `mapstructure:"edit"`
|
||||
|
||||
@@ -35,11 +35,6 @@ func (d *DB) migrate() error {
|
||||
request_raw TEXT NOT NULL,
|
||||
response_raw TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS scope (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('whitelist','blacklist')),
|
||||
pattern TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS replay_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME NOT NULL,
|
||||
@@ -69,9 +64,6 @@ func (d *DB) migrate() error {
|
||||
created_at DATETIME NOT NULL,
|
||||
UNIQUE(plugin_name, dedup_key)
|
||||
);
|
||||
INSERT INTO scope (kind, pattern)
|
||||
SELECT 'blacklist', '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM scope);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package db
|
||||
|
||||
func (d *DB) SaveScope(whitelist, blacklist []string) error {
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM scope`); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
for _, p := range whitelist {
|
||||
if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('whitelist', ?)`, p); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, p := range blacklist {
|
||||
if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('blacklist', ?)`, p); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DB) LoadScope() (whitelist, blacklist []string, err error) {
|
||||
rows, err := d.conn.Query(`SELECT kind, pattern FROM scope`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var kind, pattern string
|
||||
if err := rows.Scan(&kind, &pattern); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if kind == "whitelist" {
|
||||
whitelist = append(whitelist, pattern)
|
||||
} else {
|
||||
blacklist = append(blacklist, pattern)
|
||||
}
|
||||
}
|
||||
return whitelist, blacklist, rows.Err()
|
||||
}
|
||||
@@ -41,9 +41,8 @@ type Broker struct {
|
||||
droppedFlows sync.Map // *proxy.Flow → struct{}
|
||||
outOfScope sync.Map // *proxy.Flow → struct{}
|
||||
|
||||
scopeMu sync.RWMutex
|
||||
whitelist []*regexp.Regexp
|
||||
blacklist []*regexp.Regexp
|
||||
autoFwdMu sync.RWMutex
|
||||
autoFwdRegexes []*regexp.Regexp
|
||||
|
||||
onNewEntry func(db.Entry)
|
||||
}
|
||||
@@ -52,77 +51,45 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
|
||||
b.onNewEntry = cb
|
||||
}
|
||||
|
||||
// IsInScope reports whether the given target string (host+path) matches the
|
||||
// current scope rules. Used by the plugin API.
|
||||
func (b *Broker) IsInScope(target string) bool {
|
||||
b.scopeMu.RLock()
|
||||
wl := b.whitelist
|
||||
bl := b.blacklist
|
||||
b.scopeMu.RUnlock()
|
||||
return scopeMatches(wl, bl, target)
|
||||
}
|
||||
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{
|
||||
b := &Broker{
|
||||
Incoming: make(chan *PendingRequest, 64),
|
||||
IncomingResponse: make(chan *PendingResponse, 64),
|
||||
}
|
||||
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Broker) SetCaptureResponse(v bool) {
|
||||
b.captureResponse.Store(v)
|
||||
}
|
||||
|
||||
// SetScope compiles and stores whitelist/blacklist regex patterns.
|
||||
// SetAutoForwardRegex compiles and stores patterns for requests that should
|
||||
// be forwarded automatically without interception or history logging.
|
||||
// Invalid patterns are silently skipped.
|
||||
func (b *Broker) SetScope(whitelist, blacklist []string) {
|
||||
wl := compilePatterns(whitelist)
|
||||
bl := compilePatterns(blacklist)
|
||||
b.scopeMu.Lock()
|
||||
b.whitelist = wl
|
||||
b.blacklist = bl
|
||||
b.scopeMu.Unlock()
|
||||
}
|
||||
|
||||
func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||
out := make([]*regexp.Regexp, 0, len(patterns))
|
||||
func (b *Broker) SetAutoForwardRegex(patterns []string) {
|
||||
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||
for _, p := range patterns {
|
||||
if r, err := regexp.Compile(p); err == nil {
|
||||
out = append(out, r)
|
||||
compiled = append(compiled, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
b.autoFwdMu.Lock()
|
||||
b.autoFwdRegexes = compiled
|
||||
b.autoFwdMu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Broker) matchesScope(f *proxy.Flow) bool {
|
||||
target := f.Request.URL.Host + f.Request.URL.Path
|
||||
b.scopeMu.RLock()
|
||||
wl := b.whitelist
|
||||
bl := b.blacklist
|
||||
b.scopeMu.RUnlock()
|
||||
return scopeMatches(wl, bl, target)
|
||||
}
|
||||
|
||||
func scopeMatches(wl, bl []*regexp.Regexp, target string) bool {
|
||||
if len(wl) > 0 {
|
||||
matched := false
|
||||
for _, r := range wl {
|
||||
func (b *Broker) isAutoForwarded(target string) bool {
|
||||
b.autoFwdMu.RLock()
|
||||
regexes := b.autoFwdRegexes
|
||||
b.autoFwdMu.RUnlock()
|
||||
for _, r := range regexes {
|
||||
if r.MatchString(target) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, r := range bl {
|
||||
if r.MatchString(target) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Broker) SetDB(d *db.DB) {
|
||||
b.dbMu.Lock()
|
||||
@@ -132,7 +99,8 @@ func (b *Broker) SetDB(d *db.DB) {
|
||||
|
||||
// Hold is called from the proxy addon: it blocks until a decision is made in the TUI.
|
||||
func (b *Broker) Hold(f *proxy.Flow) Decision {
|
||||
if !b.matchesScope(f) {
|
||||
target := f.Request.URL.Host + f.Request.URL.Path
|
||||
if b.isAutoForwarded(target) {
|
||||
b.outOfScope.Store(f, struct{}{})
|
||||
return Forward
|
||||
}
|
||||
@@ -168,7 +136,7 @@ func (b *Broker) HoldResponse(f *proxy.Flow) Decision {
|
||||
|
||||
// SaveEntry persists the completed flow to the history DB.
|
||||
// It must be called after HoldResponse and before modifying f.Response.
|
||||
// Flows that were dropped at the request phase are silently skipped.
|
||||
// Flows that were dropped or auto-forwarded are silently skipped.
|
||||
func (b *Broker) SaveEntry(f *proxy.Flow) {
|
||||
b.dbMu.RLock()
|
||||
d := b.database
|
||||
|
||||
@@ -10,7 +10,7 @@ type InterceptKeyMap struct {
|
||||
ForwardAll key.Binding
|
||||
Drop key.Binding
|
||||
DropAll key.Binding
|
||||
AutoForward key.Binding
|
||||
ToggleIntercept key.Binding
|
||||
CaptureResponse key.Binding
|
||||
UndoEdits key.Binding
|
||||
Edit key.Binding
|
||||
@@ -23,7 +23,7 @@ func newInterceptKeyMap(cfg config.InterceptKeys) InterceptKeyMap {
|
||||
ForwardAll: binding(cfg.ForwardAll, "forward all"),
|
||||
Drop: binding(cfg.Drop, "drop"),
|
||||
DropAll: binding(cfg.DropAll, "drop all"),
|
||||
AutoForward: binding(cfg.AutoForward, "auto forward"),
|
||||
ToggleIntercept: binding(cfg.ToggleIntercept, "toggle intercept"),
|
||||
CaptureResponse: binding(cfg.CaptureResponse, "capture response"),
|
||||
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
|
||||
Edit: binding(cfg.Edit, "edit"),
|
||||
@@ -36,6 +36,6 @@ func (ic InterceptKeyMap) Bindings() []key.Binding {
|
||||
ic.Forward, ic.ForwardAll,
|
||||
ic.Drop, ic.DropAll,
|
||||
ic.Edit, ic.EditExternal, ic.UndoEdits,
|
||||
ic.AutoForward, ic.CaptureResponse,
|
||||
ic.ToggleIntercept, ic.CaptureResponse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package plugins
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -65,25 +64,6 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||
return 0
|
||||
}))
|
||||
|
||||
L.SetGlobal("is_in_scope", L.NewFunction(func(L *lua.LState) int {
|
||||
raw := L.CheckString(1)
|
||||
if mgr.broker == nil {
|
||||
L.Push(lua.LTrue)
|
||||
return 1
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
L.Push(lua.LFalse)
|
||||
return 1
|
||||
}
|
||||
path := u.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
L.Push(lua.LBool(mgr.broker.IsInScope(u.Host + path)))
|
||||
return 1
|
||||
}))
|
||||
|
||||
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
|
||||
reason := L.OptString(1, "plugin requested quit")
|
||||
select {
|
||||
|
||||
@@ -36,8 +36,8 @@ func RenderWithTitle(border lipgloss.Style, title, content string, width, height
|
||||
if fillW < 0 {
|
||||
fillW = 0
|
||||
}
|
||||
topLine := "╭" + label + strings.Repeat("─", fillW) + "╮"
|
||||
topLine = lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()).Render(topLine)
|
||||
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
|
||||
topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
||||
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
|
||||
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
||||
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -64,7 +63,6 @@ type Model struct {
|
||||
replay replayUI.Model
|
||||
diff diffUI.Model
|
||||
docs docsUI.Model
|
||||
scope scopeUI.Model
|
||||
pluginsPage pluginsUI.Model
|
||||
findingsPage findingsUI.Model
|
||||
copyAs copyasUI.Model
|
||||
@@ -86,7 +84,6 @@ func New(broker *intercept.Broker, name, path string) Model {
|
||||
replay: replayUI.New(),
|
||||
diff: diffUI.New(),
|
||||
docs: docsUI.New(),
|
||||
scope: scopeUI.New(name, path),
|
||||
pluginsPage: pluginsUI.New(mgr),
|
||||
findingsPage: findingsUI.New(),
|
||||
copyAs: copyasUI.New(),
|
||||
@@ -101,10 +98,6 @@ func New(broker *intercept.Broker, name, path string) Model {
|
||||
m.replay.SetDB(d)
|
||||
m.findingsPage.SetDB(d)
|
||||
mgr.SetDB(d)
|
||||
if wl, bl, err := d.LoadScope(); err == nil {
|
||||
broker.SetScope(wl, bl)
|
||||
m.scope.SetScope(wl, bl)
|
||||
}
|
||||
}
|
||||
|
||||
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
||||
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
|
||||
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
||||
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
|
||||
)
|
||||
|
||||
type page string
|
||||
@@ -20,7 +19,6 @@ const (
|
||||
pageHistory page = "History"
|
||||
pageReplay page = "Replay"
|
||||
pageDiff page = "Diff"
|
||||
pageScopes page = "Scopes"
|
||||
pagePlugins page = "Plugins"
|
||||
pageFindings page = "Findings"
|
||||
pageDocs page = "Docs"
|
||||
@@ -93,19 +91,6 @@ var pageRegistry = []pageEntry{
|
||||
},
|
||||
resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) },
|
||||
},
|
||||
{
|
||||
id: pageScopes,
|
||||
icon: func() string { return icons.I.Scope },
|
||||
|
||||
render: func(m *Model) string { return m.scope.View().Content },
|
||||
update: func(m *Model, msg tea.Msg) tea.Cmd {
|
||||
updated, cmd := m.scope.Update(msg)
|
||||
m.scope = updated.(scopeUI.Model)
|
||||
return cmd
|
||||
},
|
||||
isEditing: func(m *Model) bool { return m.scope.IsEditing() },
|
||||
resize: func(m *Model, w, h int) { m.scope.SetSize(w, h) },
|
||||
},
|
||||
{
|
||||
id: pagePlugins,
|
||||
icon: func() string { return icons.I.Plugin },
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
||||
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
||||
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
||||
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -79,15 +78,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
m.resizeChildren()
|
||||
|
||||
case scopeUI.ScopeChangedMsg:
|
||||
m.broker.SetScope(msg.Whitelist, msg.Blacklist)
|
||||
if m.database != nil {
|
||||
if err := m.database.SaveScope(msg.Whitelist, msg.Blacklist); err != nil {
|
||||
log.Printf("failed to persist scope: %v", err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case proxyPkg.ErrMsg:
|
||||
if msg.Err != nil {
|
||||
log.Printf("proxy error: %v", msg.Err)
|
||||
|
||||
@@ -19,7 +19,6 @@ var contentMarkdown = strings.Join([]string{
|
||||
readDoc("proxy.md"),
|
||||
readDoc("certificate.md"),
|
||||
readDoc("history.md"),
|
||||
readDoc("scopes.md"),
|
||||
}, "\n")
|
||||
|
||||
type Model struct {
|
||||
|
||||
@@ -29,7 +29,7 @@ type Model struct {
|
||||
responseCursor int
|
||||
|
||||
editing bool
|
||||
autoForward bool
|
||||
interceptEnabled bool
|
||||
pendingEdits map[*intercept.PendingRequest]string
|
||||
pendingResponseEdits map[*intercept.PendingResponse]string
|
||||
|
||||
@@ -60,7 +60,7 @@ func New(broker *intercept.Broker) Model {
|
||||
|
||||
return Model{
|
||||
broker: broker,
|
||||
autoForward: cfg.Intercept.DefaultAutoForward,
|
||||
interceptEnabled: cfg.Intercept.DefaultInterceptEnabled,
|
||||
captureResponse: cfg.Intercept.DefaultCaptureResponse,
|
||||
listViewport: lv,
|
||||
responseViewport: rv,
|
||||
|
||||
@@ -15,7 +15,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case intercept.RequestArrivedMsg:
|
||||
if m.autoForward {
|
||||
if !m.interceptEnabled {
|
||||
m.broker.Decide(msg.Req, intercept.Forward)
|
||||
break
|
||||
}
|
||||
@@ -152,9 +152,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Keys.Intercept.AutoForward):
|
||||
m.autoForward = !m.autoForward
|
||||
if m.autoForward {
|
||||
case key.Matches(msg, keys.Keys.Intercept.ToggleIntercept):
|
||||
m.interceptEnabled = !m.interceptEnabled
|
||||
if !m.interceptEnabled {
|
||||
for len(m.queue) > 0 {
|
||||
m.applyAndDecide(intercept.Forward)
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ func (m *Model) renderListPanel(w, h int) string {
|
||||
)
|
||||
|
||||
title := icons.I.Request + "Requests"
|
||||
if m.autoForward {
|
||||
title += " [auto forward]"
|
||||
if !m.interceptEnabled {
|
||||
title += " " + lipgloss.NewStyle().Foreground(style.S.Error).Render("[intercept off]")
|
||||
}
|
||||
return style.RenderWithTitle(border, title, inner, w, h)
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
const (
|
||||
fieldNone = -1
|
||||
fieldWhitelist = 0
|
||||
fieldBlacklist = 1
|
||||
)
|
||||
|
||||
const (
|
||||
minTaH = 3
|
||||
maxTaH = 12
|
||||
fixedH = 8 // (blank + label + desc + blank) x2
|
||||
)
|
||||
|
||||
type ScopeChangedMsg struct {
|
||||
Whitelist []string
|
||||
Blacklist []string
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
focusIdx int
|
||||
|
||||
wlTextarea textarea.Model
|
||||
blTextarea textarea.Model
|
||||
|
||||
innerH int
|
||||
width int
|
||||
height int
|
||||
|
||||
help help.Model
|
||||
}
|
||||
|
||||
func New(name, path string) Model {
|
||||
wl := style.NewTextarea(true)
|
||||
wl.Placeholder = "one pattern per line..."
|
||||
|
||||
bl := style.NewTextarea(true)
|
||||
bl.Placeholder = "one pattern per line..."
|
||||
bl.Blur()
|
||||
|
||||
return Model{
|
||||
focusIdx: fieldNone,
|
||||
wlTextarea: wl,
|
||||
blTextarea: bl,
|
||||
help: style.NewHelp(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m *Model) SetScope(whitelist, blacklist []string) {
|
||||
m.wlTextarea.SetValue(strings.Join(whitelist, "\n"))
|
||||
m.blTextarea.SetValue(strings.Join(blacklist, "\n"))
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.syncLayout()
|
||||
}
|
||||
|
||||
func (m *Model) syncLayout() {
|
||||
if m.width == 0 {
|
||||
return
|
||||
}
|
||||
m.help.SetWidth(m.width - 2)
|
||||
|
||||
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
|
||||
panelH := m.height - statusH
|
||||
m.innerH = max(1, style.PanelContentH(panelH))
|
||||
|
||||
taH := (m.innerH - fixedH) / 2
|
||||
if taH < minTaH {
|
||||
taH = minTaH
|
||||
}
|
||||
if taH > maxTaH {
|
||||
taH = maxTaH
|
||||
}
|
||||
// width - 2 (panel border) - 1 (leading space in view) - 3 (right margin + cursor)
|
||||
taW := max(1, m.width-6)
|
||||
m.wlTextarea.SetWidth(taW)
|
||||
m.wlTextarea.SetHeight(taH)
|
||||
m.blTextarea.SetWidth(taW)
|
||||
m.blTextarea.SetHeight(taH)
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool {
|
||||
return m.focusIdx == fieldWhitelist || m.focusIdx == fieldBlacklist
|
||||
}
|
||||
|
||||
func (m *Model) scopeChangedCmd() tea.Cmd {
|
||||
wl := parseLines(m.wlTextarea.Value())
|
||||
bl := parseLines(m.blTextarea.Value())
|
||||
return func() tea.Msg {
|
||||
return ScopeChangedMsg{Whitelist: wl, Blacklist: bl}
|
||||
}
|
||||
}
|
||||
|
||||
func parseLines(s string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if t := strings.TrimSpace(line); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) renderStatusBar() string {
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(
|
||||
m.help.View(formKeyMap{focusIdx: m.focusIdx}),
|
||||
)
|
||||
}
|
||||
|
||||
type formKeyMap struct {
|
||||
focusIdx int
|
||||
}
|
||||
|
||||
func (k formKeyMap) ShortHelp() []key.Binding {
|
||||
cycle := keys.Keys.Global.CycleFocus
|
||||
hlp := keys.Keys.Global.Help
|
||||
|
||||
switch k.focusIdx {
|
||||
case fieldWhitelist, fieldBlacklist:
|
||||
esc := keys.Keys.Global.Escape
|
||||
escBinding := key.NewBinding(key.WithKeys(esc.Keys()...), key.WithHelp(esc.Help().Key, "unfocus"))
|
||||
return []key.Binding{
|
||||
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")),
|
||||
escBinding,
|
||||
cycle,
|
||||
}
|
||||
}
|
||||
return []key.Binding{cycle, hlp}
|
||||
}
|
||||
|
||||
func (k formKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{k.ShortHelp()}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
kp, isKey := msg.(tea.KeyPressMsg)
|
||||
if !isKey {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if key.Matches(kp, keys.Keys.Global.CycleFocus) {
|
||||
return m.cycleFocus()
|
||||
}
|
||||
|
||||
if key.Matches(kp, keys.Keys.Global.Help) && !m.IsEditing() {
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.focusIdx {
|
||||
case fieldWhitelist:
|
||||
if key.Matches(kp, keys.Keys.Global.Escape) {
|
||||
return m.blurAll()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.wlTextarea, cmd = m.wlTextarea.Update(kp)
|
||||
return m, cmd
|
||||
|
||||
case fieldBlacklist:
|
||||
if key.Matches(kp, keys.Keys.Global.Escape) {
|
||||
return m.blurAll()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.blTextarea, cmd = m.blTextarea.Update(kp)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) blurAll() (tea.Model, tea.Cmd) {
|
||||
m.wlTextarea.Blur()
|
||||
m.blTextarea.Blur()
|
||||
m.focusIdx = fieldNone
|
||||
m.syncLayout()
|
||||
return m, m.scopeChangedCmd()
|
||||
}
|
||||
|
||||
func (m Model) cycleFocus() (tea.Model, tea.Cmd) {
|
||||
scopeCmd := m.scopeChangedCmd()
|
||||
|
||||
var focusCmd tea.Cmd
|
||||
switch m.focusIdx {
|
||||
case fieldNone, fieldBlacklist:
|
||||
m.blTextarea.Blur()
|
||||
m.focusIdx = fieldWhitelist
|
||||
focusCmd = m.wlTextarea.Focus()
|
||||
case fieldWhitelist:
|
||||
m.wlTextarea.Blur()
|
||||
m.focusIdx = fieldBlacklist
|
||||
focusCmd = m.blTextarea.Focus()
|
||||
}
|
||||
|
||||
m.syncLayout()
|
||||
return m, tea.Batch(focusCmd, scopeCmd)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
if m.width == 0 {
|
||||
return tea.NewView("")
|
||||
}
|
||||
|
||||
s := style.S
|
||||
|
||||
statusBar := m.renderStatusBar()
|
||||
statusH := strings.Count(statusBar, "\n") + 1
|
||||
panelH := m.height - statusH
|
||||
innerH := max(1, style.PanelContentH(panelH))
|
||||
|
||||
taH := (innerH - fixedH) / 2
|
||||
if taH < minTaH {
|
||||
taH = minTaH
|
||||
}
|
||||
if taH > maxTaH {
|
||||
taH = maxTaH
|
||||
}
|
||||
|
||||
var lines []string
|
||||
add := func(l string) { lines = append(lines, l) }
|
||||
|
||||
add("")
|
||||
add(fieldLabel("Whitelist", m.focusIdx == fieldWhitelist))
|
||||
add(" " + s.Faint.Render("If non-empty, only matching requests are intercepted."))
|
||||
add("")
|
||||
wlContentLines := strings.Count(m.wlTextarea.Value(), "\n") + 1
|
||||
for _, l := range taLines(m.wlTextarea.View(), taH, wlContentLines) {
|
||||
add(" " + l)
|
||||
}
|
||||
|
||||
add("")
|
||||
add(fieldLabel("Blacklist", m.focusIdx == fieldBlacklist))
|
||||
add(" " + s.Faint.Render("Matching requests are always excluded from history."))
|
||||
add("")
|
||||
blContentLines := strings.Count(m.blTextarea.Value(), "\n") + 1
|
||||
for _, l := range taLines(m.blTextarea.View(), taH, blContentLines) {
|
||||
add(" " + l)
|
||||
}
|
||||
|
||||
for len(lines) < innerH {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
content := strings.Join(lines[:innerH], "\n")
|
||||
|
||||
panel := style.RenderWithTitle(s.PanelFocused, icons.I.Scope+"Scopes", content, m.width, panelH)
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, panel, statusBar))
|
||||
}
|
||||
|
||||
func fieldLabel(name string, focused bool) string {
|
||||
s := style.S
|
||||
c := s.MutedFg
|
||||
if focused {
|
||||
c = s.Primary
|
||||
}
|
||||
return " " + lipgloss.NewStyle().Foreground(c).Bold(focused).Render(name)
|
||||
}
|
||||
|
||||
func taLines(view string, h int, contentLines int) []string {
|
||||
raw := strings.Split(strings.TrimRight(view, "\n"), "\n")
|
||||
tilde := style.S.Faint.Render("~")
|
||||
for len(raw) < h {
|
||||
raw = append(raw, tilde)
|
||||
}
|
||||
if len(raw) > h {
|
||||
raw = raw[:h]
|
||||
}
|
||||
for i := contentLines; i < len(raw); i++ {
|
||||
raw[i] = tilde
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Reference in New Issue
Block a user