Change plugins behavior

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-13 16:52:12 +02:00
parent dbea0ab0f2
commit 4eb9dd53f5
23 changed files with 740 additions and 241 deletions
+44 -21
View File
@@ -1,6 +1,7 @@
# Plugins # Plugins
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic. Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
You can found some pre-built plugins [here](../../plugins/).
## Where to place plugins ## Where to place plugins
@@ -15,25 +16,27 @@ 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.",
priority = 0, -- higher = runs before other plugins (default: 0)
-- Declare which hooks you use and whether they are synchronous. -- Declare which hooks you use and whether they are synchronous.
on_start = { sync = true }, on_start = { sync = true },
on_request = { sync = true }, on_request = { sync = true },
on_response = { sync = false }, on_response = { sync = false },
on_history_entry = {}, on_history_entry = { sync = true },
on_quit = {},
} }
``` ```
### Hook reference ### Hook reference
| Hook | When called | Sync/async | Return value | | Hook | When called | Sync/async | Return value (sync only) |
| ------------------------- | --------------------------- | ------------ | ------------------- | | ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- |
| `on_start(config_text)` | Once at startup | 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_quit()` | When the app exits | always sync | ignored | | `on_quit()` | When the app exits | always sync | ignored |
| `on_request(req)` | Every request | declared | `"drop"`, `"forward"`, or `nil` (sync only) | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_response(req, res)` | Every response | declared | `"drop"`, `"forward"`, or `nil` (sync only) | | `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_history_entry(entry)` | After a flow is saved to DB | always async | ignored | | `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
@@ -80,7 +83,8 @@ Plugin = {
log("message") log("message")
-- Send a notification bubble in the TUI -- Send a notification bubble in the TUI
notif("Title", "Body text") -- kind is optional: "info" (default), "success", "warning", "error"
notif("Title", "Body text", "warning")
-- Create a finding (shown on the Findings page, persisted in DB) -- Create a finding (shown on the Findings page, persisted in DB)
create_finding({ create_finding({
@@ -90,8 +94,8 @@ create_finding({
severity = "high", -- info | low | medium | high | critical severity = "high", -- info | low | medium | high | critical
}) })
-- Check if a URL matches the current scope (whitelist/blacklist) -- Run a raw SQL query against the history DB
local ok = is_in_scope("https://example.com/api/v1") local rows, err = db_query("SELECT id, host FROM entries WHERE host = ?", "example.com")
-- Quit the app (useful for startup checks that fail) -- Quit the app (useful for startup checks that fail)
quit("reason message") quit("reason message")
@@ -103,25 +107,44 @@ A finding is identified by `(plugin_name, key)`. If a finding with that pair alr
## Configuration ## Configuration
Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_start(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.). Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.).
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI.
## Sync vs async ## Sync vs async
- **`sync = true`**: spilltea waits for the hook to return before continuing. For `on_request`/`on_response` this blocks the proxy goroutine; the hook can return one of the values below. - **`sync = true`**: spilltea waits for the hook to return before continuing. The hook can return a decision value (see below).
- **`sync = false`** (or omitted for supported hooks): the hook runs in a background goroutine. Return values are ignored. Use this for analysis and findings. - **`sync = false`** (default): the hook runs in a background goroutine. Return values are ignored.
### Return values for `on_request` and `on_response` (sync only) ### Return values for sync hooks
**`on_request` and `on_response`:**
| Return value | Effect | | Return value | Effect |
| ------------ | ------ | | ------------ | --------------------------------------------------------------------------------- |
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. | | `"drop"` | The flow is dropped immediately and never shown in the intercept panel. |
| `"forward"` | The flow is forwarded immediately without going through the intercept panel. | | `"forward"` | The flow is forwarded immediately without going through the intercept panel. |
| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. | | `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. |
The `sync` declaration is only meaningful for `on_request` and `on_response`. The other hooks have fixed behaviour: **`on_history_entry` (sync only):**
- `on_start` is **always synchronous**: plugins are initialised one by one before the first request is accepted. | Return value | Effect |
- `on_quit` is **always synchronous**: the app waits for all `on_quit` hooks before exiting. | ------------------- | -------------------------------------- |
- `on_history_entry` is **always asynchronous**. | `"skip"` | The entry is not saved to the DB. |
| `"keep"` or `nil` | The entry is saved normally. |
> A sync `on_request` or `on_response` hook that hangs will block traffic for that flow. There is no automatic timeout. 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.
## Priority
Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
```lua
Plugin = {
name = "Scopes",
priority = 100, -- runs before all default-priority plugins
...
}
```
> A sync hook that hangs will block traffic for that flow. There is no automatic timeout.
-48
View File
@@ -1,48 +0,0 @@
-- Check that the proxy's outbound IP is in the whitelist before starting.
-- Config: one allowed IP per line. Leave empty to disable the check.
Plugin = {
name = "IP Whitelist",
on_start = {},
}
function on_start(config_text)
local allowed = {}
for line in config_text:gmatch("[^\n]+") do
local ip = line:match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(allowed, ip)
end
end
if #allowed == 0 then
log("no IPs configured, skipping check")
return
end
-- Fetch the current outbound IP via a public API.
local ok, result = pcall(function()
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
if not handle then return nil end
local ip = handle:read("*a")
handle:close()
return ip and ip:match("^%s*(.-)%s*$") or nil
end)
if not ok or not result or result == "" then
log("could not determine outbound IP, skipping check")
return
end
log("outbound IP: " .. result)
for _, ip in ipairs(allowed) do
if result == ip then
log("IP " .. result .. " is whitelisted")
return
end
end
notif("IP Whitelist", "Outbound IP " .. result .. " is NOT in the whitelist!")
quit("outbound IP " .. result .. " not whitelisted")
end
-35
View File
@@ -1,35 +0,0 @@
-- Scan response bodies for common API key / secret patterns.
-- Runs asynchronously so it never delays traffic.
Plugin = {
name = "Secret Finder",
on_response = { sync = false },
}
local PATTERNS = {
{ pattern = "AIza[0-9A-Za-z%-_]{35}", label = "Google API Key" },
{ pattern = "AKIA[0-9A-Z]{16}", label = "AWS Access Key" },
{ pattern = "sk%-[a-zA-Z0-9]{20,}", label = "OpenAI API Key" },
{ pattern = "ghp_[a-zA-Z0-9]{36}", label = "GitHub Personal Token" },
{ pattern = "Bearer%s+[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+",
label = "JWT Bearer Token" },
}
function on_response(req, res)
local body = res:get_body()
if body == "" then return end
for _, p in ipairs(PATTERNS) do
if body:find(p.pattern) then
local key = p.label .. ":" .. req.host
create_finding({
title = p.label .. " in response",
description = "**Host:** `" .. req.host .. "`\n\n" ..
"**Path:** `" .. req.path .. "`\n\n" ..
"Pattern `" .. p.pattern .. "` matched in the response body.",
key = key,
severity = "high",
})
end
end
end
+4 -2
View File
@@ -44,7 +44,7 @@ On startup, you choose:
## Plugin System ## Plugin System
Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code. Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code.
For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md). For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md) or [plugin examples](./plugins/).
## Configuration ## Configuration
@@ -54,12 +54,14 @@ Check the default configuration with all the options [here](./internal/config/de
## CLI Flags ## CLI Flags
| Flag | Short | Description | | Flag | Short | Description |
| ---- | ----- | ----------- | | ----------------------- | ----- | ------------------------------------------------------------------------------ |
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) | | `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
| `--plugin-dir` | | Path to plugins dir, overrides config (default: `~/.config/spilltea/plugins/`) |
| `--host` | | Proxy host, overrides config | | `--host` | | Proxy host, overrides config |
| `--port` | `-p` | Proxy port, overrides config | | `--port` | `-p` | Proxy port, overrides config |
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session | | `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session |
| `--version` | `-v` | Print version and exit | | `--version` | `-v` | Print version and exit |
| `--add-default-plugins` | | Add the default plugins to your plugins dir and exit |
## Deployment ## Deployment
+28
View File
@@ -11,6 +11,7 @@ import (
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
spilltea "github.com/anotherhadi/spilltea"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
appUI "github.com/anotherhadi/spilltea/internal/ui/app" appUI "github.com/anotherhadi/spilltea/internal/ui/app"
homeUI "github.com/anotherhadi/spilltea/internal/ui/home" homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
@@ -23,10 +24,12 @@ var version = "dev"
func main() { func main() {
var ( var (
flagConfig = flag.StringP("config", "c", "", "path to config file") flagConfig = flag.StringP("config", "c", "", "path to config file")
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
flagHost = flag.String("host", "", "proxy host (overrides config)") flagHost = flag.String("host", "", "proxy host (overrides config)")
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)") flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
flagVersion = flag.BoolP("version", "v", false, "print version") flagVersion = flag.BoolP("version", "v", false, "print version")
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`) flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
) )
flag.Parse() flag.Parse()
@@ -35,6 +38,28 @@ func main() {
os.Exit(0) os.Exit(0)
} }
if *flagAddDefaultPlugins {
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
if *flagConfig != "" {
cfgPath = *flagConfig
}
if err := config.Load(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
dir := config.ExpandPath(config.Global.App.PluginsDir)
if *flagPluginsDir != "" {
dir = *flagPluginsDir
}
n, err := spilltea.InstallDefaultPlugins(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err)
os.Exit(1)
}
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
os.Exit(0)
}
if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) { if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) {
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject) fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
os.Exit(1) os.Exit(1)
@@ -51,6 +76,9 @@ func main() {
} }
config.Global.Version = version config.Global.Version = version
if *flagPluginsDir != "" {
config.Global.App.PluginsDir = *flagPluginsDir
}
if *flagHost != "" { if *flagHost != "" {
config.Global.App.Host = *flagHost config.Global.App.Host = *flagHost
} }
+6
View File
@@ -68,6 +68,12 @@ CREATE TABLE IF NOT EXISTS replay_entries (
return err return err
} }
// Query executes a SQL query and returns the rows. The caller must close the
// returned rows. Args are passed as positional parameters.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
return d.conn.Query(query, args...)
}
func (d *DB) Close() error { func (d *DB) Close() error {
if d == nil { if d == nil {
return nil return nil
+13 -2
View File
@@ -44,9 +44,14 @@ type Broker struct {
autoFwdMu sync.RWMutex autoFwdMu sync.RWMutex
autoFwdRegexes []*regexp.Regexp autoFwdRegexes []*regexp.Regexp
onBeforeNewEntry func(db.Entry) bool
onNewEntry func(db.Entry) onNewEntry func(db.Entry)
} }
func (b *Broker) SetOnBeforeNewEntry(cb func(db.Entry) bool) {
b.onBeforeNewEntry = cb
}
func (b *Broker) SetOnNewEntry(cb func(db.Entry)) { func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
b.onNewEntry = cb b.onNewEntry = cb
} }
@@ -165,7 +170,7 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
return return
} }
} }
entry, err := d.InsertEntry(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,
@@ -173,7 +178,13 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
StatusCode: status, StatusCode: status,
RequestRaw: FormatRawRequest(f), RequestRaw: FormatRawRequest(f),
ResponseRaw: FormatRawResponse(f), ResponseRaw: FormatRawResponse(f),
}) }
if cb := b.onBeforeNewEntry; cb != nil {
if !cb(pending) {
return
}
}
entry, err := d.InsertEntry(pending)
if err == nil { if err == nil {
if cb := b.onNewEntry; cb != nil { if cb := b.onNewEntry; cb != nil {
go cb(entry) go cb(entry)
+82 -1
View File
@@ -26,8 +26,9 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int {
title := L.CheckString(1) title := L.CheckString(1)
body := L.CheckString(2) body := L.CheckString(2)
kind := L.OptString(3, "info")
select { select {
case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}: case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body, Kind: kind}:
default: default:
} }
return 0 return 0
@@ -64,6 +65,86 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
return 0 return 0
})) }))
L.SetGlobal("db_query", L.NewFunction(func(L *lua.LState) int {
if mgr.db == nil {
L.Push(lua.LNil)
L.Push(lua.LString("db not available"))
return 2
}
query := L.CheckString(1)
var args []any
for i := 2; i <= L.GetTop(); i++ {
switch v := L.Get(i).(type) {
case lua.LString:
args = append(args, string(v))
case lua.LNumber:
args = append(args, float64(v))
case lua.LBool:
args = append(args, bool(v))
default:
args = append(args, nil)
}
}
rows, err := mgr.db.Query(query, args...)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
result := L.NewTable()
rowIdx := 1
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
row := L.NewTable()
for i, col := range cols {
switch v := vals[i].(type) {
case int64:
L.SetField(row, col, lua.LNumber(v))
case float64:
L.SetField(row, col, lua.LNumber(v))
case string:
L.SetField(row, col, lua.LString(v))
case []byte:
L.SetField(row, col, lua.LString(string(v)))
case bool:
if v {
L.SetField(row, col, lua.LTrue)
} else {
L.SetField(row, col, lua.LFalse)
}
case nil:
L.SetField(row, col, lua.LNil)
}
}
L.RawSetInt(result, rowIdx, row)
rowIdx++
}
if err := rows.Err(); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(result)
L.Push(lua.LNil)
return 2
}))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
reason := L.OptString(1, "plugin requested quit") reason := L.OptString(1, "plugin requested quit")
select { select {
+90 -32
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
@@ -32,7 +33,8 @@ func NewManager(broker *intercept.Broker) *Manager {
Quit: make(chan string, 4), Quit: make(chan string, 4),
} }
if broker != nil { if broker != nil {
broker.SetOnNewEntry(mgr.RunOnHistoryEntry) broker.SetOnBeforeNewEntry(mgr.RunSyncOnHistoryEntry)
broker.SetOnNewEntry(mgr.RunAsyncOnHistoryEntry)
} }
return mgr return mgr
} }
@@ -107,27 +109,41 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua") p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
} }
// Defaults when not overridden by the Plugin table. if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
hookDefaults := map[string]bool{ p.Description = string(s)
"on_start": true, // always sync
"on_request": false, // async
"on_response": false, // async
"on_quit": true, // always sync
"on_history_entry": false, // always async
} }
for hookName, defaultSync := range hookDefaults {
// Plugin table entry overrides the default (except on_start/on_quit/on_history_entry which are fixed). if n, ok := pluginTable.RawGetString("priority").(lua.LNumber); ok {
if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" { p.Priority = int(n)
}
// Hooks configurable via the Plugin table (sync field).
configurableHooks := map[string]bool{
"on_start": false, // async by default
"on_request": false,
"on_response": false,
"on_history_entry": false,
}
// Fixed-sync hooks: always sync, not configurable.
fixedSyncHooks := map[string]struct{}{
"on_config": {},
"on_quit": {},
}
for hookName, defaultSync := range configurableHooks {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue} p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue continue
} }
}
// Auto-detect: register the hook if the function exists as a global.
if p.L.GetGlobal(hookName) != lua.LNil { if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: defaultSync} p.hooks[hookName] = HookConfig{Sync: defaultSync}
} }
} }
for hookName := range fixedSyncHooks {
if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: true}
}
}
return p, nil return p, nil
} }
@@ -137,6 +153,7 @@ 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
} }
@@ -179,45 +196,61 @@ func (m *Manager) SaveConfig(name, configText string) {
found.mu.Lock() found.mu.Lock()
found.ConfigText = configText found.ConfigText = configText
enabled := found.Enabled enabled := found.Enabled
hc, hasOnStart := found.hooks["on_start"] _, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText) _ = m.db.SavePluginState(name, enabled, configText)
} }
if !hasOnStart { if !hasOnConfig {
return return
} }
// Re-run on_start so the plugin can re-parse the new config. // on_config is always sync.
if hc.Sync {
found.mu.Lock() found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err) log.Printf("plugin %s on_config (config reload): %v", name, err)
} }
found.mu.Unlock() found.mu.Unlock()
} else {
go func() {
found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
found.mu.Unlock()
}()
}
} }
func (m *Manager) RunOnStart() { func (m *Manager) RunOnStart() {
// on_config runs first, always sync, for every enabled plugin that has it.
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_start"]; !ok { if _, ok := p.hooks["on_config"]; !ok {
continue continue
} }
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil { if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
log.Printf("plugin %s on_config: %v", p.Name, err)
}
p.mu.Unlock()
}
// on_start runs after, sync or async depending on plugin config.
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_start"]
if !ok {
continue
}
if hc.Sync {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err) log.Printf("plugin %s on_start: %v", p.Name, err)
} }
p.mu.Unlock() p.mu.Unlock()
} else {
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
} }
@@ -327,12 +360,37 @@ func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
} }
} }
func (m *Manager) RunOnHistoryEntry(e db.Entry) { // RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_history_entry"]; !ok { hc, ok := p.hooks["on_history_entry"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_history_entry", pushEntry(p.L, e))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
continue
}
if result == "skip" {
return false
}
}
return true
}
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_history_entry"]
if !ok || hc.Sync {
continue continue
} }
go func(p *Plugin) { go func(p *Plugin) {
+14 -2
View File
@@ -12,9 +12,11 @@ type HookConfig struct {
type Plugin struct { type Plugin struct {
Name string Name string
Description string
FilePath string FilePath string
Enabled bool Enabled bool
ConfigText string ConfigText string
Priority int
L *lua.LState L *lua.LState
mu sync.Mutex mu sync.Mutex
@@ -36,22 +38,31 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
type Info struct { type Info struct {
Name string Name string
Description string
FilePath string FilePath string
Enabled bool Enabled bool
ConfigText string ConfigText string
Priority int
Hooks map[string]HookConfig Hooks map[string]HookConfig
} }
func (p *Plugin) Info() Info { func (p *Plugin) Info() Info {
p.mu.Lock()
enabled := p.Enabled
configText := p.ConfigText
p.mu.Unlock()
hooks := make(map[string]HookConfig, len(p.hooks)) hooks := make(map[string]HookConfig, len(p.hooks))
for k, v := range p.hooks { for k, v := range p.hooks {
hooks[k] = v hooks[k] = v
} }
return Info{ return Info{
Name: p.Name, Name: p.Name,
Description: p.Description,
FilePath: p.FilePath, FilePath: p.FilePath,
Enabled: p.Enabled, Enabled: enabled,
ConfigText: p.ConfigText, ConfigText: configText,
Priority: p.Priority,
Hooks: hooks, Hooks: hooks,
} }
} }
@@ -59,6 +70,7 @@ func (p *Plugin) Info() Info {
type PluginNotifMsg struct { type PluginNotifMsg struct {
Title string Title string
Body string Body string
Kind string // "info", "success", "warning", "error"
} }
type PluginQuitMsg struct { type PluginQuitMsg struct {
+7 -2
View File
@@ -28,15 +28,20 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
ta.Prompt = "" ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0 ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles() ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle() ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle() ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text) ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg) ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ta.SetStyles(ts) ta.SetStyles(ts)
return ta return ta
} }
+6
View File
@@ -24,6 +24,7 @@ type Styles struct {
Panel lipgloss.Style Panel lipgloss.Style
PanelFocused lipgloss.Style PanelFocused lipgloss.Style
PanelEditing lipgloss.Style
PagerDotActive string PagerDotActive string
PagerDotInactive string PagerDotInactive string
@@ -43,6 +44,7 @@ func Init(cfg *config.Config) {
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
success := lipgloss.Color("#" + c.Base0B) // Green: success success := lipgloss.Color("#" + c.Base0B) // Green: success
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
S = &Styles{ S = &Styles{
Primary: primary, Primary: primary,
@@ -66,6 +68,10 @@ func Init(cfg *config.Config) {
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(primary), BorderForeground(primary),
PanelEditing: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(purple),
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
} }
+10 -1
View File
@@ -44,11 +44,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case plugins.PluginNotifMsg: case plugins.PluginNotifMsg:
cmd := plugins.WaitForNotif(m.pluginManager) cmd := plugins.WaitForNotif(m.pluginManager)
kind := notificationsUI.KindInfo
switch msg.Kind {
case "success":
kind = notificationsUI.KindSuccess
case "warning":
kind = notificationsUI.KindWarning
case "error":
kind = notificationsUI.KindError
}
notifCmd := func() tea.Msg { notifCmd := func() tea.Msg {
return notificationsUI.NotificationMsg{ return notificationsUI.NotificationMsg{
Title: msg.Title, Title: msg.Title,
Body: msg.Body, Body: msg.Body,
Kind: notificationsUI.KindInfo, Kind: kind,
} }
} }
return m, tea.Batch(cmd, notifCmd) return m, tea.Batch(cmd, notifCmd)
+10
View File
@@ -13,6 +13,16 @@ import (
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case intercept.RequestArrivedMsg: case intercept.RequestArrivedMsg:
if !m.interceptEnabled { if !m.interceptEnabled {
+52 -12
View File
@@ -1,7 +1,6 @@
package plugins package plugins
import ( import (
"os"
"strings" "strings"
"charm.land/bubbles/v2/help" "charm.land/bubbles/v2/help"
@@ -25,6 +24,7 @@ type Model struct {
filtered []plugins.Info filtered []plugins.Info
listViewport viewport.Model listViewport viewport.Model
detailViewport viewport.Model
textarea textarea.Model textarea textarea.Model
filterInput textinput.Model filterInput textinput.Model
filtering bool filtering bool
@@ -36,7 +36,7 @@ type Model struct {
} }
func New(mgr *plugins.Manager) Model { func New(mgr *plugins.Manager) Model {
ta := style.NewTextarea(false) ta := style.NewTextarea(true)
ta.Placeholder = "plugin configuration..." ta.Placeholder = "plugin configuration..."
ta.Blur() ta.Blur()
@@ -46,6 +46,7 @@ func New(mgr *plugins.Manager) Model {
return Model{ return Model{
manager: mgr, manager: mgr,
listViewport: style.NewViewport(), listViewport: style.NewViewport(),
detailViewport: style.NewViewport(),
textarea: ta, textarea: ta,
filterInput: fi, filterInput: fi,
pager: style.NewPaginator(), pager: style.NewPaginator(),
@@ -88,10 +89,27 @@ func (m *Model) recalcSizes() {
} }
m.filterInput.SetWidth(inner - 2) m.filterInput.SetWidth(inner - 2)
detailContentH := style.PanelContentH(detailH)
const headerH = 2
const configFixedH = 2 // blank line + label line
textareaH := max(3, detailContentH/3)
if textareaH > 12 {
textareaH = 12
}
configTotalH := 0
if m.hasConfig() {
configTotalH = configFixedH + textareaH
}
descVH := max(1, detailContentH-headerH-configTotalH)
m.detailViewport.SetWidth(inner)
m.detailViewport.SetHeight(descVH)
m.textarea.SetWidth(max(1, inner-2)) m.textarea.SetWidth(max(1, inner-2))
m.textarea.SetHeight(max(3, detailH-6)) m.textarea.SetHeight(max(3, textareaH))
m.refreshListViewport() m.refreshListViewport()
m.syncDetailViewport()
} }
// Refresh reloads the plugin list from the manager. // Refresh reloads the plugin list from the manager.
@@ -126,6 +144,7 @@ func (m *Model) applyFilter() {
} }
m.refreshListViewport() m.refreshListViewport()
m.syncTextarea() m.syncTextarea()
m.syncDetailViewport()
} }
func (m *Model) selected() (plugins.Info, bool) { func (m *Model) selected() (plugins.Info, bool) {
@@ -135,6 +154,15 @@ func (m *Model) selected() (plugins.Info, bool) {
return m.filtered[m.cursor], true return m.filtered[m.cursor], true
} }
func (m *Model) hasConfig() bool {
info, ok := m.selected()
if !ok {
return false
}
_, has := info.Hooks["on_config"]
return has
}
func (m *Model) syncTextarea() { func (m *Model) syncTextarea() {
if m.editing { if m.editing {
return return
@@ -147,6 +175,16 @@ func (m *Model) syncTextarea() {
m.textarea.SetValue(info.ConfigText) m.textarea.SetValue(info.ConfigText)
} }
func (m *Model) syncDetailViewport() {
info, ok := m.selected()
if !ok || info.Description == "" {
m.detailViewport.SetContent("")
return
}
desc := renderPluginDescription(info.Description, m.width-6)
m.detailViewport.SetContent(desc)
}
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
@@ -155,15 +193,10 @@ func (m *Model) refreshListViewport() {
m.listViewport.SetContent(m.renderList()) m.listViewport.SetContent(m.renderList())
} }
func shortenPath(p string) string { type pluginsKeyMap struct {
home := os.Getenv("HOME") editing bool
if home != "" && strings.HasPrefix(p, home) { hasConfig bool
return "~" + p[len(home):]
} }
return p
}
type pluginsKeyMap struct{ editing bool }
func (k pluginsKeyMap) ShortHelp() []key.Binding { func (k pluginsKeyMap) ShortHelp() []key.Binding {
pk := keys.Keys.Plugins pk := keys.Keys.Plugins
@@ -172,7 +205,14 @@ func (k pluginsKeyMap) ShortHelp() []key.Binding {
esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit")) esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit"))
return []key.Binding{esc} return []key.Binding{esc}
} }
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter} scrollHint := key.NewBinding(
key.WithKeys(g.ScrollUp.Keys()...),
key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"),
)
if k.hasConfig {
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter, scrollHint}
}
return []key.Binding{pk.Toggle, pk.Filter, scrollHint}
} }
func (k pluginsKeyMap) FullHelp() [][]key.Binding { func (k pluginsKeyMap) FullHelp() [][]key.Binding {
+39 -3
View File
@@ -21,7 +21,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
}
}
case tea.KeyPressMsg: case tea.KeyPressMsg:
pk := keys.Keys.Plugins pk := keys.Keys.Plugins
g := keys.Keys.Global g := keys.Keys.Global
@@ -90,15 +110,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
m.refreshListViewport() m.recalcSizes()
m.syncTextarea() m.syncTextarea()
m.detailViewport.GotoTop()
} }
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
if m.cursor < len(m.filtered)-1 { if m.cursor < len(m.filtered)-1 {
m.cursor++ m.cursor++
m.refreshListViewport() m.recalcSizes()
m.syncTextarea() m.syncTextarea()
m.detailViewport.GotoTop()
} }
case key.Matches(msg, pk.Toggle): case key.Matches(msg, pk.Toggle):
@@ -115,11 +137,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, pk.EditConfig): case key.Matches(msg, pk.EditConfig):
if _, ok := m.selected(); ok { if _, ok := m.selected(); ok && m.hasConfig() {
m.editing = true m.editing = true
m.textarea.Focus() m.textarea.Focus()
} }
case key.Matches(msg, g.ScrollUp):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
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()
+53 -14
View File
@@ -1,10 +1,13 @@
package plugins package plugins
import ( import (
"path/filepath"
"strings" "strings"
"charm.land/glamour/v2"
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/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"
@@ -27,23 +30,29 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
dots := s.Faint.Render(m.pager.View()) dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
) )
return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h) return style.RenderWithTitle(panelStyle, icons.I.Plugin+"Plugins", inner, w, h)
} }
func (m *Model) renderDetailPanel(h int) string { func (m *Model) renderDetailPanel(h int) string {
s := style.S s := style.S
panelStyle := s.Panel
if m.editing {
panelStyle = s.PanelFocused
}
info, ok := m.selected() info, ok := m.selected()
if !ok { if !ok {
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h) return style.RenderWithTitle(panelStyle, "Detail", "", m.width, h)
} }
var sb strings.Builder
statusSt := lipgloss.NewStyle().Foreground(s.Error) statusSt := lipgloss.NewStyle().Foreground(s.Error)
if info.Enabled { if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success) statusSt = lipgloss.NewStyle().Foreground(s.Success)
@@ -52,22 +61,52 @@ func (m *Model) renderDetailPanel(h int) string {
if info.Enabled { if info.Enabled {
status = "enabled" status = "enabled"
} }
sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n")
sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n")
pad := lipgloss.NewStyle().Padding(0, 1)
header := pad.Render(
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
s.Faint.Render(filepath.Base(info.FilePath)),
)
parts := []string{header, m.detailViewport.View()}
if m.hasConfig() {
var configLabel string
if m.editing { if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key escKey := keys.Keys.Global.Escape.Help().Key
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):")) configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
} else { } else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key editKey := keys.Keys.Plugins.EditConfig.Help().Key
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):")) configLabel = pad.Render(s.Faint.Render("config (" + editKey + " to edit):"))
}
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
} }
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()), return style.RenderWithTitle(panelStyle, "Detail", inner, m.width, h)
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()), }
func renderPluginDescription(desc string, width int) string {
desc = strings.TrimSpace(desc)
lines := strings.Split(desc, "\n")
for i, l := range lines {
lines[i] = strings.TrimLeft(l, " \t")
}
desc = strings.Join(lines, "\n")
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width),
) )
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h) if err != nil {
return desc
}
out, err := r.Render(desc)
if err != nil {
return desc
}
return strings.Trim(out, "\n")
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
@@ -81,9 +120,9 @@ func (m *Model) renderStatusBar() string {
escKey := keys.Keys.Global.Escape.Help().Key escKey := keys.Keys.Global.Escape.Help().Key
accent := lipgloss.NewStyle().Foreground(s.Primary) accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear")) filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))) return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()})))
} }
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})) return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()}))
} }
func (m *Model) renderList() string { func (m *Model) renderList() string {
+13 -1
View File
@@ -33,6 +33,18 @@ func sendCmd(entry Entry, index int) tea.Cmd {
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case SendToReplayMsg: case SendToReplayMsg:
entry := entryFromMsg(msg) entry := entryFromMsg(msg)
@@ -104,7 +116,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateNormalMode(msg) return m.updateNormalMode(msg)
} }
return m, nil return m, tea.Batch(cmds...)
} }
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+5 -1
View File
@@ -33,12 +33,16 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
dots := s.Faint.Render(m.pager.View()) dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
) )
return style.RenderWithTitle(s.PanelFocused, icons.I.Replay+"Replay", inner, w, h) return style.RenderWithTitle(panelStyle, icons.I.Replay+"Replay", inner, w, h)
} }
func (m *Model) renderRequestPanel(w, h int) string { func (m *Model) renderRequestPanel(w, h int) string {
+45
View File
@@ -0,0 +1,45 @@
package spilltea
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
)
//go:embed plugins/*.lua
var PluginsFS embed.FS
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
// files that already exist. Returns the number of files written.
func InstallDefaultPlugins(dir string) (int, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return 0, fmt.Errorf("create plugins dir: %w", err)
}
entries, err := fs.ReadDir(PluginsFS, "plugins")
if err != nil {
return 0, err
}
written := 0
for _, e := range entries {
if e.IsDir() {
continue
}
dst := filepath.Join(dir, e.Name())
if _, err := os.Stat(dst); err == nil {
continue
}
data, err := PluginsFS.ReadFile("plugins/" + e.Name())
if err != nil {
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return written, fmt.Errorf("write %s: %w", dst, err)
}
written++
}
return written, nil
}
@@ -1,26 +1,28 @@
-- Inject a custom header into every request.
-- Config format (one per line): Header-Name: value
Plugin = { Plugin = {
name = "Inject Header", name = "Inject Header",
description = [[
Inject custom headers into every intercepted request.
**Config**:
- one 'Header-Name: value' per line.
]],
on_request = { sync = true }, on_request = { sync = true },
} }
local headers = {} local headers = {}
function on_start(config_text) function on_config(config_text)
headers = {}
for line in config_text:gmatch("[^\n]+") do for line in config_text:gmatch("[^\n]+") do
local name, value = line:match("^([^:]+):%s*(.+)$") local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then if name and value then
table.insert(headers, { name = name, value = value }) table.insert(headers, { name = name, value = value })
end end
end end
log("loaded " .. #headers .. " header(s)")
end end
function on_request(req) function on_request(req)
for _, h in ipairs(headers) do for _, h in ipairs(headers) do
req:set_header(h.name, h.value) req:set_header(h.name, h.value)
end end
return "forward"
end end
+75
View File
@@ -0,0 +1,75 @@
Plugin = {
name = "IP Filter (Whitelist/Blacklist)",
description = [[
Checks that the proxy's outbound IP is in an allowed list on startup.
**Config**:
- one IP per line
- prefix with `!` for a blacklist entry (blocked)
- prefix with `#` to comment it out (ignored)
- if no IPs are configured, the check is skipped
]],
on_start = { sync = false },
}
local whitelist = {}
local blacklist = {}
function on_config(config_text)
whitelist = {}
blacklist = {}
for line in config_text:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(blacklist, ip)
end
else
table.insert(whitelist, trimmed)
end
end
end
end
function on_start()
if #whitelist == 0 and #blacklist == 0 then
return
end
-- Fetch the current outbound IP via a public API.
local ok, result = pcall(function()
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
if not handle then return nil end
local ip = handle:read("*a")
handle:close()
return ip and ip:match("^%s*(.-)%s*$") or nil
end)
if not ok or not result or result == "" then
log("could not determine outbound IP, skipping check")
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
return
end
for _, ip in ipairs(blacklist) do
if result == ip then
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
return
end
end
if #whitelist == 0 then
return
end
for _, ip in ipairs(whitelist) do
if result == ip then
return
end
end
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
end
+78
View File
@@ -0,0 +1,78 @@
Plugin = {
name = "Scopes",
description = [[
Auto-forward requests and exclude them from history based on patterns.
**Config**:
- `pattern` - whitelist: only intercept matching requests
- `!pattern` - blacklist: don't intercept matching requests and exclude from history
- lines starting with `#` are comments
Example (ignore static assets):
```
!%.css$
!%.js$
!%.png$
```
Example (focus on mytarget.com, skip everything else):
```
mytarget%.com/
```
Example (intercept mytarget.com except its static assets):
```
mytarget%.com/
!%.css$
!%.js$
!%.png$
```
]],
priority = 100,
on_request = { sync = true },
on_response = { sync = true },
on_history_entry = { sync = true },
}
local whitelist = {}
local blacklist = {}
function on_config(config_text)
whitelist = {}
blacklist = {}
for line in config_text:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
if trimmed:sub(1, 1) == "!" then
table.insert(blacklist, trimmed:sub(2))
else
table.insert(whitelist, trimmed)
end
end
end
end
local function should_skip(url)
for _, pattern in ipairs(blacklist) do
if url:match(pattern) then return true end
end
if #whitelist > 0 then
for _, pattern in ipairs(whitelist) do
if url:match(pattern) then return false end
end
return true
end
return false
end
function on_request(req)
if should_skip(req.url) then return "forward" end
end
function on_response(req)
if should_skip(req.url) then return "forward" end
end
function on_history_entry(entry)
if should_skip(entry.host .. entry.path) then return "skip" end
end