mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Compare commits
19 Commits
598455f8d3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af872afbe8 | |||
| 2225afd9ee | |||
| 6dc959de77 | |||
| 0017f37c33 | |||
| 924cb73afb | |||
| 746f1afd1b | |||
| 905013943d | |||
| c6bca887cb | |||
| dcf9cb4c8e | |||
| ae372d7283 | |||
| e20250f0a0 | |||
| 3463e51739 | |||
| 87fa9448d6 | |||
| 4240c4ceb9 | |||
| d79c9f91d1 | |||
| 33e2afe709 | |||
| 2c3e19258f | |||
| 69d5d0ffec | |||
| d47f51d2b5 |
+36
-5
@@ -31,14 +31,14 @@ Plugin = {
|
|||||||
|
|
||||||
### Hook reference
|
### Hook reference
|
||||||
|
|
||||||
| Hook | When called | Sync/async | Return value (sync only) |
|
| Hook | When called | Sync/async | Return value |
|
||||||
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
|
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
|
||||||
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
|
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
|
||||||
| `on_start()` | Once at startup, after `on_config` | configurable | ignored |
|
| `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored |
|
||||||
| `on_quit()` | When the app exits | always sync | ignored |
|
| `on_quit()` | When the app exits | always sync | ignored |
|
||||||
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
|
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
||||||
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
|
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
||||||
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) |
|
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) -- sync only |
|
||||||
|
|
||||||
## Request and response objects
|
## Request and response objects
|
||||||
|
|
||||||
@@ -110,6 +110,16 @@ end
|
|||||||
|
|
||||||
-- Quit the app (useful for startup checks that fail)
|
-- Quit the app (useful for startup checks that fail)
|
||||||
quit("reason message")
|
quit("reason message")
|
||||||
|
|
||||||
|
-- Run a shell command, optionally piping a string to its stdin.
|
||||||
|
-- Returns: output string, error string (nil on success).
|
||||||
|
-- The command runs via "sh -c" with a 30-second timeout.
|
||||||
|
local out, err = shell_pipe("trufflehog filesystem --no-update --json /dev/stdin", body)
|
||||||
|
if err then
|
||||||
|
log("command failed: " .. err)
|
||||||
|
else
|
||||||
|
log("output: " .. out)
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
### Finding deduplication
|
### Finding deduplication
|
||||||
@@ -131,6 +141,27 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
|
|||||||
|
|
||||||
### Return values for sync hooks
|
### Return values for sync hooks
|
||||||
|
|
||||||
|
**`on_start`:**
|
||||||
|
|
||||||
|
| Return value | Effect |
|
||||||
|
| ------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| `false` | The plugin is disabled immediately and the state is persisted (equivalent to toggling it off). |
|
||||||
|
| anything else | Ignored. |
|
||||||
|
|
||||||
|
This is useful for prerequisite checks (binary not found, config invalid, etc.) so the plugin does not silently run in a broken state:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function on_start()
|
||||||
|
local h = io.popen("command -v mytool 2>/dev/null")
|
||||||
|
local result = h and h:read("*a") or ""
|
||||||
|
if h then h:close() end
|
||||||
|
if result:match("^%s*$") then
|
||||||
|
notif("MyPlugin", "mytool not found, plugin disabled", "error")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
**`on_request` and `on_response`:**
|
**`on_request` and `on_response`:**
|
||||||
|
|
||||||
| Return value | Effect |
|
| Return value | Effect |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
(system: f system (import nixpkgs {inherit system;}));
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
pname = "spilltea";
|
pname = "spilltea";
|
||||||
version = "0.0.4";
|
version = "0.0.5";
|
||||||
|
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
in {
|
in {
|
||||||
|
|||||||
@@ -47,35 +47,35 @@ tui:
|
|||||||
keybindings:
|
keybindings:
|
||||||
global:
|
global:
|
||||||
quit: "q,ctrl+c"
|
quit: "q,ctrl+c"
|
||||||
|
help: "?"
|
||||||
open_logs: "ctrl+g"
|
open_logs: "ctrl+g"
|
||||||
toggle_sidebar: "ctrl+b"
|
toggle_sidebar: "ctrl+b"
|
||||||
help: "?"
|
cycle_focus: "tab"
|
||||||
|
send_to_replay: "ctrl+r"
|
||||||
|
send_to_diff: "ctrl+d"
|
||||||
|
copy_as: "ctrl+y"
|
||||||
|
copy: "y"
|
||||||
up: "up,k"
|
up: "up,k"
|
||||||
down: "down,j"
|
down: "down,j"
|
||||||
left: "left,h"
|
left: "left,h"
|
||||||
right: "right,l"
|
right: "right,l"
|
||||||
cycle_focus: "tab"
|
goto_top: "g"
|
||||||
copy_as: "ctrl+y"
|
goto_bottom: "G,end"
|
||||||
copy: "y"
|
|
||||||
send_to_replay: "ctrl+r"
|
|
||||||
scroll_up: "pgup"
|
scroll_up: "pgup"
|
||||||
scroll_down: "pgdown"
|
scroll_down: "pgdown"
|
||||||
send_to_diff: "ctrl+d"
|
|
||||||
goto_top: "home"
|
|
||||||
goto_bottom: "G,end"
|
|
||||||
prev_page: "["
|
prev_page: "["
|
||||||
next_page: "]"
|
next_page: "]"
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
|
toggle_intercept: "i"
|
||||||
|
capture_response: "r"
|
||||||
forward: "f"
|
forward: "f"
|
||||||
forward_all: "F"
|
forward_all: "F"
|
||||||
drop: "d"
|
drop: "d"
|
||||||
drop_all: "D"
|
drop_all: "D"
|
||||||
toggle_intercept: "i"
|
|
||||||
capture_response: "r"
|
|
||||||
undo_edits: "ctrl+z"
|
|
||||||
edit: "e,enter"
|
edit: "e,enter"
|
||||||
edit_external: "E"
|
edit_external: "E"
|
||||||
|
undo_edits: "ctrl+z"
|
||||||
|
|
||||||
history:
|
history:
|
||||||
delete_entry: "x"
|
delete_entry: "x"
|
||||||
@@ -85,20 +85,20 @@ keybindings:
|
|||||||
flag: "m"
|
flag: "m"
|
||||||
|
|
||||||
home:
|
home:
|
||||||
open: "enter,l"
|
open: "l,enter"
|
||||||
delete: "x"
|
delete: "x"
|
||||||
filter: "/"
|
filter: "/"
|
||||||
|
|
||||||
replay:
|
replay:
|
||||||
send: "enter,s"
|
send: "s, enter"
|
||||||
edit: "e"
|
edit: "e"
|
||||||
edit_external: "E"
|
edit_external: "E"
|
||||||
undo_edits: "R"
|
undo_edits: "ctrl+z"
|
||||||
delete_entry: "x"
|
delete_entry: "x"
|
||||||
delete_all: "X"
|
delete_all: "X"
|
||||||
|
|
||||||
diff:
|
diff:
|
||||||
clear: "c"
|
clear: "x"
|
||||||
|
|
||||||
findings:
|
findings:
|
||||||
dismiss: "x"
|
dismiss: "x"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type Finding struct {
|
|||||||
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
|
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
|
||||||
// not already exist. Returns true when the row was actually inserted.
|
// not already exist. Returns true when the row was actually inserted.
|
||||||
func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
||||||
|
d.dedupMu.Lock()
|
||||||
|
defer d.dedupMu.Unlock()
|
||||||
res, err := d.conn.Exec(
|
res, err := d.conn.Exec(
|
||||||
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
|
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||||
|
|||||||
+32
-7
@@ -1,7 +1,10 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -171,6 +174,31 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
cmd := L.CheckString(1)
|
||||||
|
input := L.OptString(2, "")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c := exec.CommandContext(ctx, "sh", "-c", cmd)
|
||||||
|
c.Stdin = strings.NewReader(input)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
c.Stdout = &stdout
|
||||||
|
c.Stderr = &stderr
|
||||||
|
|
||||||
|
err := c.Run()
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LString(stdout.String()))
|
||||||
|
L.Push(lua.LString(err.Error() + ": " + stderr.String()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
L.Push(lua.LString(stdout.String()))
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 2
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func luaTableString(t *lua.LTable, key string) string {
|
func luaTableString(t *lua.LTable, key string) string {
|
||||||
@@ -264,22 +292,19 @@ func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
func callHook(p *Plugin, hookName string, args ...lua.LValue) (string, error) {
|
func callHook(p *Plugin, hookName string, args ...lua.LValue) (lua.LValue, error) {
|
||||||
fn := p.L.GetGlobal(hookName)
|
fn := p.L.GetGlobal(hookName)
|
||||||
if fn == lua.LNil {
|
if fn == lua.LNil {
|
||||||
return "", nil
|
return lua.LNil, nil
|
||||||
}
|
}
|
||||||
if err := p.L.CallByParam(lua.P{
|
if err := p.L.CallByParam(lua.P{
|
||||||
Fn: fn,
|
Fn: fn,
|
||||||
NRet: 1,
|
NRet: 1,
|
||||||
Protect: true,
|
Protect: true,
|
||||||
}, args...); err != nil {
|
}, args...); err != nil {
|
||||||
return "", err
|
return lua.LNil, err
|
||||||
}
|
}
|
||||||
ret := p.L.Get(-1)
|
ret := p.L.Get(-1)
|
||||||
p.L.Pop(1)
|
p.L.Pop(1)
|
||||||
if s, ok := ret.(lua.LString); ok {
|
return ret, nil
|
||||||
return string(s), nil
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,42 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
_ = m.db.SavePluginState(name, enabled, configText)
|
||||||
}
|
}
|
||||||
|
if !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hc, ok := found.hooks["on_start"]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
|
if ret == lua.LFalse {
|
||||||
|
p.Enabled = false
|
||||||
|
if m.db != nil {
|
||||||
|
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hc.Sync {
|
||||||
|
found.mu.Lock()
|
||||||
|
ret, err := callHook(found, "on_start")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugin %s on_start: %v", found.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(found, ret)
|
||||||
|
}
|
||||||
|
found.mu.Unlock()
|
||||||
|
} else {
|
||||||
|
go func() {
|
||||||
|
found.mu.Lock()
|
||||||
|
ret, err := callHook(found, "on_start")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugin %s on_start: %v", found.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(found, ret)
|
||||||
|
}
|
||||||
|
found.mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SaveConfig(name, configText string) {
|
func (m *Manager) SaveConfig(name, configText string) {
|
||||||
@@ -242,17 +278,31 @@ func (m *Manager) RunOnStart() {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
|
if ret == lua.LFalse {
|
||||||
|
p.Enabled = false
|
||||||
|
if m.db != nil {
|
||||||
|
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if hc.Sync {
|
if hc.Sync {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if _, err := callHook(p, "on_start"); err != nil {
|
ret, err := callHook(p, "on_start")
|
||||||
|
if err != nil {
|
||||||
log.Printf("plugin %s on_start: %v", p.Name, err)
|
log.Printf("plugin %s on_start: %v", p.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(p, ret)
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
} else {
|
} else {
|
||||||
go func(p *Plugin) {
|
go func(p *Plugin) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if _, err := callHook(p, "on_start"); err != nil {
|
ret, err := callHook(p, "on_start")
|
||||||
|
if err != nil {
|
||||||
log.Printf("plugin %s on_start: %v", p.Name, err)
|
log.Printf("plugin %s on_start: %v", p.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(p, ret)
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
}(p)
|
}(p)
|
||||||
@@ -294,11 +344,13 @@ func (m *Manager) runSyncDecisionForPlugins(hookName string, argsFor func(*Plugi
|
|||||||
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
|
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch result {
|
if s, ok := result.(lua.LString); ok {
|
||||||
case "drop":
|
switch string(s) {
|
||||||
return intercept.Drop
|
case "drop":
|
||||||
case "forward":
|
return intercept.Drop
|
||||||
return intercept.Forward
|
case "forward":
|
||||||
|
return intercept.Forward
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return intercept.Intercept
|
return intercept.Intercept
|
||||||
@@ -366,7 +418,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
|
|||||||
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
|
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if result == "skip" {
|
if s, ok := result.(lua.LString); ok && string(s) == "skip" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,45 +187,62 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, keys.Keys.Global.CopyAs):
|
case key.Matches(msg, keys.Keys.Global.CopyAs):
|
||||||
var raw, scheme string
|
var raw, scheme string
|
||||||
|
var responseFocused bool
|
||||||
switch m.page {
|
switch m.page {
|
||||||
case pageDiff:
|
|
||||||
raw = m.diff.CurrentRaw()
|
|
||||||
scheme = "https"
|
|
||||||
case pageIntercept:
|
case pageIntercept:
|
||||||
raw = m.intercept.CurrentRaw()
|
raw = m.intercept.CurrentRaw()
|
||||||
scheme = m.intercept.CurrentScheme()
|
scheme = m.intercept.CurrentScheme()
|
||||||
|
responseFocused = m.intercept.IsResponseFocused()
|
||||||
case pageHistory:
|
case pageHistory:
|
||||||
raw = m.history.CurrentRaw()
|
raw = m.history.CurrentRaw()
|
||||||
scheme = m.history.CurrentScheme()
|
scheme = m.history.CurrentScheme()
|
||||||
|
responseFocused = m.history.IsResponseFocused()
|
||||||
case pageReplay:
|
case pageReplay:
|
||||||
raw = m.replay.CurrentRaw()
|
raw = m.replay.CurrentRaw()
|
||||||
scheme = m.replay.CurrentScheme()
|
scheme = m.replay.CurrentScheme()
|
||||||
|
responseFocused = m.replay.IsResponseFocused()
|
||||||
}
|
}
|
||||||
if raw != "" {
|
if raw != "" && !responseFocused {
|
||||||
m.copyAs.SetSize(m.width, m.height)
|
m.copyAs.SetSize(m.width, m.height)
|
||||||
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
|
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Copy):
|
case key.Matches(msg, keys.Keys.Global.Copy):
|
||||||
|
if m.page == pageFindings {
|
||||||
|
if md := m.findingsPage.CurrentMarkdown(); md != "" {
|
||||||
|
return m, tea.Batch(
|
||||||
|
tea.SetClipboard(md),
|
||||||
|
func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Copied",
|
||||||
|
Body: "Finding copied to clipboard",
|
||||||
|
Kind: notificationsUI.KindSuccess,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
var raw, scheme string
|
var raw, scheme string
|
||||||
|
var responseFocused bool
|
||||||
switch m.page {
|
switch m.page {
|
||||||
case pageIntercept:
|
case pageIntercept:
|
||||||
raw = m.intercept.CurrentRaw()
|
raw = m.intercept.CurrentRaw()
|
||||||
scheme = m.intercept.CurrentScheme()
|
scheme = m.intercept.CurrentScheme()
|
||||||
case pageDiff:
|
responseFocused = m.intercept.IsResponseFocused()
|
||||||
raw = m.diff.CurrentRaw()
|
|
||||||
scheme = "https"
|
|
||||||
case pageHistory:
|
case pageHistory:
|
||||||
raw = m.history.CurrentRaw()
|
raw = m.history.CurrentRaw()
|
||||||
scheme = m.history.CurrentScheme()
|
scheme = m.history.CurrentScheme()
|
||||||
|
responseFocused = m.history.IsResponseFocused()
|
||||||
case pageReplay:
|
case pageReplay:
|
||||||
raw = m.replay.CurrentRaw()
|
raw = m.replay.CurrentRaw()
|
||||||
scheme = m.replay.CurrentScheme()
|
scheme = m.replay.CurrentScheme()
|
||||||
|
responseFocused = m.replay.IsResponseFocused()
|
||||||
}
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
m.copy.SetSize(m.width, m.height)
|
m.copy.SetSize(m.width, m.height)
|
||||||
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme})
|
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme, ShowURL: !responseFocused})
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package copy
|
package copy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/list"
|
"charm.land/bubbles/v2/list"
|
||||||
@@ -17,14 +14,10 @@ const (
|
|||||||
popupH = 20
|
popupH = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeClipboard(text string) {
|
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(text))
|
|
||||||
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
Scheme string
|
Scheme string
|
||||||
|
ShowURL bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type copyItem struct {
|
type copyItem struct {
|
||||||
@@ -90,6 +83,17 @@ func (m *Model) Open(msg OpenMsg) {
|
|||||||
m.rawRequest = msg.RawRequest
|
m.rawRequest = msg.RawRequest
|
||||||
m.scheme = msg.Scheme
|
m.scheme = msg.Scheme
|
||||||
m.open = true
|
m.open = true
|
||||||
|
items := allItems
|
||||||
|
if !msg.ShowURL {
|
||||||
|
filtered := make([]list.Item, 0, len(allItems))
|
||||||
|
for _, it := range allItems {
|
||||||
|
if it.(copyItem).id != "url" {
|
||||||
|
filtered = append(filtered, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = filtered
|
||||||
|
}
|
||||||
|
m.list.SetItems(items)
|
||||||
m.list.ResetFilter()
|
m.list.ResetFilter()
|
||||||
m.list.Select(0)
|
m.list.Select(0)
|
||||||
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
|
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
||||||
switch {
|
switch {
|
||||||
case kp.String() == "enter":
|
case kp.String() == "enter":
|
||||||
if item, ok := m.list.SelectedItem().(copyItem); ok {
|
|
||||||
writeClipboard(m.extract(item.id))
|
|
||||||
}
|
|
||||||
m.open = false
|
m.open = false
|
||||||
|
if item, ok := m.list.SelectedItem().(copyItem); ok {
|
||||||
|
return m, tea.Batch(
|
||||||
|
tea.SetClipboard(m.extract(item.id)),
|
||||||
|
func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Copied",
|
||||||
|
Body: "Request copied to clipboard",
|
||||||
|
Kind: notificationsUI.KindSuccess,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case key.Matches(kp, keys.Keys.Global.Escape):
|
case key.Matches(kp, keys.Keys.Global.Escape):
|
||||||
if m.list.SettingFilter() {
|
if m.list.SettingFilter() {
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package copyas
|
package copyas
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/list"
|
"charm.land/bubbles/v2/list"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
@@ -16,13 +12,6 @@ const (
|
|||||||
popupH = 20
|
popupH = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
|
|
||||||
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
|
|
||||||
func writeClipboard(text string) {
|
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(text))
|
|
||||||
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
Scheme string
|
Scheme string
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
||||||
switch {
|
switch {
|
||||||
case kp.String() == "enter":
|
case kp.String() == "enter":
|
||||||
if item, ok := m.list.SelectedItem().(formatItem); ok {
|
|
||||||
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
|
|
||||||
}
|
|
||||||
m.open = false
|
m.open = false
|
||||||
|
if item, ok := m.list.SelectedItem().(formatItem); ok {
|
||||||
|
return m, tea.Batch(
|
||||||
|
tea.SetClipboard(formatAs(item.id, m.rawRequest, m.scheme)),
|
||||||
|
func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Copied",
|
||||||
|
Body: "Request copied to clipboard",
|
||||||
|
Kind: notificationsUI.KindSuccess,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case key.Matches(kp, keys.Keys.Global.Escape):
|
case key.Matches(kp, keys.Keys.Global.Escape):
|
||||||
if m.list.SettingFilter() {
|
if m.list.SettingFilter() {
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ func (diffKeyMap) ShortHelp() []key.Binding {
|
|||||||
|
|
||||||
func (m diffKeyMap) FullHelp() [][]key.Binding {
|
func (m diffKeyMap) FullHelp() [][]key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Copy, g.CopyAs}
|
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right}
|
||||||
all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
|
all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
|
||||||
all = append(all, g.CommonBindings()...)
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -38,6 +39,12 @@ func (m *Model) renderPanels(panelH int) string {
|
|||||||
if m.right.label != "" {
|
if m.right.label != "" {
|
||||||
rightTitle = icons.I.Diff + "Second: " + m.right.label
|
rightTitle = icons.I.Diff + "Second: " + m.right.label
|
||||||
}
|
}
|
||||||
|
if maxW := leftW - 4; maxW > 0 {
|
||||||
|
leftTitle = ansi.Truncate(leftTitle, maxW, "…")
|
||||||
|
}
|
||||||
|
if maxW := rightW - 4; maxW > 0 {
|
||||||
|
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
|
||||||
|
}
|
||||||
|
|
||||||
leftBorder := s.Panel
|
leftBorder := s.Panel
|
||||||
rightBorder := s.Panel
|
rightBorder := s.Panel
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -12,12 +13,7 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &e.viewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
if e.searching {
|
if e.searching {
|
||||||
@@ -61,17 +57,9 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, g.Down):
|
case key.Matches(msg, g.Down):
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := e.viewport.Height() / 2
|
util.ScrollViewport(&e.viewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() - step)
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := e.viewport.Height() / 2
|
util.ScrollViewport(&e.viewport, 1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() + step)
|
|
||||||
case key.Matches(msg, g.Help):
|
case key.Matches(msg, g.Help):
|
||||||
e.help.ShowAll = !e.help.ShowAll
|
e.help.ShowAll = !e.help.ShowAll
|
||||||
e.SetSize(e.width, e.height)
|
e.SetSize(e.width, e.height)
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ func New() Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m *Model) CurrentMarkdown() string {
|
||||||
|
if len(m.findings) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
f := m.findings[m.cursor]
|
||||||
|
return "# " + f.Title + "\n\n" + f.Description
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) SetDB(d *db.DB) {
|
func (m *Model) SetDB(d *db.DB) {
|
||||||
m.database = d
|
m.database = d
|
||||||
}
|
}
|
||||||
@@ -113,6 +121,14 @@ type FindingsLoadedMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) refreshBody() {
|
func (m *Model) refreshBody() {
|
||||||
|
m.refreshBodyScroll(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) refreshBodyKeepScroll() {
|
||||||
|
m.refreshBodyScroll(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) refreshBodyScroll(reset bool) {
|
||||||
if len(m.findings) == 0 {
|
if len(m.findings) == 0 {
|
||||||
m.bodyViewport.SetContent("")
|
m.bodyViewport.SetContent("")
|
||||||
return
|
return
|
||||||
@@ -120,7 +136,9 @@ func (m *Model) refreshBody() {
|
|||||||
f := m.findings[m.cursor]
|
f := m.findings[m.cursor]
|
||||||
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
|
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
|
||||||
m.bodyViewport.SetContent(rendered)
|
m.bodyViewport.SetContent(rendered)
|
||||||
m.bodyViewport.GotoTop()
|
if reset {
|
||||||
|
m.bodyViewport.GotoTop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderMarkdownCached(src string, width int) string {
|
func (m *Model) renderMarkdownCached(src string, width int) string {
|
||||||
@@ -164,12 +182,12 @@ type findingsKeyMap struct{ width int }
|
|||||||
func (findingsKeyMap) ShortHelp() []key.Binding {
|
func (findingsKeyMap) ShortHelp() []key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
f := keys.Keys.Findings
|
f := keys.Keys.Findings
|
||||||
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Help}
|
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Copy, g.Help}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m findingsKeyMap) FullHelp() [][]key.Binding {
|
func (m findingsKeyMap) FullHelp() [][]key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown}
|
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Copy}
|
||||||
all := append(keys.Keys.Findings.Bindings(), pageGlobals...)
|
all := append(keys.Keys.Findings.Bindings(), pageGlobals...)
|
||||||
all = append(all, g.CommonBindings()...)
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -15,6 +16,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
log.Printf("findings load error: %v", msg.Err)
|
log.Printf("findings load error: %v", msg.Err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
var prevID int64
|
||||||
|
if len(m.findings) > 0 && m.cursor < len(m.findings) {
|
||||||
|
prevID = m.findings[m.cursor].ID
|
||||||
|
}
|
||||||
m.findings = msg.Findings
|
m.findings = msg.Findings
|
||||||
if m.cursor >= len(m.findings) {
|
if m.cursor >= len(m.findings) {
|
||||||
m.cursor = max(0, len(m.findings)-1)
|
m.cursor = max(0, len(m.findings)-1)
|
||||||
@@ -26,16 +31,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.pager.SetTotalPages(len(m.findings))
|
m.pager.SetTotalPages(len(m.findings))
|
||||||
}
|
}
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
var newID int64
|
||||||
|
if len(m.findings) > 0 && m.cursor < len(m.findings) {
|
||||||
|
newID = m.findings[m.cursor].ID
|
||||||
|
}
|
||||||
|
if newID != prevID {
|
||||||
|
m.refreshBody()
|
||||||
|
} else {
|
||||||
|
m.refreshBodyKeepScroll()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
@@ -70,17 +78,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, RefreshCmd(m.database)
|
return m, RefreshCmd(m.database)
|
||||||
}
|
}
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, 1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
|
||||||
case key.Matches(msg, g.GotoTop):
|
case key.Matches(msg, g.GotoTop):
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.pager.Page = 0
|
m.pager.Page = 0
|
||||||
@@ -88,38 +88,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|
||||||
case key.Matches(msg, g.GotoBottom):
|
case key.Matches(msg, g.GotoBottom):
|
||||||
if len(m.findings) > 0 {
|
m.cursor = util.CursorGotoBottom(len(m.findings))
|
||||||
m.cursor = len(m.findings) - 1
|
m.pager.Page = util.CursorGotoBottom(m.pager.TotalPages)
|
||||||
m.pager.Page = m.pager.TotalPages - 1
|
m.refreshListViewport()
|
||||||
m.refreshListViewport()
|
m.refreshBody()
|
||||||
m.refreshBody()
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, g.PrevPage):
|
case key.Matches(msg, g.PrevPage):
|
||||||
step := m.pager.PerPage
|
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, false)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.cursor -= step
|
|
||||||
if m.cursor < 0 {
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
m.pager.Page = m.cursor / m.pager.PerPage
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|
||||||
case key.Matches(msg, g.NextPage):
|
case key.Matches(msg, g.NextPage):
|
||||||
step := m.pager.PerPage
|
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, true)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.cursor += step
|
|
||||||
if m.cursor >= len(m.findings) {
|
|
||||||
m.cursor = len(m.findings) - 1
|
|
||||||
if m.cursor < 0 {
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.pager.Page = m.cursor / m.pager.PerPage
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|||||||
@@ -60,9 +60,16 @@ func (m Model) CurrentRaw() string {
|
|||||||
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m.entries[m.cursor].ResponseRaw
|
||||||
|
}
|
||||||
return m.entries[m.cursor].RequestRaw
|
return m.entries[m.cursor].RequestRaw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
return m.focusedPanel == panelResponse
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) CurrentScheme() string {
|
func (m Model) CurrentScheme() string {
|
||||||
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||||
return "https"
|
return "https"
|
||||||
|
|||||||
@@ -36,18 +36,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
|
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
prevCursor := m.cursor
|
// Remember the selected entry's ID so we can re-anchor after the list is
|
||||||
|
// reloaded (new entries are prepended; a pure index-based cursor would
|
||||||
|
// silently jump to a different entry).
|
||||||
|
var selectedID int64
|
||||||
|
if m.cursor >= 0 && m.cursor < len(m.entries) {
|
||||||
|
selectedID = m.entries[m.cursor].ID
|
||||||
|
}
|
||||||
m.entries = msg.Entries
|
m.entries = msg.Entries
|
||||||
|
entryChanged := true
|
||||||
|
if selectedID != 0 {
|
||||||
|
for i, e := range m.entries {
|
||||||
|
if e.ID == selectedID {
|
||||||
|
m.cursor = i
|
||||||
|
entryChanged = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if m.cursor >= len(m.entries) {
|
if m.cursor >= len(m.entries) {
|
||||||
m.cursor = len(m.entries) - 1
|
m.cursor = len(m.entries) - 1
|
||||||
|
entryChanged = true
|
||||||
}
|
}
|
||||||
if m.cursor < 0 {
|
if m.cursor < 0 {
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
|
entryChanged = true
|
||||||
}
|
}
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
if m.cursor != prevCursor {
|
if entryChanged {
|
||||||
m.bodyViewport.SetYOffset(0)
|
m.bodyViewport.SetYOffset(0)
|
||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
}
|
}
|
||||||
@@ -75,24 +93,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelLeft:
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
h := keys.Keys.History
|
h := keys.Keys.History
|
||||||
@@ -258,18 +259,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.clearSearch()
|
return m, m.clearSearch()
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, 1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.Left):
|
case key.Matches(msg, g.Left):
|
||||||
m.bodyViewport.ScrollLeft(6)
|
m.bodyViewport.ScrollLeft(6)
|
||||||
@@ -286,41 +279,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
case key.Matches(msg, g.GotoBottom):
|
case key.Matches(msg, g.GotoBottom):
|
||||||
if len(m.entries) > 0 {
|
m.cursor = util.CursorGotoBottom(len(m.entries))
|
||||||
m.cursor = len(m.entries) - 1
|
m.refreshListViewport()
|
||||||
m.pager.Page = m.pager.TotalPages - 1
|
m.refreshBody()
|
||||||
m.refreshListViewport()
|
m.bodyViewport.SetYOffset(0)
|
||||||
m.refreshBody()
|
m.bodyViewport.SetXOffset(0)
|
||||||
m.bodyViewport.SetYOffset(0)
|
|
||||||
m.bodyViewport.SetXOffset(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, g.PrevPage):
|
case key.Matches(msg, g.PrevPage):
|
||||||
step := m.pager.PerPage
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.cursor -= step
|
|
||||||
if m.cursor < 0 {
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
m.bodyViewport.SetYOffset(0)
|
m.bodyViewport.SetYOffset(0)
|
||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
case key.Matches(msg, g.NextPage):
|
case key.Matches(msg, g.NextPage):
|
||||||
step := m.pager.PerPage
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.cursor += step
|
|
||||||
if m.cursor >= len(m.entries) {
|
|
||||||
m.cursor = len(m.entries) - 1
|
|
||||||
if m.cursor < 0 {
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
m.bodyViewport.SetYOffset(0)
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ type Model struct {
|
|||||||
teapotFrame int
|
teapotFrame int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func New(projectDir string) Model {
|
func New(projectDir string) Model {
|
||||||
projects := loadProjects(projectDir)
|
projects := loadProjects(projectDir)
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ func (m Model) Init() tea.Cmd { return nil }
|
|||||||
|
|
||||||
func (m Model) IsEditing() bool { return m.editing }
|
func (m Model) IsEditing() bool { return m.editing }
|
||||||
|
|
||||||
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
return m.captureResponse && m.focusedPanel == panelResponses
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) CurrentScheme() string {
|
func (m Model) CurrentScheme() string {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return "https"
|
return "https"
|
||||||
|
|||||||
@@ -52,24 +52,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
if !m.editing {
|
if !m.editing {
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelLeft:
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
@@ -127,18 +110,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.ScrollUp):
|
case key.Matches(msg, keys.Keys.Global.ScrollUp):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.ScrollDown):
|
case key.Matches(msg, keys.Keys.Global.ScrollDown):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, 1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Left):
|
case key.Matches(msg, keys.Keys.Global.Left):
|
||||||
m.bodyViewport.ScrollLeft(6)
|
m.bodyViewport.ScrollLeft(6)
|
||||||
@@ -278,13 +253,29 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
||||||
if onResponses {
|
if onResponses {
|
||||||
if len(m.responseQueue) > 0 {
|
m.responseCursor = util.CursorGotoBottom(len(m.responseQueue))
|
||||||
m.responseCursor = len(m.responseQueue) - 1
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if len(m.queue) > 0 {
|
m.cursor = util.CursorGotoBottom(len(m.queue))
|
||||||
m.cursor = len(m.queue) - 1
|
}
|
||||||
}
|
m.refreshListViewport()
|
||||||
|
m.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.PrevPage):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, false)
|
||||||
|
} else {
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, false)
|
||||||
|
}
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.NextPage):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, true)
|
||||||
|
} else {
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, true)
|
||||||
}
|
}
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshResponseListViewport()
|
m.refreshResponseListViewport()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -20,12 +21,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
if !m.editing {
|
if !m.editing {
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.detailViewport)
|
||||||
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:
|
||||||
@@ -128,19 +124,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.textarea.Focus()
|
m.textarea.Focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, g.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, false)
|
||||||
|
m.recalcSizes()
|
||||||
|
m.syncTextarea()
|
||||||
|
m.detailViewport.GotoTop()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, true)
|
||||||
|
m.recalcSizes()
|
||||||
|
m.syncTextarea()
|
||||||
|
m.detailViewport.GotoTop()
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.detailViewport.Height() / 2
|
util.ScrollViewport(&m.detailViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.detailViewport.Height() / 2
|
util.ScrollViewport(&m.detailViewport, 1)
|
||||||
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
|
||||||
|
|||||||
@@ -34,11 +34,20 @@ type Entry struct {
|
|||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type panel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
panelList panel = iota
|
||||||
|
panelRequest
|
||||||
|
panelResponse
|
||||||
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
entries []Entry
|
entries []Entry
|
||||||
cursor int
|
cursor int
|
||||||
editing bool
|
editing bool
|
||||||
database *db.DB
|
focusedPanel panel
|
||||||
|
database *db.DB
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
requestViewport viewport.Model
|
requestViewport viewport.Model
|
||||||
@@ -68,10 +77,17 @@ func (m Model) Init() tea.Cmd { return nil }
|
|||||||
|
|
||||||
func (m Model) IsEditing() bool { return m.editing }
|
func (m Model) IsEditing() bool { return m.editing }
|
||||||
|
|
||||||
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
return m.focusedPanel == panelResponse
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) CurrentRaw() string {
|
func (m Model) CurrentRaw() string {
|
||||||
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m.entries[m.cursor].ResponseRaw
|
||||||
|
}
|
||||||
return m.entries[m.cursor].RequestRaw
|
return m.entries[m.cursor].RequestRaw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,12 +199,12 @@ type replayKeyMap struct{ width int }
|
|||||||
func (replayKeyMap) ShortHelp() []key.Binding {
|
func (replayKeyMap) ShortHelp() []key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
r := keys.Keys.Replay
|
r := keys.Keys.Replay
|
||||||
return []key.Binding{g.Up, g.Down, r.Send, r.Edit, g.Help}
|
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m replayKeyMap) FullHelp() [][]key.Binding {
|
func (m replayKeyMap) FullHelp() [][]key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs}
|
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs, g.SendToDiff}
|
||||||
all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
|
all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
|
||||||
all = append(all, g.CommonBindings()...)
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
|
|||||||
+124
-45
@@ -1,6 +1,9 @@
|
|||||||
package replay
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"compress/zlib"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -9,13 +12,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/andybalholm/brotli"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sentMsg struct {
|
type sentMsg struct {
|
||||||
@@ -91,14 +98,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.requestViewport.ScrollLeft(6)
|
m.requestViewport.ScrollLeft(6)
|
||||||
m.responseViewport.ScrollLeft(6)
|
m.responseViewport.ScrollLeft(6)
|
||||||
} else {
|
} else {
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1)
|
m.scrollFocusedViewportVertical(-1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelDown:
|
case tea.MouseWheelDown:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollRight(6)
|
m.requestViewport.ScrollRight(6)
|
||||||
m.responseViewport.ScrollRight(6)
|
m.responseViewport.ScrollRight(6)
|
||||||
} else {
|
} else {
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1)
|
m.scrollFocusedViewportVertical(1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelLeft:
|
case tea.MouseWheelLeft:
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.requestViewport.ScrollLeft(6)
|
||||||
@@ -124,17 +131,35 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
r := keys.Keys.Replay
|
r := keys.Keys.Replay
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, g.Up):
|
case key.Matches(msg, g.Up):
|
||||||
if m.cursor > 0 {
|
if m.focusedPanel == panelList {
|
||||||
m.cursor--
|
if m.cursor > 0 {
|
||||||
m.refreshListViewport()
|
m.cursor--
|
||||||
m.refreshBody()
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.scrollFocusedViewportVertical(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, g.Down):
|
case key.Matches(msg, g.Down):
|
||||||
if m.cursor < len(m.entries)-1 {
|
if m.focusedPanel == panelList {
|
||||||
m.cursor++
|
if m.cursor < len(m.entries)-1 {
|
||||||
m.refreshListViewport()
|
m.cursor++
|
||||||
m.refreshBody()
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.scrollFocusedViewportVertical(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, g.CycleFocus):
|
||||||
|
switch m.focusedPanel {
|
||||||
|
case panelList:
|
||||||
|
m.focusedPanel = panelRequest
|
||||||
|
case panelRequest:
|
||||||
|
m.focusedPanel = panelResponse
|
||||||
|
default:
|
||||||
|
m.focusedPanel = panelList
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.Send):
|
case key.Matches(msg, r.Send):
|
||||||
@@ -166,18 +191,14 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.responseViewport.Height() / 2
|
vp := m.focusedViewport()
|
||||||
if step < 1 {
|
util.ScrollViewport(&vp, -1)
|
||||||
step = 1
|
m.setFocusedViewport(vp)
|
||||||
}
|
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.responseViewport.Height() / 2
|
vp := m.focusedViewport()
|
||||||
if step < 1 {
|
util.ScrollViewport(&vp, 1)
|
||||||
step = 1
|
m.setFocusedViewport(vp)
|
||||||
}
|
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.Left):
|
case key.Matches(msg, g.Left):
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.requestViewport.ScrollLeft(6)
|
||||||
@@ -219,40 +240,38 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
||||||
if len(m.entries) > 0 {
|
m.cursor = util.CursorGotoBottom(len(m.entries))
|
||||||
m.cursor = len(m.entries) - 1
|
m.refreshListViewport()
|
||||||
m.pager.Page = m.pager.TotalPages - 1
|
m.refreshBody()
|
||||||
m.refreshListViewport()
|
|
||||||
m.refreshBody()
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.PrevPage):
|
case key.Matches(msg, keys.Keys.Global.PrevPage):
|
||||||
step := m.pager.PerPage
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.cursor -= step
|
|
||||||
if m.cursor < 0 {
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.NextPage):
|
case key.Matches(msg, keys.Keys.Global.NextPage):
|
||||||
step := m.pager.PerPage
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.cursor += step
|
|
||||||
if m.cursor >= len(m.entries) {
|
|
||||||
m.cursor = len(m.entries) - 1
|
|
||||||
if m.cursor < 0 {
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.SendToDiff):
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
e := m.entries[m.cursor]
|
||||||
|
var raw, label string
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
raw = e.ResponseRaw
|
||||||
|
label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode))
|
||||||
|
} else {
|
||||||
|
raw = e.RequestRaw
|
||||||
|
label = e.Method + " " + e.Host + e.Path
|
||||||
|
}
|
||||||
|
if raw != "" {
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -280,6 +299,29 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// focusedViewport returns the viewport that should receive scroll events.
|
||||||
|
// When the list is focused, scroll targets the request panel.
|
||||||
|
func (m *Model) focusedViewport() viewport.Model {
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m.responseViewport
|
||||||
|
}
|
||||||
|
return m.requestViewport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) setFocusedViewport(vp viewport.Model) {
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
m.responseViewport = vp
|
||||||
|
} else {
|
||||||
|
m.requestViewport = vp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollFocusedViewportVertical(delta int) {
|
||||||
|
vp := m.focusedViewport()
|
||||||
|
vp.SetYOffset(vp.YOffset() + delta)
|
||||||
|
m.setFocusedViewport(vp)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) refreshListViewport() {
|
func (m *Model) refreshListViewport() {
|
||||||
if m.pager.PerPage > 0 {
|
if m.pager.PerPage > 0 {
|
||||||
if len(m.entries) == 0 {
|
if len(m.entries) == 0 {
|
||||||
@@ -369,6 +411,14 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
|
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||||
|
|
||||||
|
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
|
||||||
|
if decoded, decErr := decodeBody(enc, respBody); decErr == nil {
|
||||||
|
respBody = decoded
|
||||||
|
resp.Header.Del("Content-Encoding")
|
||||||
|
resp.Header.Del("Content-Length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
|
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||||
for _, line := range util.SortedHeaderLines(resp.Header) {
|
for _, line := range util.SortedHeaderLines(resp.Header) {
|
||||||
@@ -380,6 +430,35 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
return sb.String(), resp.StatusCode, nil
|
return sb.String(), resp.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeBody(encoding string, body []byte) ([]byte, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "gzip":
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
case "br":
|
||||||
|
return io.ReadAll(brotli.NewReader(bytes.NewReader(body)))
|
||||||
|
case "deflate":
|
||||||
|
r, err := zlib.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
case "zstd":
|
||||||
|
r, err := zstd.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
func entryToDB(e Entry) db.ReplayEntry {
|
func entryToDB(e Entry) db.ReplayEntry {
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if e.Err != nil {
|
if e.Err != nil {
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ 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
|
panelStyle := s.Panel
|
||||||
if m.editing {
|
if !m.editing && m.focusedPanel == panelList {
|
||||||
panelStyle = s.Panel
|
panelStyle = s.PanelFocused
|
||||||
}
|
}
|
||||||
var dots string
|
var dots string
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
@@ -58,13 +58,20 @@ func (m *Model) renderRequestPanel(w, h int) string {
|
|||||||
border = s.PanelFocused
|
border = s.PanelFocused
|
||||||
} else {
|
} else {
|
||||||
body = m.requestViewport.View()
|
body = m.requestViewport.View()
|
||||||
|
if m.focusedPanel == panelRequest {
|
||||||
|
border = s.PanelFocused
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
|
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderResponsePanel(w, h int) string {
|
func (m *Model) renderResponsePanel(w, h int) string {
|
||||||
s := style.S
|
s := style.S
|
||||||
return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h)
|
border := s.Panel
|
||||||
|
if !m.editing && m.focusedPanel == panelResponse {
|
||||||
|
border = s.PanelFocused
|
||||||
|
}
|
||||||
|
return style.RenderWithTitle(border, icons.I.Response+"Response", m.responseViewport.View(), w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
// CursorMovePage moves cursor forward or backward by one page (perPage items),
|
||||||
|
// clamped to [0, total-1].
|
||||||
|
func CursorMovePage(cursor, total, perPage int, forward bool) int {
|
||||||
|
step := perPage
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
if forward {
|
||||||
|
cursor += step
|
||||||
|
} else {
|
||||||
|
cursor -= step
|
||||||
|
}
|
||||||
|
if cursor < 0 || total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if cursor >= total {
|
||||||
|
return total - 1
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// CursorGotoBottom returns the last valid cursor index for a list of total items.
|
||||||
|
func CursorGotoBottom(total int) int {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total - 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScrollViewport scrolls vp vertically by half its height.
|
||||||
|
// delta should be -1 for up, +1 for down.
|
||||||
|
func ScrollViewport(vp *viewport.Model, delta int) {
|
||||||
|
step := vp.Height() / 2
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
vp.SetYOffset(vp.YOffset() + delta*step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMouseWheel applies standard mouse wheel scrolling to vp.
|
||||||
|
// Vertical: one line at a time. Shift+vertical or horizontal: scroll 6 columns.
|
||||||
|
func HandleMouseWheel(msg tea.MouseWheelMsg, vp *viewport.Model) {
|
||||||
|
switch msg.Button {
|
||||||
|
case tea.MouseWheelUp:
|
||||||
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
|
vp.ScrollLeft(6)
|
||||||
|
} else {
|
||||||
|
vp.SetYOffset(vp.YOffset() - 1)
|
||||||
|
}
|
||||||
|
case tea.MouseWheelDown:
|
||||||
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
|
vp.ScrollRight(6)
|
||||||
|
} else {
|
||||||
|
vp.SetYOffset(vp.YOffset() + 1)
|
||||||
|
}
|
||||||
|
case tea.MouseWheelLeft:
|
||||||
|
vp.ScrollLeft(6)
|
||||||
|
case tea.MouseWheelRight:
|
||||||
|
vp.ScrollRight(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,16 +40,10 @@ function on_start()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Fetch the current outbound IP via a public API.
|
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
||||||
local ok, result = pcall(function()
|
result = result and result:match("^%s*(.-)%s*$") or nil
|
||||||
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
|
if err or not result or result == "" then
|
||||||
log("could not determine outbound IP, skipping check")
|
log("could not determine outbound IP, skipping check")
|
||||||
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
Plugin = {
|
||||||
|
name = "Secret Scan",
|
||||||
|
description = [[
|
||||||
|
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
|
||||||
|
secrets by matching common secret key names followed by a non-trivial value.
|
||||||
|
|
||||||
|
Uses `grep -E` (available on all Unix systems, no extra dependencies).
|
||||||
|
]],
|
||||||
|
on_request = { sync = false },
|
||||||
|
on_response = { sync = false },
|
||||||
|
disable_by_default = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local CONTENT_TYPES = {
|
||||||
|
"text/html",
|
||||||
|
"text/javascript",
|
||||||
|
"application/javascript",
|
||||||
|
"application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Key name alternation (case-insensitive via grep -i)
|
||||||
|
-- Suffixes are required (no bare generic keyword alone).
|
||||||
|
local KEYS = {
|
||||||
|
"access(_key|_token)", "accessid_secret", "account(_key|_sid)",
|
||||||
|
"admin_pass(word)?", "admin_user",
|
||||||
|
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
|
||||||
|
"ansible_vault_password", "aos_key",
|
||||||
|
"api(_key|_secret|_token)",
|
||||||
|
"app_(id|key|secret)", "application(_key|_id|_secret)",
|
||||||
|
"auth(_token|_secret|orization)", "authkey", "authsecret",
|
||||||
|
"bearer_?token",
|
||||||
|
"bucket(_password|_key)",
|
||||||
|
"cert_?pass(word)?", "certificate_password",
|
||||||
|
"client(_id|_secret)",
|
||||||
|
"codecov_token", "consumer_(key|secret)",
|
||||||
|
"connection_?string", "credentials?", "crypt(_key|_secret)",
|
||||||
|
"db_(password|passwd|user(name)?)",
|
||||||
|
"deploy(_key|_password|_token)",
|
||||||
|
"docker_?pass(word)?", "dockerhub_?password",
|
||||||
|
"encryption_(key|password)",
|
||||||
|
"jwt_secret", "json_web_token",
|
||||||
|
"keycloak_secret", "kubernetes_token",
|
||||||
|
"ldap_(password|bindpw)", "login(_password|_token)",
|
||||||
|
"mail_?password", "mail_smtp_pass",
|
||||||
|
"mysql_password", "mongo_password",
|
||||||
|
"netlify_token", "npm(_token|_auth_token)",
|
||||||
|
"oauth(_token|_secret)",
|
||||||
|
"openai_(api_key|secret)",
|
||||||
|
"pass(word)?", "passwd",
|
||||||
|
"private(_key|_token)",
|
||||||
|
"rds_password",
|
||||||
|
"s3(_key|_secret|_access_key_id)",
|
||||||
|
"secret(_key|_token|_id)", "security_token",
|
||||||
|
"sendgrid_api_key",
|
||||||
|
"ses_(smtp|access|secret)",
|
||||||
|
"service(_account|_key|_token)",
|
||||||
|
"smtp_pass(word)?", "smtp_secret",
|
||||||
|
"sonar_token",
|
||||||
|
"ssh(_key|_private_key|_rsa)",
|
||||||
|
"supabase(_anon|_service)?_key",
|
||||||
|
"symfony_secret",
|
||||||
|
"telegram_bot_token",
|
||||||
|
"token",
|
||||||
|
"travis_token",
|
||||||
|
"vault(_token|_secret)",
|
||||||
|
"webhook(_secret|_token)",
|
||||||
|
"zapier_webhook_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Built once at load time.
|
||||||
|
-- Pattern breakdown:
|
||||||
|
-- KEY[a-z0-9._-]{0,20} key name + optional alphanumeric suffix (e.g. _ID in AWS_ACCESS_KEY_ID)
|
||||||
|
-- [^=:a-zA-Z0-9_]{0,3} optional non-identifier chars before separator (e.g. closing " in JSON "key":)
|
||||||
|
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
|
||||||
|
-- [[:space:]]*"? optional whitespace + opening quote
|
||||||
|
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
|
||||||
|
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
|
||||||
|
local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:=][[:space:]]*"?[a-zA-Z0-9+/=_.-]{8,}'
|
||||||
|
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
|
||||||
|
|
||||||
|
local function is_relevant(ct)
|
||||||
|
if not ct or ct == "" then return false end
|
||||||
|
ct = ct:lower()
|
||||||
|
for _, t in ipairs(CONTENT_TYPES) do
|
||||||
|
if ct:find(t, 1, true) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_context(lines, linenum)
|
||||||
|
local lo = math.max(1, linenum - 6)
|
||||||
|
local hi = math.min(#lines, linenum + 6)
|
||||||
|
|
||||||
|
local before, after = {}, {}
|
||||||
|
for i = lo, linenum - 1 do
|
||||||
|
local l = lines[i] or ""
|
||||||
|
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
||||||
|
table.insert(before, l)
|
||||||
|
end
|
||||||
|
for i = linenum + 1, hi do
|
||||||
|
local l = lines[i] or ""
|
||||||
|
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
||||||
|
table.insert(after, l)
|
||||||
|
end
|
||||||
|
|
||||||
|
local matched_line = lines[linenum] or ""
|
||||||
|
if #matched_line > 200 then matched_line = matched_line:sub(1, 200) .. "..." end
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
if #before > 0 then
|
||||||
|
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
||||||
|
end
|
||||||
|
table.insert(parts, "> **`" .. matched_line .. "`**")
|
||||||
|
if #after > 0 then
|
||||||
|
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
||||||
|
end
|
||||||
|
return table.concat(parts, "\n\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scan(label, ct, body, host, path)
|
||||||
|
if not is_relevant(ct) then return end
|
||||||
|
if not body or body == "" then return end
|
||||||
|
|
||||||
|
local out, err = shell_pipe(GREP_CMD, body)
|
||||||
|
if err and err ~= "" then
|
||||||
|
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not out or out == "" then return end
|
||||||
|
|
||||||
|
local lines = {}
|
||||||
|
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||||
|
table.insert(lines, line)
|
||||||
|
end
|
||||||
|
|
||||||
|
for entry in out:gmatch("[^\n]+") do
|
||||||
|
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
||||||
|
if linenum_str then
|
||||||
|
local linenum = tonumber(linenum_str)
|
||||||
|
matched = matched:match("^%s*(.-)%s*$")
|
||||||
|
if matched ~= "" then
|
||||||
|
local display = matched
|
||||||
|
if #display > 200 then display = display:sub(1, 200) .. "..." end
|
||||||
|
local ctx = build_context(lines, linenum)
|
||||||
|
create_finding({
|
||||||
|
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
||||||
|
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx,
|
||||||
|
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
||||||
|
severity = "high",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_request(req)
|
||||||
|
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_response(req, res)
|
||||||
|
local ct = ""
|
||||||
|
if res.headers then
|
||||||
|
ct = res.headers["Content-Type"] or ""
|
||||||
|
end
|
||||||
|
scan("response", ct, res:get_body(), req.host, req.path)
|
||||||
|
end
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
Plugin = {
|
||||||
|
name = "TruffleHog",
|
||||||
|
description = [[
|
||||||
|
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
|
||||||
|
|
||||||
|
Requires `trufflehog` v3+ to be installed and available in PATH.
|
||||||
|
|
||||||
|
Each finding is stored on the **Findings** page with the matched detector output.
|
||||||
|
Findings are deduplicated per host+path+body content so repeated requests do not create duplicates.
|
||||||
|
]],
|
||||||
|
on_start = { sync = false },
|
||||||
|
on_request = { sync = false },
|
||||||
|
on_response = { sync = false },
|
||||||
|
disable_by_default = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function on_start()
|
||||||
|
local handle = io.popen("command -v trufflehog 2>/dev/null")
|
||||||
|
local result = handle and handle:read("*a") or ""
|
||||||
|
if handle then handle:close() end
|
||||||
|
if not result or result:match("^%s*$") then
|
||||||
|
log("trufflehog is not installed or not in PATH")
|
||||||
|
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scan(label, content, host, path)
|
||||||
|
if not content or content == "" then return end
|
||||||
|
local out, err = shell_pipe("f=$(mktemp) && cat > \"$f\" && trufflehog filesystem --no-color \"$f\"; rc=$?; rm -f \"$f\"; exit $rc", content)
|
||||||
|
if err and err ~= "" then
|
||||||
|
log("trufflehog error on " .. label .. ": " .. err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not out or out == "" then return end
|
||||||
|
local blocks = {}
|
||||||
|
local current = nil
|
||||||
|
for line in out:gmatch("[^\n]+") do
|
||||||
|
if line:match("^Found ") then
|
||||||
|
if current then table.insert(blocks, current) end
|
||||||
|
current = line
|
||||||
|
elseif current then
|
||||||
|
current = current .. "\n" .. line
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if current then table.insert(blocks, current) end
|
||||||
|
for _, block in ipairs(blocks) do
|
||||||
|
create_finding({
|
||||||
|
title = "Secret detected in " .. label .. " (" .. host .. ")",
|
||||||
|
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
|
||||||
|
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
|
||||||
|
severity = "high",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_request(req)
|
||||||
|
scan("request", req:get_body(), req.host, req.path)
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_response(req, res)
|
||||||
|
scan("response", res:get_body(), req.host, req.path)
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user