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
+52 -29
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
@@ -14,26 +15,28 @@ 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_quit()` | When the app exits | always sync | ignored | | `on_start()` | Once at startup, after `on_config` | configurable | ignored |
| `on_request(req)` | Every request | declared | `"drop"`, `"forward"`, or `nil` (sync only) | | `on_quit()` | When the app exits | always sync | ignored |
| `on_response(req, res)` | Every response | declared | `"drop"`, `"forward"`, or `nil` (sync only) | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_history_entry(entry)` | After a flow is saved to DB | always async | ignored | | `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) |
## 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
| Return value | Effect | **`on_request` and `on_response`:**
| ------------ | ------ |
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. | | Return value | Effect |
| `"forward"` | The flow is forwarded immediately without going through 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. |
| `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
+10 -8
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
@@ -53,13 +53,15 @@ 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`) |
| `--host` | | Proxy host, overrides config | | `--plugin-dir` | | Path to plugins dir, overrides config (default: `~/.config/spilltea/plugins/`) |
| `--port` | `-p` | Proxy port, overrides config | | `--host` | | Proxy host, overrides config |
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session | | `--port` | `-p` | Proxy port, overrides config |
| `--version` | `-v` | Print version and exit | | `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session |
| `--version` | `-v` | Print version and exit |
| `--add-default-plugins` | | Add the default plugins to your plugins dir and exit |
## Deployment ## Deployment
+33 -5
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"
@@ -22,11 +23,13 @@ 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")
flagHost = flag.String("host", "", "proxy host (overrides config)") flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)") flagHost = flag.String("host", "", "proxy host (overrides config)")
flagVersion = flag.BoolP("version", "v", false, "print version") flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`) flagVersion = flag.BoolP("version", "v", false, "print version")
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
+14 -3
View File
@@ -44,7 +44,12 @@ type Broker struct {
autoFwdMu sync.RWMutex autoFwdMu sync.RWMutex
autoFwdRegexes []*regexp.Regexp autoFwdRegexes []*regexp.Regexp
onNewEntry func(db.Entry) onBeforeNewEntry func(db.Entry) bool
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)) {
@@ -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)
+83 -2
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,7 +65,87 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
return 0 return 0
})) }))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { 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 {
reason := L.OptString(1, "plugin requested quit") reason := L.OptString(1, "plugin requested quit")
select { select {
case mgr.Quit <- reason: case mgr.Quit <- reason:
+97 -39
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
@@ -27,12 +28,13 @@ type Manager struct {
func NewManager(broker *intercept.Broker) *Manager { func NewManager(broker *intercept.Broker) *Manager {
mgr := &Manager{ mgr := &Manager{
broker: broker, broker: broker,
Notifs: make(chan PluginNotifMsg, 64), Notifs: make(chan PluginNotifMsg, 64),
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)
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { }
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue // 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 {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
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,46 +196,62 @@ 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_config", lua.LString(configText)); err != nil {
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { log.Printf("plugin %s on_config (config reload): %v", name, err)
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
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()
}()
} }
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_start: %v", p.Name, err) log.Printf("plugin %s on_config: %v", p.Name, err)
} }
p.mu.Unlock() 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)
}
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)
}
}
} }
func (m *Manager) RunOnQuit() { func (m *Manager) RunOnQuit() {
@@ -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) {
+26 -14
View File
@@ -11,10 +11,12 @@ type HookConfig struct {
} }
type Plugin struct { type Plugin struct {
Name string Name string
FilePath string Description string
Enabled bool FilePath string
ConfigText string Enabled bool
ConfigText string
Priority int
L *lua.LState L *lua.LState
mu sync.Mutex mu sync.Mutex
@@ -35,30 +37,40 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
} }
type Info struct { type Info struct {
Name string Name string
FilePath string Description string
Enabled bool FilePath string
ConfigText string Enabled bool
Hooks map[string]HookConfig ConfigText string
Priority int
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,
FilePath: p.FilePath, Description: p.Description,
Enabled: p.Enabled, FilePath: p.FilePath,
ConfigText: p.ConfigText, Enabled: enabled,
Hooks: hooks, ConfigText: configText,
Priority: p.Priority,
Hooks: hooks,
} }
} }
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 {
+64 -24
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"
@@ -24,19 +23,20 @@ type Model struct {
filter string filter string
filtered []plugins.Info filtered []plugins.Info
listViewport viewport.Model listViewport viewport.Model
textarea textarea.Model detailViewport viewport.Model
filterInput textinput.Model textarea textarea.Model
filtering bool filterInput textinput.Model
pager paginator.Model filtering bool
help help.Model pager paginator.Model
help help.Model
width int width int
height int height int
} }
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()
@@ -44,12 +44,13 @@ func New(mgr *plugins.Manager) Model {
fi.Prompt = "" fi.Prompt = ""
return Model{ return Model{
manager: mgr, manager: mgr,
listViewport: style.NewViewport(), listViewport: style.NewViewport(),
textarea: ta, detailViewport: style.NewViewport(),
filterInput: fi, textarea: ta,
pager: style.NewPaginator(), filterInput: fi,
help: style.NewHelp(), pager: style.NewPaginator(),
help: style.NewHelp(),
} }
} }
@@ -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,16 +193,11 @@ 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
g := keys.Keys.Global g := keys.Keys.Global
@@ -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()
+57 -18
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")
if m.editing { pad := lipgloss.NewStyle().Padding(0, 1)
escKey := keys.Keys.Global.Escape.Help().Key
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):")) header := pad.Render(
} else { s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
editKey := keys.Keys.Plugins.EditConfig.Help().Key s.Faint.Render(filepath.Base(info.FilePath)),
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):")) )
parts := []string{header, m.detailViewport.View()}
if m.hasConfig() {
var configLabel string
if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key
configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
} else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key
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",
on_request = { sync = true }, description = [[
Inject custom headers into every intercepted request.
**Config**:
- one 'Header-Name: value' per line.
]],
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