mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 17:52:33 +02:00
Compare commits
54 Commits
ed59923b7d
..
v0.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e673f5c11 | |||
| 2c63cdbeff | |||
| 0e982c6ade | |||
| 04ba32cbd5 | |||
| f7e9da94cc | |||
| 9253d85c81 | |||
| 1bb547870e | |||
| 4251e4fb2a | |||
| b547a79d6e | |||
| fe58468abf | |||
| 3542098905 | |||
| f78b3f7174 | |||
| 722021ba02 | |||
| e18f660e83 | |||
| 67fe8eb911 | |||
| af872afbe8 | |||
| 2225afd9ee | |||
| 6dc959de77 | |||
| 0017f37c33 | |||
| 924cb73afb | |||
| 746f1afd1b | |||
| 905013943d | |||
| c6bca887cb | |||
| dcf9cb4c8e | |||
| ae372d7283 | |||
| e20250f0a0 | |||
| 3463e51739 | |||
| 87fa9448d6 | |||
| 4240c4ceb9 | |||
| d79c9f91d1 | |||
| 33e2afe709 | |||
| 2c3e19258f | |||
| 69d5d0ffec | |||
| d47f51d2b5 | |||
| 598455f8d3 | |||
| 28b070dafc | |||
| 6f56e0b26a | |||
| eaa960e6ab | |||
| f874a70639 | |||
| 4643989ab6 | |||
| 7bbc00880a | |||
| 385b6e84e0 | |||
| 6a9935ec27 | |||
| b490c7a0ac | |||
| 1a1c0cff30 | |||
| 172a77e13b | |||
| 41c0e489cf | |||
| 79128bb865 | |||
| 48de2a8e10 | |||
| b4a45a23e5 | |||
| b5e2721aa1 | |||
| 0cfba17d3d | |||
| a147e8b972 | |||
| 03260e0947 |
@@ -23,6 +23,6 @@ jobs:
|
|||||||
- uses: goreleaser/goreleaser-action@v6
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: "~> v2"
|
version: "~> v2"
|
||||||
args: release --clean
|
args: release --clean --config .github/.goreleaser.yaml
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
result/
|
result/
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|||||||
+24
-28
@@ -2,9 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
spilltea "github.com/anotherhadi/spilltea"
|
spilltea "github.com/anotherhadi/spilltea"
|
||||||
@@ -21,6 +21,15 @@ import (
|
|||||||
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if version != "dev" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||||
|
version = info.Main.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
||||||
@@ -46,7 +55,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *flagAddDefaultPlugins {
|
if *flagAddDefaultPlugins {
|
||||||
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
if *flagConfig != "" {
|
if *flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = *flagConfig
|
||||||
}
|
}
|
||||||
@@ -68,7 +78,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *flagAddDefaultConfig {
|
if *flagAddDefaultConfig {
|
||||||
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
if *flagConfig != "" {
|
if *flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = *flagConfig
|
||||||
}
|
}
|
||||||
@@ -85,7 +96,8 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
if *flagConfig != "" {
|
if *flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = *flagConfig
|
||||||
}
|
}
|
||||||
@@ -109,47 +121,31 @@ func main() {
|
|||||||
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
|
|
||||||
// Check if the proxy port is available before starting the UI.
|
|
||||||
ln, err := net.Listen("tcp", addr)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "proxy: cannot bind to %s: %v\n", addr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
ln.Close()
|
|
||||||
|
|
||||||
style.Init(config.Global)
|
style.Init(config.Global)
|
||||||
icons.Init(config.Global)
|
icons.Init(config.Global)
|
||||||
keys.Init(config.Global)
|
keys.Init(config.Global)
|
||||||
|
|
||||||
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
||||||
|
|
||||||
// Resolve project: either from --project flag or by running the home UI.
|
// If --project flag is set, skip the home screen entirely.
|
||||||
var project *homeUI.Project
|
|
||||||
if *flagProject != "" {
|
if *flagProject != "" {
|
||||||
p, err := homeUI.OpenProject(projectDir, *flagProject)
|
project, err := homeUI.OpenProject(projectDir, *flagProject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "project: %v\n", err)
|
fmt.Fprintf(os.Stderr, "project: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
project = p
|
broker := intercept.NewBroker()
|
||||||
} else {
|
m := appUI.New(broker, project.Name, project.Path)
|
||||||
finalModel, err := tea.NewProgram(homeUI.New(projectDir)).Run()
|
if _, err := tea.NewProgram(m).Run(); err != nil {
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
project = finalModel.(homeUI.Model).Selected()
|
|
||||||
}
|
|
||||||
|
|
||||||
// User quit the home screen without selecting a project.
|
|
||||||
if project == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
broker := intercept.NewBroker()
|
// Run home + app in a single program to avoid a blank flash on transition.
|
||||||
m := appUI.New(broker, project.Name, project.Path)
|
root := rootModel{home: homeUI.New(projectDir)}
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
if _, err := tea.NewProgram(root).Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rootState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
rootStateHome rootState = iota
|
||||||
|
rootStateApp
|
||||||
|
)
|
||||||
|
|
||||||
|
type rootModel struct {
|
||||||
|
state rootState
|
||||||
|
home homeUI.Model
|
||||||
|
app tea.Model
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rootModel) Init() tea.Cmd {
|
||||||
|
return m.home.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
||||||
|
m.width = ws.Width
|
||||||
|
m.height = ws.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.state == rootStateHome {
|
||||||
|
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
|
||||||
|
broker := intercept.NewBroker()
|
||||||
|
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
||||||
|
m.app = app
|
||||||
|
m.state = rootStateApp
|
||||||
|
return m, tea.Batch(app.Init(), func() tea.Msg {
|
||||||
|
return tea.WindowSizeMsg{Width: m.width, Height: m.height}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
updated, cmd := m.home.Update(msg)
|
||||||
|
m.home = updated.(homeUI.Model)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, cmd := m.app.Update(msg)
|
||||||
|
m.app = updated
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rootModel) View() tea.View {
|
||||||
|
if m.state == rootStateApp {
|
||||||
|
return m.app.(interface{ View() tea.View }).View()
|
||||||
|
}
|
||||||
|
return m.home.View()
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package spilltea
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed docs
|
|
||||||
var DocsFS embed.FS
|
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
- On Chrome:
|
- On Chrome:
|
||||||
- Open your Chrome settings, search for "Certificates" and click on "Security".
|
- Open your Chrome settings, search for "Certificates" and click on "Security".
|
||||||
- In the security settings page, scroll down and click on "Manage certificates".
|
- In the security settings page, scroll down and click on "Manage certificates".
|
||||||
- Select the "Authorities" tab and click on "Import tab and click on "Import".
|
- Select the "Authorities" tab and click on "Import".
|
||||||
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
|
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
|
||||||
- On Firefox:
|
- On Firefox:
|
||||||
- Open your Firefox settings, search for "Certificates" and click on "View Certificates".
|
- Open your Firefox settings, search for "Certificates" and click on "View Certificates".
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
## How do I convert a temporary session into a regular project?
|
||||||
|
|
||||||
|
Temporary sessions are stored under `/tmp/spilltea/<random-id>/` and will be lost on reboot. To keep the data, move the directory into the Spilltea projects folder and give it a name.
|
||||||
|
|
||||||
|
**1. Find the temporary session directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /tmp/spilltea/
|
||||||
|
```
|
||||||
|
|
||||||
|
You will see one or more directories with a random hex name (e.g. `a1b2c3d4`).
|
||||||
|
|
||||||
|
**2. Move it to the projects folder**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv /tmp/spilltea/<random-id> ~/.local/share/spilltea/<project-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
The project name must only contain lowercase letters, digits, `-`, and `_`.
|
||||||
|
|
||||||
|
**3. Open the project**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spilltea -P my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simply launch Spilltea and pick `my-project` from the project list.
|
||||||
+2
-6
@@ -4,9 +4,7 @@ The History page has a built-in search bar with two modes:
|
|||||||
|
|
||||||
**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies.
|
**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies.
|
||||||
|
|
||||||
**SQL mode**: press `:` to open it, then `Enter` to run. You can write either a WHERE expression or a full SELECT query against the `entries` table.
|
**SQL mode**: press `:` to open it, then `Enter` to run. Type a WHERE expression: the full `SELECT … FROM entries WHERE` is added automatically.
|
||||||
|
|
||||||
WHERE expression (the `SELECT` is added automatically):
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
status_code = 404
|
status_code = 404
|
||||||
@@ -16,10 +14,8 @@ status_code = 404
|
|||||||
host LIKE '%.api.%' AND method = 'POST'
|
host LIKE '%.api.%' AND method = 'POST'
|
||||||
```
|
```
|
||||||
|
|
||||||
Full SELECT query:
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT * FROM entries WHERE response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20
|
response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20
|
||||||
```
|
```
|
||||||
|
|
||||||
The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`.
|
The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`.
|
||||||
|
|||||||
+68
-33
@@ -1,5 +1,7 @@
|
|||||||
# Plugins
|
# Plugins
|
||||||
|
|
||||||
|
> **Warning:** Plugins can execute arbitrary shell commands, read and write files via `shell_pipe`, and access all intercepted traffic. Only load plugins you trust and have reviewed. You are solely responsible for the plugins you run.
|
||||||
|
|
||||||
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/).
|
You can found some pre-built plugins [here](../../plugins/).
|
||||||
|
|
||||||
@@ -15,9 +17,10 @@ Every plugin must declare a `Plugin` table and implement the hooks it wants to u
|
|||||||
|
|
||||||
```lua
|
```lua
|
||||||
Plugin = {
|
Plugin = {
|
||||||
name = "My Plugin",
|
name = "My Plugin",
|
||||||
description = "What this plugin does.",
|
description = "What this plugin does.",
|
||||||
priority = 0, -- higher = runs before other plugins (default: 0)
|
priority = 0, -- higher = runs before other plugins (default: 0)
|
||||||
|
disable_by_default = true, -- if true, plugin starts disabled on first load (default: false)
|
||||||
|
|
||||||
-- Declare which hooks you use and whether they are synchronous (default: false).
|
-- Declare which hooks you use and whether they are synchronous (default: false).
|
||||||
-- on_config and on_quit are always sync and do not need to be declared here.
|
-- on_config and on_quit are always sync and do not need to be declared here.
|
||||||
@@ -30,14 +33,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()` | 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` (nil does nothing and le the user/TUI choose) (sync only) |
|
||||||
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
|
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (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
|
||||||
|
|
||||||
@@ -109,17 +112,64 @@ 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
|
||||||
|
|
||||||
|
-- Return the plugin's config section as a Lua table (parsed from YAML).
|
||||||
|
-- Returns an empty table if no config is set.
|
||||||
|
local cfg = get_config()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Finding deduplication
|
### Finding deduplication
|
||||||
|
|
||||||
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
|
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts.
|
||||||
|
If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
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.).
|
Plugin configuration is stored in a `plugins.yaml` file alongside the project database.
|
||||||
|
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
|
||||||
|
|
||||||
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI.
|
```yaml
|
||||||
|
plugins:
|
||||||
|
my_plugin:
|
||||||
|
enable: true
|
||||||
|
config:
|
||||||
|
some_key: some_value
|
||||||
|
list:
|
||||||
|
- item1
|
||||||
|
- item2
|
||||||
|
other_plugin:
|
||||||
|
enable: false
|
||||||
|
```
|
||||||
|
|
||||||
|
The config block is edited from the **Plugins** page in the TUI.
|
||||||
|
Inside a plugin, call `get_config()` to retrieve the config as a Lua table.
|
||||||
|
|
||||||
|
`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI.
|
||||||
|
It is the right place to read `get_config()` and populate local variables.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local items = {}
|
||||||
|
|
||||||
|
function on_config()
|
||||||
|
items = {}
|
||||||
|
local cfg = get_config()
|
||||||
|
if cfg and cfg.list then
|
||||||
|
for _, v in ipairs(cfg.list) do
|
||||||
|
table.insert(items, v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
## Sync vs async
|
## Sync vs async
|
||||||
|
|
||||||
@@ -128,28 +178,13 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
|
|||||||
|
|
||||||
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
||||||
|
|
||||||
### Return values for sync hooks
|
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.
|
||||||
**`on_request` and `on_response`:**
|
|
||||||
|
|
||||||
| Return value | Effect |
|
|
||||||
| ------------ | --------------------------------------------------------------------------------- |
|
|
||||||
| `"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. |
|
|
||||||
|
|
||||||
**`on_history_entry` (sync only):**
|
|
||||||
|
|
||||||
| Return value | Effect |
|
|
||||||
| ------------------- | -------------------------------------- |
|
|
||||||
| `"skip"` | The entry is not saved to the DB. |
|
|
||||||
| `"keep"` or `nil` | The entry is saved normally. |
|
|
||||||
|
|
||||||
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
|
|
||||||
|
|
||||||
## Priority
|
## 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.
|
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
|
```lua
|
||||||
Plugin = {
|
Plugin = {
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ You can install it from the [Google Chrome extension store](https://chromewebsto
|
|||||||
2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save".
|
2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save".
|
||||||
3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button.
|
3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button.
|
||||||
4. You're all set! You can now use Spilltea.
|
4. You're all set! You can now use Spilltea.
|
||||||
|
|
||||||
|
If `proxy_auth` is set in the config (`user:pass`), enter the same credentials in FoxyProxy under "Username" and "Password" in the proxy settings.
|
||||||
|
|||||||
Generated
+115
@@ -1,5 +1,103 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767039857,
|
||||||
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778507602,
|
||||||
|
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gomod2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770585520,
|
||||||
|
"narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"rev": "1201ddd1279c35497754f016ef33d5e060f3da8d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777954456,
|
"lastModified": 1777954456,
|
||||||
@@ -18,8 +116,25 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"gomod2nix": "gomod2nix",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -1,55 +1,41 @@
|
|||||||
{
|
{
|
||||||
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
|
|
||||||
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";};
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
gomod2nix = {
|
||||||
|
url = "github:nix-community/gomod2nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
git-hooks = {
|
||||||
|
url = "github:cachix/git-hooks.nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs = {
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
|
gomod2nix,
|
||||||
|
git-hooks,
|
||||||
}: let
|
}: let
|
||||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
|
||||||
forAllSystems = f:
|
forAllSystems = f:
|
||||||
nixpkgs.lib.genAttrs supportedSystems
|
nixpkgs.lib.genAttrs supportedSystems
|
||||||
(system: f system (import nixpkgs {inherit system;}));
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
pname = "spilltea";
|
|
||||||
version = "0.0.4";
|
|
||||||
|
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
|
||||||
in {
|
in {
|
||||||
packages = forAllSystems (system: pkgs: let
|
packages = forAllSystems (system: pkgs:
|
||||||
pkg = pkgs.buildGoModule {
|
import ./nix/package.nix {
|
||||||
inherit pname version ldflags;
|
inherit pkgs;
|
||||||
|
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
|
||||||
src = ./.;
|
});
|
||||||
outputs = ["out"];
|
|
||||||
|
|
||||||
vendorHash = "sha256-1iPwFsyzdonak9EWMRnudwcCQZfI+Uvre38+puG4s0s=";
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
|
||||||
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
|
||||||
homepage = "https://github.com/anotherhadi/spilltea";
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
"${pname}" = pkg;
|
|
||||||
default = pkg;
|
|
||||||
});
|
|
||||||
|
|
||||||
devShells = forAllSystems (system: pkgs: {
|
devShells = forAllSystems (system: pkgs: {
|
||||||
default = pkgs.mkShell {
|
default = import ./nix/shell.nix {
|
||||||
packages = with pkgs; [
|
inherit pkgs;
|
||||||
go
|
gitHooksLib = git-hooks.lib.${system};
|
||||||
python3
|
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
|
||||||
lefthook
|
|
||||||
doctoc
|
|
||||||
];
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
lefthook install
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,12 +19,15 @@ type Config struct {
|
|||||||
Version string `mapstructure:"-"`
|
Version string `mapstructure:"-"`
|
||||||
|
|
||||||
App struct {
|
App struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
CertDir string `mapstructure:"cert_dir"`
|
CertDir string `mapstructure:"cert_dir"`
|
||||||
ProjectDir string `mapstructure:"project_dir"`
|
ProjectDir string `mapstructure:"project_dir"`
|
||||||
PluginsDir string `mapstructure:"plugins_dir"`
|
PluginsDir string `mapstructure:"plugins_dir"`
|
||||||
UpstreamProxy string `mapstructure:"upstream_proxy"`
|
UpstreamProxy string `mapstructure:"upstream_proxy"`
|
||||||
|
ProxyAuth string `mapstructure:"proxy_auth"`
|
||||||
|
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
||||||
|
ExternalEditor string `mapstructure:"external_editor"`
|
||||||
} `mapstructure:"app"`
|
} `mapstructure:"app"`
|
||||||
|
|
||||||
TUI struct {
|
TUI struct {
|
||||||
@@ -37,6 +41,7 @@ type Config struct {
|
|||||||
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
||||||
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||||
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
||||||
|
QueueSize int `mapstructure:"queue_size"`
|
||||||
} `mapstructure:"intercept"`
|
} `mapstructure:"intercept"`
|
||||||
|
|
||||||
Replay struct {
|
Replay struct {
|
||||||
@@ -65,7 +70,7 @@ func Load(path string) error {
|
|||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
viper.SetConfigFile(path)
|
viper.SetConfigFile(path)
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,7 @@ func WriteDefaultConfig(path string) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return fmt.Errorf("create config dir: %w", err)
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil {
|
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
||||||
return fmt.Errorf("write config: %w", err)
|
return fmt.Errorf("write config: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ app:
|
|||||||
project_dir: ~/.local/share/spilltea
|
project_dir: ~/.local/share/spilltea
|
||||||
plugins_dir: ~/.config/spilltea/plugins
|
plugins_dir: ~/.config/spilltea/plugins
|
||||||
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
|
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
|
||||||
|
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
|
||||||
|
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
|
||||||
|
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
default_intercept_enabled: true
|
default_intercept_enabled: true
|
||||||
default_capture_response: false
|
default_capture_response: false
|
||||||
|
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
|
||||||
auto_forward_regex:
|
auto_forward_regex:
|
||||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||||
|
|
||||||
@@ -44,53 +48,58 @@ 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"
|
prev_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"
|
||||||
delete_all: "X"
|
delete_all: "X"
|
||||||
sql_query: ":"
|
sql_query: ":"
|
||||||
filter: "/"
|
filter: "/"
|
||||||
|
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"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ type GlobalKeys struct {
|
|||||||
ScrollUp string `mapstructure:"scroll_up"`
|
ScrollUp string `mapstructure:"scroll_up"`
|
||||||
ScrollDown string `mapstructure:"scroll_down"`
|
ScrollDown string `mapstructure:"scroll_down"`
|
||||||
SendToDiff string `mapstructure:"send_to_diff"`
|
SendToDiff string `mapstructure:"send_to_diff"`
|
||||||
|
GotoTop string `mapstructure:"goto_top"`
|
||||||
|
GotoBottom string `mapstructure:"goto_bottom"`
|
||||||
|
PrevPage string `mapstructure:"prev_page"`
|
||||||
|
NextPage string `mapstructure:"next_page"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type InterceptKeys struct {
|
type InterceptKeys struct {
|
||||||
@@ -35,6 +39,7 @@ type HistoryKeys struct {
|
|||||||
DeleteAll string `mapstructure:"delete_all"`
|
DeleteAll string `mapstructure:"delete_all"`
|
||||||
Filter string `mapstructure:"filter"`
|
Filter string `mapstructure:"filter"`
|
||||||
SqlQuery string `mapstructure:"sql_query"`
|
SqlQuery string `mapstructure:"sql_query"`
|
||||||
|
Flag string `mapstructure:"flag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HomeKeys struct {
|
type HomeKeys struct {
|
||||||
|
|||||||
+33
-9
@@ -2,12 +2,16 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
|
roConn *sql.DB
|
||||||
|
path string
|
||||||
|
dedupMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(path string) (*DB, error) {
|
func Open(path string) (*DB, error) {
|
||||||
@@ -15,15 +19,33 @@ func Open(path string) (*DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
d := &DB{conn: conn}
|
// SQLite only supports one concurrent writer; a pool of connections would
|
||||||
|
// cause SQLITE_BUSY errors when multiple proxy goroutines try to insert
|
||||||
|
// history entries at the same time.
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
d := &DB{conn: conn, path: path}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
roConn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
roConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.roConn = roConn
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) migrate() error {
|
func (d *DB) migrate() error {
|
||||||
|
if _, err := d.conn.Exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
_, err := d.conn.Exec(`
|
_, err := d.conn.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -33,7 +55,9 @@ func (d *DB) migrate() error {
|
|||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
status_code INTEGER NOT NULL,
|
status_code INTEGER NOT NULL,
|
||||||
request_raw TEXT NOT NULL,
|
request_raw TEXT NOT NULL,
|
||||||
response_raw TEXT NOT NULL
|
response_raw TEXT NOT NULL,
|
||||||
|
body_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
flagged INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS replay_entries (
|
CREATE TABLE IF NOT EXISTS replay_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -48,12 +72,7 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
|||||||
status_code INTEGER NOT NULL,
|
status_code INTEGER NOT NULL,
|
||||||
error_msg TEXT NOT NULL
|
error_msg TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS plugins (
|
CREATE TABLE IF NOT EXISTS findings (
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
config_text TEXT NOT NULL DEFAULT ''
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS findings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
plugin_name TEXT NOT NULL,
|
plugin_name TEXT NOT NULL,
|
||||||
dedup_key TEXT NOT NULL,
|
dedup_key TEXT NOT NULL,
|
||||||
@@ -65,6 +84,10 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
|||||||
UNIQUE(plugin_name, dedup_key)
|
UNIQUE(plugin_name, dedup_key)
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = d.conn.Exec(`CREATE INDEX IF NOT EXISTS idx_entries_dedup ON entries(method, host, path, body_hash)`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +101,7 @@ func (d *DB) Close() error {
|
|||||||
if d == nil {
|
if d == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
_ = d.roConn.Close()
|
||||||
return d.conn.Close()
|
return d.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-42
@@ -1,8 +1,10 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -16,47 +18,57 @@ type Entry struct {
|
|||||||
StatusCode int
|
StatusCode int
|
||||||
RequestRaw string
|
RequestRaw string
|
||||||
ResponseRaw string
|
ResponseRaw string
|
||||||
|
Flagged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func bodyHash(body string) string {
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasDuplicate returns true if an entry with the same method, host, path and
|
// HasDuplicate returns true if an entry with the same method, host, path and
|
||||||
// request body already exists. Used to implement skip_duplicates filtering.
|
// request body hash already exists.
|
||||||
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
|
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
|
||||||
rows, err := d.conn.Query(
|
hash := bodyHash(body)
|
||||||
`SELECT request_raw FROM entries WHERE method = ? AND host = ? AND path = ?`,
|
var exists int
|
||||||
method, host, path,
|
err := d.conn.QueryRow(
|
||||||
)
|
`SELECT 1 FROM entries WHERE method = ? AND host = ? AND path = ? AND body_hash = ? LIMIT 1`,
|
||||||
if err != nil {
|
method, host, path, hash,
|
||||||
return false, err
|
).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
return err == nil, err
|
||||||
for rows.Next() {
|
|
||||||
var raw string
|
|
||||||
if err := rows.Scan(&raw); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(raw, "\n\n", 2)
|
|
||||||
entryBody := ""
|
|
||||||
if len(parts) == 2 {
|
|
||||||
entryBody = parts[1]
|
|
||||||
}
|
|
||||||
if entryBody == body {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) InsertEntry(e Entry) (Entry, error) {
|
// InsertIfNotDuplicate atomically checks for a duplicate and inserts if none
|
||||||
|
// exists. Returns (entry, isDuplicate, error).
|
||||||
|
func (d *DB) InsertIfNotDuplicate(e Entry, body string) (Entry, bool, error) {
|
||||||
|
d.dedupMu.Lock()
|
||||||
|
defer d.dedupMu.Unlock()
|
||||||
|
dup, err := d.HasDuplicate(e.Method, e.Host, e.Path, body)
|
||||||
|
if err != nil || dup {
|
||||||
|
return e, dup, err
|
||||||
|
}
|
||||||
|
e, err = d.InsertEntry(e, body)
|
||||||
|
return e, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
|
||||||
res, err := d.conn.Exec(
|
res, err := d.conn.Exec(
|
||||||
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw)
|
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw, body_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
e.Timestamp.UTC().Format(time.RFC3339),
|
e.Timestamp.UTC().Format(time.RFC3339),
|
||||||
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw,
|
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw, bodyHash(body),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
e.ID, _ = res.LastInsertId()
|
var idErr error
|
||||||
|
e.ID, idErr = res.LastInsertId()
|
||||||
|
if idErr != nil {
|
||||||
|
log.Printf("db: LastInsertId: %v", idErr)
|
||||||
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +77,12 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
var e Entry
|
||||||
var ts string
|
var ts string
|
||||||
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw); err != nil {
|
var flagged int
|
||||||
|
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||||
|
e.Flagged = flagged != 0
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
@@ -76,7 +90,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
|
|
||||||
func (d *DB) ListEntries() ([]Entry, error) {
|
func (d *DB) ListEntries() ([]Entry, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
|
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
|
||||||
FROM entries ORDER BY id DESC`,
|
FROM entries ORDER BY id DESC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,7 +103,7 @@ func (d *DB) ListEntries() ([]Entry, error) {
|
|||||||
func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
||||||
like := "%" + term + "%"
|
like := "%" + term + "%"
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
|
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
|
||||||
FROM entries
|
FROM entries
|
||||||
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
|
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
|
||||||
ORDER BY id DESC`,
|
ORDER BY id DESC`,
|
||||||
@@ -102,17 +116,13 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
|||||||
return scanEntries(rows)
|
return scanEntries(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryEntries executes a user-supplied query against the entries table.
|
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||||
// If the query does not start with SELECT, it is treated as a WHERE expression
|
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
|
||||||
// and wrapped automatically (e.g. "status_code = 404" becomes a full SELECT).
|
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||||
func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
|
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||||
q := strings.TrimSpace(rawSQL)
|
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
||||||
if !strings.HasPrefix(strings.ToUpper(q), "SELECT") {
|
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
|
||||||
q = "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw FROM entries WHERE " + q
|
rows, err := d.roConn.Query(q)
|
||||||
} else if strings.ContainsAny(strings.ToUpper(q), "INSERTDELETEUPDATEDROP") {
|
|
||||||
return nil, fmt.Errorf("only SELECT queries are allowed")
|
|
||||||
}
|
|
||||||
rows, err := d.conn.Query(q)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -120,6 +130,11 @@ func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
|
|||||||
return scanEntries(rows)
|
return scanEntries(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ToggleFlag(id int64) error {
|
||||||
|
_, err := d.conn.Exec(`UPDATE entries SET flagged = NOT flagged WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) DeleteEntry(id int64) error {
|
func (d *DB) DeleteEntry(id int64) error {
|
||||||
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
|
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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, ?)`,
|
||||||
@@ -33,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
|||||||
func (d *DB) LoadFindings() ([]Finding, error) {
|
func (d *DB) LoadFindings() ([]Finding, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
|
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
|
||||||
FROM findings WHERE dismissed = 0 ORDER BY id DESC`,
|
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
type PluginState struct {
|
|
||||||
Name string
|
|
||||||
Enabled bool
|
|
||||||
ConfigText string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) SavePluginState(name string, enabled bool, configText string) error {
|
|
||||||
_, err := d.conn.Exec(
|
|
||||||
`INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`,
|
|
||||||
name, enabled, configText,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) LoadPluginStates() ([]PluginState, error) {
|
|
||||||
rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []PluginState
|
|
||||||
for rows.Next() {
|
|
||||||
var s PluginState
|
|
||||||
var enabled int
|
|
||||||
if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.Enabled = enabled != 0
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ type Icons struct {
|
|||||||
New string
|
New string
|
||||||
Temp string
|
Temp string
|
||||||
Project string
|
Project string
|
||||||
|
Flag string
|
||||||
}
|
}
|
||||||
|
|
||||||
var I *Icons
|
var I *Icons
|
||||||
@@ -44,6 +45,7 @@ func Init(cfg *config.Config) {
|
|||||||
New: " ",
|
New: " ",
|
||||||
Temp: " ",
|
Temp: " ",
|
||||||
Project: " ",
|
Project: " ",
|
||||||
|
Flag: " ",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
I = &Icons{}
|
I = &Icons{}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package intercept
|
package intercept
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -57,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewBroker() *Broker {
|
func NewBroker() *Broker {
|
||||||
|
size := config.Global.Intercept.QueueSize
|
||||||
|
if size <= 0 {
|
||||||
|
size = 64
|
||||||
|
}
|
||||||
b := &Broker{
|
b := &Broker{
|
||||||
Incoming: make(chan *PendingRequest, 64),
|
Incoming: make(chan *PendingRequest, size),
|
||||||
IncomingResponse: make(chan *PendingResponse, 64),
|
IncomingResponse: make(chan *PendingResponse, size),
|
||||||
}
|
}
|
||||||
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
||||||
return b
|
return b
|
||||||
@@ -75,9 +80,12 @@ func (b *Broker) SetCaptureResponse(v bool) {
|
|||||||
func (b *Broker) SetAutoForwardRegex(patterns []string) {
|
func (b *Broker) SetAutoForwardRegex(patterns []string) {
|
||||||
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
if r, err := regexp.Compile(p); err == nil {
|
r, err := regexp.Compile(p)
|
||||||
compiled = append(compiled, r)
|
if err != nil {
|
||||||
|
log.Printf("intercept: invalid auto_forward_regex %q: %v", p, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
compiled = append(compiled, r)
|
||||||
}
|
}
|
||||||
b.autoFwdMu.Lock()
|
b.autoFwdMu.Lock()
|
||||||
b.autoFwdRegexes = compiled
|
b.autoFwdRegexes = compiled
|
||||||
@@ -164,19 +172,14 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
if config.Global.History.SkipDuplicates {
|
body := string(r.Body)
|
||||||
body := string(r.Body)
|
|
||||||
if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending := db.Entry{
|
pending := db.Entry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
Host: r.URL.Host,
|
Host: r.URL.Host,
|
||||||
Path: path,
|
Path: path,
|
||||||
StatusCode: status,
|
StatusCode: status,
|
||||||
RequestRaw: FormatRawRequest(f),
|
RequestRaw: FormatRawRequest(f),
|
||||||
ResponseRaw: func() string {
|
ResponseRaw: func() string {
|
||||||
if config.Global.History.KeepResponses {
|
if config.Global.History.KeepResponses {
|
||||||
return FormatRawResponse(f)
|
return FormatRawResponse(f)
|
||||||
@@ -189,11 +192,25 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry, err := d.InsertEntry(pending)
|
var (
|
||||||
if err == nil {
|
entry db.Entry
|
||||||
if cb := b.onNewEntry; cb != nil {
|
err error
|
||||||
go cb(entry)
|
)
|
||||||
|
if config.Global.History.SkipDuplicates {
|
||||||
|
var dup bool
|
||||||
|
entry, dup, err = d.InsertIfNotDuplicate(pending, body)
|
||||||
|
if dup {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
entry, err = d.InsertEntry(pending, body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("intercept: failed to save history entry: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cb := b.onNewEntry; cb != nil {
|
||||||
|
go cb(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package intercept
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
"github.com/lqqyt2423/go-mitmproxy/proxy"
|
"github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,15 +14,8 @@ func FormatRawRequest(f *proxy.Flow) string {
|
|||||||
r := f.Request
|
r := f.Request
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
||||||
keys := make([]string, 0, len(r.Header))
|
for _, line := range util.SortedHeaderLines(r.Header) {
|
||||||
for k := range r.Header {
|
sb.WriteString(line)
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if len(r.Body) > 0 {
|
if len(r.Body) > 0 {
|
||||||
@@ -43,15 +36,8 @@ func FormatRawResponse(f *proxy.Flow) string {
|
|||||||
proto = "HTTP/1.1"
|
proto = "HTTP/1.1"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
|
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
|
||||||
keys := make([]string, 0, len(r.Header))
|
for _, line := range util.SortedHeaderLines(r.Header) {
|
||||||
for k := range r.Header {
|
sb.WriteString(line)
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if len(r.Body) > 0 {
|
if len(r.Body) > 0 {
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ type GlobalKeyMap struct {
|
|||||||
ScrollUp key.Binding
|
ScrollUp key.Binding
|
||||||
ScrollDown key.Binding
|
ScrollDown key.Binding
|
||||||
SendToDiff key.Binding
|
SendToDiff key.Binding
|
||||||
|
GotoTop key.Binding
|
||||||
|
GotoBottom key.Binding
|
||||||
|
PrevPage key.Binding
|
||||||
|
NextPage key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
||||||
@@ -42,6 +46,10 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
|||||||
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
||||||
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
||||||
SendToDiff: binding(cfg.SendToDiff, "send to diff"),
|
SendToDiff: binding(cfg.SendToDiff, "send to diff"),
|
||||||
|
GotoTop: binding(cfg.GotoTop, "go to top"),
|
||||||
|
GotoBottom: binding(cfg.GotoBottom, "go to bottom"),
|
||||||
|
PrevPage: binding(cfg.PrevPage, "prev page"),
|
||||||
|
NextPage: binding(cfg.NextPage, "next page"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +60,7 @@ func (g GlobalKeyMap) Bindings() []key.Binding {
|
|||||||
g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
|
g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
|
||||||
g.SendToReplay, g.SendToDiff,
|
g.SendToReplay, g.SendToDiff,
|
||||||
g.ScrollUp, g.ScrollDown,
|
g.ScrollUp, g.ScrollDown,
|
||||||
|
g.GotoTop, g.GotoBottom, g.PrevPage, g.NextPage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type HistoryKeyMap struct {
|
|||||||
DeleteAll key.Binding
|
DeleteAll key.Binding
|
||||||
Filter key.Binding
|
Filter key.Binding
|
||||||
SqlQuery key.Binding
|
SqlQuery key.Binding
|
||||||
|
Flag key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
||||||
@@ -18,9 +19,10 @@ func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
|||||||
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
||||||
Filter: binding(cfg.Filter, "filter"),
|
Filter: binding(cfg.Filter, "filter"),
|
||||||
SqlQuery: binding(cfg.SqlQuery, "sql query"),
|
SqlQuery: binding(cfg.SqlQuery, "sql query"),
|
||||||
|
Flag: binding(cfg.Flag, "flag"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h HistoryKeyMap) Bindings() []key.Binding {
|
func (h HistoryKeyMap) Bindings() []key.Binding {
|
||||||
return []key.Binding{h.DeleteEntry, h.DeleteAll}
|
return []key.Binding{h.DeleteEntry, h.DeleteAll, h.Flag}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-8
@@ -1,17 +1,39 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
lua "github.com/yuin/gopher-lua"
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
||||||
L := lua.NewState()
|
L := lua.NewState(lua.Options{SkipOpenLibs: true})
|
||||||
|
for _, lib := range []struct {
|
||||||
|
name string
|
||||||
|
fn lua.LGFunction
|
||||||
|
}{
|
||||||
|
{lua.BaseLibName, lua.OpenBase},
|
||||||
|
{lua.TabLibName, lua.OpenTable},
|
||||||
|
{lua.StringLibName, lua.OpenString},
|
||||||
|
{lua.MathLibName, lua.OpenMath},
|
||||||
|
{lua.CoroutineLibName, lua.OpenCoroutine},
|
||||||
|
} {
|
||||||
|
L.Push(L.NewFunction(lib.fn))
|
||||||
|
L.Push(lua.LString(lib.name))
|
||||||
|
L.Call(1, 0)
|
||||||
|
}
|
||||||
|
// Remove filesystem-access functions to prevent plugins from reading/executing arbitrary files.
|
||||||
|
for _, name := range []string{"dofile", "loadfile", "load"} {
|
||||||
|
L.SetGlobal(name, lua.LNil)
|
||||||
|
}
|
||||||
registerUtilities(L, mgr, p)
|
registerUtilities(L, mgr, p)
|
||||||
return L
|
return L
|
||||||
}
|
}
|
||||||
@@ -153,6 +175,81 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
L.SetGlobal("get_config", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
// p.mu is already held by the hook caller - do not lock again.
|
||||||
|
configText := p.ConfigText
|
||||||
|
if configText == "" {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
var data interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &data); err != nil || data == nil {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
lv := goToLuaValue(L, data)
|
||||||
|
if _, ok := lv.(*lua.LTable); !ok {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
L.Push(lv)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
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 goToLuaValue(L *lua.LState, v interface{}) lua.LValue {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
t := L.NewTable()
|
||||||
|
for k, v2 := range val {
|
||||||
|
L.SetField(t, k, goToLuaValue(L, v2))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case []interface{}:
|
||||||
|
t := L.NewTable()
|
||||||
|
for i, v2 := range val {
|
||||||
|
L.RawSetInt(t, i+1, goToLuaValue(L, v2))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
return lua.LString(val)
|
||||||
|
case int:
|
||||||
|
return lua.LNumber(val)
|
||||||
|
case float64:
|
||||||
|
return lua.LNumber(val)
|
||||||
|
case bool:
|
||||||
|
if val {
|
||||||
|
return lua.LTrue
|
||||||
|
}
|
||||||
|
return lua.LFalse
|
||||||
|
}
|
||||||
|
return lua.LNil
|
||||||
}
|
}
|
||||||
|
|
||||||
func luaTableString(t *lua.LTable, key string) string {
|
func luaTableString(t *lua.LTable, key string) string {
|
||||||
@@ -246,22 +343,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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+156
-134
@@ -19,8 +19,9 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
plugins []*Plugin
|
plugins []*Plugin
|
||||||
|
|
||||||
db *db.DB
|
db *db.DB
|
||||||
broker *intercept.Broker
|
pluginsFile *PluginsFile
|
||||||
|
broker *intercept.Broker
|
||||||
|
|
||||||
Notifs chan PluginNotifMsg
|
Notifs chan PluginNotifMsg
|
||||||
Quit chan string
|
Quit chan string
|
||||||
@@ -43,6 +44,10 @@ func (m *Manager) SetDB(d *db.DB) {
|
|||||||
m.db = d
|
m.db = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetPluginsFile(pf *PluginsFile) {
|
||||||
|
m.pluginsFile = pf
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) LoadFromDir(dir string) error {
|
func (m *Manager) LoadFromDir(dir string) error {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -52,17 +57,6 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var states map[string]db.PluginState
|
|
||||||
if m.db != nil {
|
|
||||||
list, err := m.db.LoadPluginStates()
|
|
||||||
if err == nil {
|
|
||||||
states = make(map[string]db.PluginState, len(list))
|
|
||||||
for _, s := range list {
|
|
||||||
states[s.Name] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||||
continue
|
continue
|
||||||
@@ -73,19 +67,27 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
log.Printf("plugin load error %s: %v", path, err)
|
log.Printf("plugin load error %s: %v", path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s, ok := states[p.Name]; ok {
|
if m.pluginsFile != nil {
|
||||||
p.Enabled = s.Enabled
|
if enabled, configText, found := m.pluginsFile.get(p.ID); found {
|
||||||
p.ConfigText = s.ConfigText
|
p.Enabled = enabled
|
||||||
|
p.ConfigText = configText
|
||||||
|
} else {
|
||||||
|
m.pluginsFile.register(p.ID, p.Enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.plugins = append(m.plugins, p)
|
m.plugins = append(m.plugins, p)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
sort.Slice(m.plugins, func(i, j int) bool { return m.plugins[i].Priority > m.plugins[j].Priority })
|
||||||
|
m.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
||||||
p := &Plugin{
|
p := &Plugin{
|
||||||
|
ID: strings.TrimSuffix(filepath.Base(path), ".lua"),
|
||||||
FilePath: path,
|
FilePath: path,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
hooks: make(map[string]HookConfig),
|
hooks: make(map[string]HookConfig),
|
||||||
@@ -106,7 +108,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.Name = string(s)
|
p.Name = string(s)
|
||||||
}
|
}
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
|
p.Name = p.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
|
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
|
||||||
@@ -117,6 +119,10 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.Priority = int(n)
|
p.Priority = int(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pluginTable.RawGetString("disable_by_default") == lua.LTrue {
|
||||||
|
p.Enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks configurable via the Plugin table (sync field).
|
// Hooks configurable via the Plugin table (sync field).
|
||||||
configurableHooks := map[string]bool{
|
configurableHooks := map[string]bool{
|
||||||
"on_start": false, // async by default
|
"on_start": false, // async by default
|
||||||
@@ -124,12 +130,6 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
"on_response": false,
|
"on_response": false,
|
||||||
"on_history_entry": 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 {
|
for hookName, defaultSync := range configurableHooks {
|
||||||
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
||||||
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
|
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
|
||||||
@@ -139,9 +139,9 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.hooks[hookName] = HookConfig{Sync: defaultSync}
|
p.hooks[hookName] = HookConfig{Sync: defaultSync}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for hookName := range fixedSyncHooks {
|
for _, fixedSync := range []string{"on_config", "on_quit"} {
|
||||||
if p.L.GetGlobal(hookName) != lua.LNil {
|
if p.L.GetGlobal(fixedSync) != lua.LNil {
|
||||||
p.hooks[hookName] = HookConfig{Sync: true}
|
p.hooks[fixedSync] = HookConfig{Sync: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,15 +153,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) TogglePlugin(name string) {
|
func (m *Manager) TogglePlugin(id string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -173,18 +172,57 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.Enabled = !found.Enabled
|
found.Enabled = !found.Enabled
|
||||||
enabled := found.Enabled
|
enabled := found.Enabled
|
||||||
configText := found.ConfigText
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
if err := m.pluginsFile.setEnabled(id, enabled); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.pluginsFile != nil {
|
||||||
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(id, configText string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -195,39 +233,35 @@ func (m *Manager) SaveConfig(name, configText string) {
|
|||||||
}
|
}
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.ConfigText = configText
|
found.ConfigText = configText
|
||||||
enabled := found.Enabled
|
|
||||||
_, hasOnConfig := found.hooks["on_config"]
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
if err := m.pluginsFile.setConfig(id, configText); err != nil {
|
||||||
|
log.Printf("plugin %s: save config: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !hasOnConfig {
|
if _, ok := found.hooks["on_config"]; !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// on_config is always sync.
|
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
|
if _, err := callHook(found, "on_config"); err != nil {
|
||||||
log.Printf("plugin %s on_config (config reload): %v", name, err)
|
log.Printf("plugin %s on_config: %v", id, 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_config"]; !ok {
|
if _, ok := p.hooks["on_config"]; ok {
|
||||||
continue
|
p.mu.Lock()
|
||||||
|
if _, err := callHook(p, "on_config"); err != nil {
|
||||||
|
log.Printf("plugin %s on_config: %v", p.Name, err)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
|
|
||||||
log.Printf("plugin %s on_config: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
// on_start runs after, sync or async depending on plugin config.
|
|
||||||
for _, p := range m.GetPlugins() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
@@ -236,17 +270,33 @@ 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.pluginsFile != nil {
|
||||||
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -270,94 +320,79 @@ func (m *Manager) RunOnQuit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
// runSyncDecisionForPlugins runs hookName synchronously for all enabled plugins
|
||||||
|
// that registered it as sync, and returns the first non-Intercept decision.
|
||||||
|
func (m *Manager) runSyncDecisionForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) intercept.Decision {
|
||||||
for _, p := range m.GetPlugins() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hc, ok := p.hooks["on_request"]
|
hc, ok := p.hooks[hookName]
|
||||||
if !ok || !hc.Sync {
|
if !ok || !hc.Sync {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
result, err := callHook(p, "on_request", pushRequest(p.L, f))
|
result, err := callHook(p, hookName, argsFor(p)...)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("plugin %s on_request: %v", p.Name, err)
|
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch result {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runAsyncForPlugins fires hookName asynchronously for all enabled plugins
|
||||||
|
// that registered it as async.
|
||||||
|
func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) {
|
||||||
|
for _, p := range m.GetPlugins() {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hc, ok := p.hooks[hookName]
|
||||||
|
if !ok || hc.Sync {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func(p *Plugin) {
|
||||||
|
p.mu.Lock()
|
||||||
|
if _, err := callHook(p, hookName, argsFor(p)...); err != nil {
|
||||||
|
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
||||||
|
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
|
return []lua.LValue{pushRequest(p.L, f)}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
||||||
for _, p := range m.GetPlugins() {
|
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushRequest(p.L, f)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_request"]
|
|
||||||
if !ok || hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(p *Plugin) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_request", pushRequest(p.L, f)); err != nil {
|
|
||||||
log.Printf("plugin %s on_request: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}(p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
||||||
for _, p := range m.GetPlugins() {
|
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_response"]
|
|
||||||
if !ok || !hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
result, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f))
|
|
||||||
p.mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("plugin %s on_response: %v", p.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch result {
|
|
||||||
case "drop":
|
|
||||||
return intercept.Drop
|
|
||||||
case "forward":
|
|
||||||
return intercept.Forward
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return intercept.Intercept
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
||||||
for _, p := range m.GetPlugins() {
|
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_response"]
|
|
||||||
if !ok || hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(p *Plugin) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)); err != nil {
|
|
||||||
log.Printf("plugin %s on_response: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}(p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
|
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
|
||||||
@@ -377,7 +412,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,20 +420,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
|
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
|
||||||
for _, p := range m.GetPlugins() {
|
m.runAsyncForPlugins("on_history_entry", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushEntry(p.L, e)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_history_entry"]
|
|
||||||
if !ok || hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(p *Plugin) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_history_entry", pushEntry(p.L, e)); err != nil {
|
|
||||||
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}(p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pluginFileEntry struct {
|
||||||
|
Enable bool `yaml:"enable"`
|
||||||
|
Config interface{} `yaml:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pluginsFileData struct {
|
||||||
|
Plugins map[string]pluginFileEntry `yaml:"plugins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginsFile struct {
|
||||||
|
path string
|
||||||
|
data pluginsFileData
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
|
||||||
|
path := filepath.Join(filepath.Dir(dbPath), "plugins.yaml")
|
||||||
|
pf := &PluginsFile{
|
||||||
|
path: path,
|
||||||
|
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &pf.data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pf.data.Plugins == nil {
|
||||||
|
pf.data.Plugins = make(map[string]pluginFileEntry)
|
||||||
|
}
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) save() error {
|
||||||
|
raw, err := yaml.Marshal(&pf.data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(pf.path, raw, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool) {
|
||||||
|
e, ok := pf.data.Plugins[id]
|
||||||
|
if !ok {
|
||||||
|
return false, "", false
|
||||||
|
}
|
||||||
|
if e.Config == nil {
|
||||||
|
return e.Enable, "", true
|
||||||
|
}
|
||||||
|
raw, err := yaml.Marshal(e.Config)
|
||||||
|
if err != nil {
|
||||||
|
return e.Enable, "", true
|
||||||
|
}
|
||||||
|
return e.Enable, string(raw), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) register(id string, defaultEnabled bool) {
|
||||||
|
if _, ok := pf.data.Plugins[id]; !ok {
|
||||||
|
pf.data.Plugins[id] = pluginFileEntry{Enable: defaultEnabled}
|
||||||
|
_ = pf.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) setEnabled(id string, enabled bool) error {
|
||||||
|
e := pf.data.Plugins[id]
|
||||||
|
e.Enable = enabled
|
||||||
|
pf.data.Plugins[id] = e
|
||||||
|
return pf.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) setConfig(id string, configText string) error {
|
||||||
|
e := pf.data.Plugins[id]
|
||||||
|
if configText == "" {
|
||||||
|
e.Config = nil
|
||||||
|
} else {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &parsed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Config = parsed
|
||||||
|
}
|
||||||
|
pf.data.Plugins[id] = e
|
||||||
|
return pf.save()
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type HookConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -57,6 +59,7 @@ func (p *Plugin) Info() Info {
|
|||||||
hooks[k] = v
|
hooks[k] = v
|
||||||
}
|
}
|
||||||
return Info{
|
return Info{
|
||||||
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
FilePath: p.FilePath,
|
FilePath: p.FilePath,
|
||||||
|
|||||||
+47
-3
@@ -1,10 +1,14 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
@@ -43,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
|||||||
switch a.plugins.RunSyncOnRequest(f) {
|
switch a.plugins.RunSyncOnRequest(f) {
|
||||||
case intercept.Drop:
|
case intercept.Drop:
|
||||||
f.Response = dropResponse()
|
f.Response = dropResponse()
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
|
||||||
return
|
return
|
||||||
case intercept.Forward:
|
case intercept.Forward:
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
go a.plugins.RunAsyncOnRequest(f)
|
||||||
@@ -63,7 +66,15 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
|||||||
func (a *interceptAddon) Response(f *goproxy.Flow) {
|
func (a *interceptAddon) Response(f *goproxy.Flow) {
|
||||||
if f.Response != nil {
|
if f.Response != nil {
|
||||||
if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
|
if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
|
||||||
body, _ := io.ReadAll(f.Response.BodyReader)
|
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
|
||||||
|
body, err := io.ReadAll(io.LimitReader(f.Response.BodyReader, limit))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("proxy: reading response body: %v", err)
|
||||||
|
}
|
||||||
|
if int64(len(body)) == limit {
|
||||||
|
log.Printf("proxy: response body truncated at %dMB for %s", config.Global.App.MaxBodySizeMB, f.Request.URL.Host)
|
||||||
|
body = append(body, []byte(fmt.Sprintf("\n\n[body truncated at %dMB]", config.Global.App.MaxBodySizeMB))...)
|
||||||
|
}
|
||||||
f.Response.Body = body
|
f.Response.Body = body
|
||||||
f.Response.BodyReader = nil
|
f.Response.BodyReader = nil
|
||||||
}
|
}
|
||||||
@@ -106,7 +117,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
|
|
||||||
opts := &goproxy.Options{
|
opts := &goproxy.Options{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
StreamLargeBodies: 1024 * 1024 * 5,
|
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
||||||
CaRootPath: caPath,
|
CaRootPath: caPath,
|
||||||
Upstream: cfg.UpstreamProxy,
|
Upstream: cfg.UpstreamProxy,
|
||||||
}
|
}
|
||||||
@@ -116,10 +127,43 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.ProxyAuth != "" {
|
||||||
|
parts := strings.SplitN(cfg.ProxyAuth, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
wantUser, wantPass := parts[0], parts[1]
|
||||||
|
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
||||||
|
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
||||||
|
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(wantUser))
|
||||||
|
passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(wantPass))
|
||||||
|
if !ok || userOK&passOK != 1 {
|
||||||
|
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
|
||||||
|
return false, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
|
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
|
||||||
return p.Start()
|
return p.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBasicProxyAuth(header string) (user, pass string, ok bool) {
|
||||||
|
const prefix = "Basic "
|
||||||
|
if !strings.HasPrefix(header, prefix) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(header[len(prefix):])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(decoded), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], true
|
||||||
|
}
|
||||||
|
|
||||||
func dropResponse() *goproxy.Response {
|
func dropResponse() *goproxy.Response {
|
||||||
return &goproxy.Response{
|
return &goproxy.Response{
|
||||||
StatusCode: 502,
|
StatusCode: 502,
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ func NewViewport() viewport.Model {
|
|||||||
return vp
|
return vp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ViewportView(vp *viewport.Model) string {
|
||||||
|
v := vp.View()
|
||||||
|
if vp.AtBottom() {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
lines := strings.Split(v, "\n")
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓")
|
||||||
|
arrowW := lipgloss.Width(arrow)
|
||||||
|
inner := vp.Width() - 2*arrowW
|
||||||
|
if inner < 0 {
|
||||||
|
inner = 0
|
||||||
|
}
|
||||||
|
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func NewPaginator() paginator.Model {
|
func NewPaginator() paginator.Model {
|
||||||
p := paginator.New()
|
p := paginator.New()
|
||||||
p.Type = paginator.Dots
|
p.Type = paginator.Dots
|
||||||
@@ -46,7 +65,6 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
|
|||||||
return ta
|
return ta
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
|
|
||||||
func SeverityStyle(sev string) lipgloss.Style {
|
func SeverityStyle(sev string) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true)
|
base := lipgloss.NewStyle().Bold(true)
|
||||||
switch sev {
|
switch sev {
|
||||||
@@ -63,7 +81,6 @@ func SeverityStyle(sev string) lipgloss.Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusStyle returns a bold lipgloss style coloured by HTTP status code.
|
|
||||||
func StatusStyle(code, width int) lipgloss.Style {
|
func StatusStyle(code, width int) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true).Width(width)
|
base := lipgloss.NewStyle().Bold(true).Width(width)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ func Paint(c color.Color, s string) string {
|
|||||||
return lipgloss.NewStyle().Foreground(c).Render(s)
|
return lipgloss.NewStyle().Foreground(c).Render(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HighlightHTTP highlights a full raw HTTP message (headers + body).
|
|
||||||
func HighlightHTTP(raw string) string {
|
func HighlightHTTP(raw string) string {
|
||||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
idx := strings.Index(raw, "\n\n")
|
idx := strings.Index(raw, "\n\n")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return highlightHeaders(raw)
|
return highlightHeaders(raw)
|
||||||
|
|||||||
+18
-6
@@ -28,6 +28,12 @@ type Styles struct {
|
|||||||
|
|
||||||
PagerDotActive string
|
PagerDotActive string
|
||||||
PagerDotInactive string
|
PagerDotInactive string
|
||||||
|
|
||||||
|
methodGet lipgloss.Style
|
||||||
|
methodPost lipgloss.Style
|
||||||
|
methodPutPatch lipgloss.Style
|
||||||
|
methodDelete lipgloss.Style
|
||||||
|
methodDefault lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
var S *Styles
|
var S *Styles
|
||||||
@@ -46,6 +52,7 @@ func Init(cfg *config.Config) {
|
|||||||
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
|
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
|
||||||
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
|
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
|
||||||
|
|
||||||
|
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
|
||||||
S = &Styles{
|
S = &Styles{
|
||||||
Primary: primary,
|
Primary: primary,
|
||||||
Success: success,
|
Success: success,
|
||||||
@@ -74,6 +81,12 @@ func Init(cfg *config.Config) {
|
|||||||
|
|
||||||
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(),
|
||||||
|
|
||||||
|
methodGet: methodBase.Foreground(success),
|
||||||
|
methodPost: methodBase.Foreground(warning),
|
||||||
|
methodPutPatch: methodBase.Foreground(primary),
|
||||||
|
methodDelete: methodBase.Foreground(errCol),
|
||||||
|
methodDefault: methodBase.Foreground(text),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,17 +103,16 @@ func NewHelp() help.Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Styles) Method(method string) lipgloss.Style {
|
func (s *Styles) Method(method string) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true).Width(7)
|
|
||||||
switch method {
|
switch method {
|
||||||
case "GET":
|
case "GET":
|
||||||
return base.Foreground(s.Success)
|
return s.methodGet
|
||||||
case "POST":
|
case "POST":
|
||||||
return base.Foreground(s.Warning)
|
return s.methodPost
|
||||||
case "PUT", "PATCH":
|
case "PUT", "PATCH":
|
||||||
return base.Foreground(s.Primary)
|
return s.methodPutPatch
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return base.Foreground(s.Error)
|
return s.methodDelete
|
||||||
default:
|
default:
|
||||||
return base.Foreground(s.Text)
|
return s.methodDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -31,10 +32,9 @@ const tickInterval = 2 * time.Second
|
|||||||
type tickMsg struct{}
|
type tickMsg struct{}
|
||||||
|
|
||||||
func tickCmd() tea.Cmd {
|
func tickCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return tea.Tick(tickInterval, func(time.Time) tea.Msg {
|
||||||
time.Sleep(tickInterval)
|
|
||||||
return tickMsg{}
|
return tickMsg{}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sidebarEntries = pageRegistry
|
var sidebarEntries = pageRegistry
|
||||||
@@ -94,14 +94,23 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
||||||
}
|
}
|
||||||
|
|
||||||
if d, err := db.Open(path); err == nil {
|
d, err := db.Open(path)
|
||||||
m.database = d
|
if err != nil {
|
||||||
broker.SetDB(d)
|
fmt.Fprintf(os.Stderr, "db: %v\n", err)
|
||||||
m.history.SetDB(d)
|
os.Exit(1)
|
||||||
m.replay.SetDB(d)
|
|
||||||
m.findingsPage.SetDB(d)
|
|
||||||
mgr.SetDB(d)
|
|
||||||
}
|
}
|
||||||
|
m.database = d
|
||||||
|
broker.SetDB(d)
|
||||||
|
m.history.SetDB(d)
|
||||||
|
m.replay.SetDB(d)
|
||||||
|
m.findingsPage.SetDB(d)
|
||||||
|
mgr.SetDB(d)
|
||||||
|
|
||||||
|
pf, err := plugins.OpenPluginsFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugins file: %v", err)
|
||||||
|
}
|
||||||
|
mgr.SetPluginsFile(pf)
|
||||||
|
|
||||||
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
||||||
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type pageEntry struct {
|
|||||||
isEditing func(m *Model) bool
|
isEditing func(m *Model) bool
|
||||||
// resize propagates a new (w, h) to the page model.
|
// resize propagates a new (w, h) to the page model.
|
||||||
resize func(m *Model, w, h int)
|
resize func(m *Model, w, h int)
|
||||||
|
// hasUpdate reports whether the page has unseen updates.
|
||||||
|
hasUpdate func(m *Model) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var pageRegistry = []pageEntry{
|
var pageRegistry = []pageEntry{
|
||||||
@@ -52,6 +54,7 @@ var pageRegistry = []pageEntry{
|
|||||||
},
|
},
|
||||||
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
||||||
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.intercept.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageHistory,
|
id: pageHistory,
|
||||||
@@ -114,7 +117,8 @@ var pageRegistry = []pageEntry{
|
|||||||
m.findingsPage = updated.(findingsUI.Model)
|
m.findingsPage = updated.(findingsUI.Model)
|
||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.findingsPage.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageDocs,
|
id: pageDocs,
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ func (m *Model) renderSidebar() string {
|
|||||||
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
||||||
|
|
||||||
var items strings.Builder
|
var items strings.Builder
|
||||||
|
badgeUnread := lipgloss.NewStyle().Foreground(s.Warning).Bold(true)
|
||||||
|
|
||||||
for i, entry := range sidebarEntries {
|
for i, entry := range sidebarEntries {
|
||||||
selected := entry.id == m.page
|
selected := entry.id == m.page
|
||||||
badgeStyle, textStyle := badgeNormal, textNormal
|
badgeStyle, textStyle := badgeNormal, textNormal
|
||||||
if selected {
|
if selected {
|
||||||
badgeStyle, textStyle = badgeSelected, textSelected
|
badgeStyle, textStyle = badgeSelected, textSelected
|
||||||
|
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
|
||||||
|
badgeStyle = badgeUnread
|
||||||
}
|
}
|
||||||
icon := ""
|
icon := ""
|
||||||
if entry.icon != nil {
|
if entry.icon != nil {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case intercept.RequestArrivedMsg:
|
case intercept.RequestArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
m.intercept = updated.(interceptUI.Model)
|
m.intercept = updated.(interceptUI.Model)
|
||||||
|
if m.page == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
||||||
case intercept.ResponseArrivedMsg:
|
case intercept.ResponseArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
@@ -104,6 +107,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case proxyPkg.ErrMsg:
|
case proxyPkg.ErrMsg:
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
log.Printf("proxy error: %v", msg.Err)
|
log.Printf("proxy error: %v", msg.Err)
|
||||||
|
return m, tea.Batch(
|
||||||
|
func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Proxy Error",
|
||||||
|
Body: msg.Err.Error(),
|
||||||
|
Kind: notificationsUI.KindError,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tea.Quit,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -119,6 +132,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case findingsUI.FindingsLoadedMsg:
|
case findingsUI.FindingsLoadedMsg:
|
||||||
updated, cmd := m.findingsPage.Update(msg)
|
updated, cmd := m.findingsPage.Update(msg)
|
||||||
m.findingsPage = updated.(findingsUI.Model)
|
m.findingsPage = updated.(findingsUI.Model)
|
||||||
|
if m.page == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case replayUI.SendToReplayMsg:
|
case replayUI.SendToReplayMsg:
|
||||||
@@ -177,45 +193,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
|
||||||
|
|
||||||
@@ -231,8 +264,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.history.RefreshCmd()
|
return m, m.history.RefreshCmd()
|
||||||
}
|
}
|
||||||
if p == pageFindings {
|
if p == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
return m, findingsUI.RefreshCmd(m.database)
|
return m, findingsUI.RefreshCmd(m.database)
|
||||||
}
|
}
|
||||||
|
if p == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +1,12 @@
|
|||||||
package copyas
|
package copyas
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type header struct{ key, value string }
|
type header struct{ key, value string }
|
||||||
@@ -12,46 +16,22 @@ type parsedRequest struct {
|
|||||||
path string
|
path string
|
||||||
host string
|
host string
|
||||||
scheme string
|
scheme string
|
||||||
headers []header
|
headers []header // garder header{key, value} pour compat locale
|
||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRaw(raw, scheme string) parsedRequest {
|
func parseRaw(raw, scheme string) parsedRequest {
|
||||||
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
r := util.ParseRawRequest(raw)
|
||||||
pr := parsedRequest{scheme: scheme}
|
pr := parsedRequest{
|
||||||
if len(lines) == 0 {
|
method: r.Method,
|
||||||
return pr
|
path: r.Path,
|
||||||
|
host: r.Host,
|
||||||
|
scheme: scheme,
|
||||||
}
|
}
|
||||||
|
for _, h := range r.Headers {
|
||||||
parts := strings.SplitN(lines[0], " ", 3)
|
pr.headers = append(pr.headers, header{h.Key, h.Value})
|
||||||
if len(parts) >= 1 {
|
|
||||||
pr.method = strings.TrimSpace(parts[0])
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
pr.path = strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
i := 1
|
|
||||||
for i < len(lines) {
|
|
||||||
line := strings.TrimRight(lines[i], "\r")
|
|
||||||
if line == "" {
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
|
||||||
k := strings.TrimSpace(kv[0])
|
|
||||||
v := strings.TrimSpace(kv[1])
|
|
||||||
pr.headers = append(pr.headers, header{k, v})
|
|
||||||
if strings.EqualFold(k, "host") {
|
|
||||||
pr.host = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
if i < len(lines) {
|
|
||||||
pr.body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
|
|
||||||
}
|
}
|
||||||
|
pr.body = r.Body
|
||||||
return pr
|
return pr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +58,31 @@ func formatAs(id, raw, scheme string) string {
|
|||||||
return toFFUF(pr)
|
return toFFUF(pr)
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return toMarkdown(pr)
|
return toMarkdown(pr)
|
||||||
|
case "har":
|
||||||
|
return toHAR(pr)
|
||||||
|
case "httpie":
|
||||||
|
return toHTTPie(pr)
|
||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toHTTPie(pr parsedRequest) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
method := strings.ToUpper(pr.method)
|
||||||
|
fmt.Fprintf(&sb, "http %s '%s'", method, pr.fullURL())
|
||||||
|
for _, h := range pr.headers {
|
||||||
|
if strings.EqualFold(h.key, "content-length") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, " \\\n '%s:%s'", h.key, h.value)
|
||||||
|
}
|
||||||
|
if pr.body != "" {
|
||||||
|
// Pass body via stdin hint
|
||||||
|
fmt.Fprintf(&sb, " \\\n <<< %q", pr.body)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
func toMarkdown(pr parsedRequest) string {
|
func toMarkdown(pr parsedRequest) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
|
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
|
||||||
@@ -200,3 +201,104 @@ func toFFUF(pr parsedRequest) string {
|
|||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toHAR(pr parsedRequest) string {
|
||||||
|
type harNameValue struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
type harPostData struct {
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
type harRequest struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTTPVersion string `json:"httpVersion"`
|
||||||
|
Headers []harNameValue `json:"headers"`
|
||||||
|
QueryString []harNameValue `json:"queryString"`
|
||||||
|
Cookies []harNameValue `json:"cookies"`
|
||||||
|
HeadersSize int `json:"headersSize"`
|
||||||
|
BodySize int `json:"bodySize"`
|
||||||
|
PostData *harPostData `json:"postData,omitempty"`
|
||||||
|
}
|
||||||
|
type harEntry struct {
|
||||||
|
StartedDateTime string `json:"startedDateTime"`
|
||||||
|
Time int `json:"time"`
|
||||||
|
Request harRequest `json:"request"`
|
||||||
|
Cache struct{} `json:"cache"`
|
||||||
|
Timings struct {
|
||||||
|
Send int `json:"send"`
|
||||||
|
Wait int `json:"wait"`
|
||||||
|
Receive int `json:"receive"`
|
||||||
|
} `json:"timings"`
|
||||||
|
}
|
||||||
|
type harLog struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Creator struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
} `json:"creator"`
|
||||||
|
Entries []harEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
type harRoot struct {
|
||||||
|
Log harLog `json:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make([]harNameValue, 0, len(pr.headers))
|
||||||
|
for _, h := range pr.headers {
|
||||||
|
headers = append(headers, harNameValue{h.key, h.value})
|
||||||
|
}
|
||||||
|
|
||||||
|
var qs []harNameValue
|
||||||
|
if idx := strings.Index(pr.path, "?"); idx != -1 {
|
||||||
|
vals, err := url.ParseQuery(pr.path[idx+1:])
|
||||||
|
if err == nil {
|
||||||
|
for k, vs := range vals {
|
||||||
|
for _, v := range vs {
|
||||||
|
qs = append(qs, harNameValue{k, v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if qs == nil {
|
||||||
|
qs = []harNameValue{}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := harRequest{
|
||||||
|
Method: pr.method,
|
||||||
|
URL: pr.fullURL(),
|
||||||
|
HTTPVersion: "HTTP/1.1",
|
||||||
|
Headers: headers,
|
||||||
|
QueryString: qs,
|
||||||
|
Cookies: []harNameValue{},
|
||||||
|
HeadersSize: -1,
|
||||||
|
BodySize: len(pr.body),
|
||||||
|
}
|
||||||
|
if pr.body != "" {
|
||||||
|
mimeType := "application/octet-stream"
|
||||||
|
for _, h := range pr.headers {
|
||||||
|
if strings.EqualFold(h.key, "content-type") {
|
||||||
|
mimeType = h.value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.PostData = &harPostData{MimeType: mimeType, Text: pr.body}
|
||||||
|
}
|
||||||
|
|
||||||
|
root := harRoot{Log: harLog{
|
||||||
|
Version: "1.2",
|
||||||
|
Entries: []harEntry{{
|
||||||
|
StartedDateTime: "1970-01-01T00:00:00.000Z",
|
||||||
|
Time: -1,
|
||||||
|
Request: req,
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
root.Log.Creator.Name = "spilltea"
|
||||||
|
|
||||||
|
b, err := json.MarshalIndent(root, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -45,6 +34,8 @@ var allFormats = []list.Item{
|
|||||||
formatItem{"go", "Go", "net/http package"},
|
formatItem{"go", "Go", "net/http package"},
|
||||||
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
|
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
|
||||||
formatItem{"markdown", "Markdown", "formatted for documentation"},
|
formatItem{"markdown", "Markdown", "formatted for documentation"},
|
||||||
|
formatItem{"har", "HAR", "HTTP Archive (JSON)"},
|
||||||
|
formatItem{"httpie", "HTTPie", "HTTPie command line client"},
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+160
-7
@@ -10,8 +10,159 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isWordChar reports whether c belongs to a "word" token (letter, digit, underscore).
|
||||||
|
func isWordChar(c byte) bool {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize splits s into runs of word characters and individual non-word bytes.
|
||||||
|
func tokenize(s string) []string {
|
||||||
|
var out []string
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if isWordChar(s[i]) {
|
||||||
|
j := i
|
||||||
|
for j < len(s) && isWordChar(s[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, s[i:j])
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
out = append(out, s[i:i+1])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordDiff computes a token-level diff between leftLine and rightLine and
|
||||||
|
// returns the two rendered strings with changed tokens highlighted.
|
||||||
|
func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) {
|
||||||
|
lToks := tokenize(leftLine)
|
||||||
|
rToks := tokenize(rightLine)
|
||||||
|
|
||||||
|
n, m := len(lToks), len(rToks)
|
||||||
|
dp := make([][]int, n+1)
|
||||||
|
for i := range dp {
|
||||||
|
dp[i] = make([]int, m+1)
|
||||||
|
}
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
for j := 1; j <= m; j++ {
|
||||||
|
if lToks[i-1] == rToks[j-1] {
|
||||||
|
dp[i][j] = dp[i-1][j-1] + 1
|
||||||
|
} else if dp[i-1][j] >= dp[i][j-1] {
|
||||||
|
dp[i][j] = dp[i-1][j]
|
||||||
|
} else {
|
||||||
|
dp[i][j] = dp[i][j-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type segment struct {
|
||||||
|
kind int // 0=same, 1=left-only, 2=right-only
|
||||||
|
tok string
|
||||||
|
}
|
||||||
|
segs := make([]segment, 0, n+m)
|
||||||
|
i, j := n, m
|
||||||
|
for i > 0 || j > 0 {
|
||||||
|
switch {
|
||||||
|
case i > 0 && j > 0 && lToks[i-1] == rToks[j-1]:
|
||||||
|
segs = append(segs, segment{0, lToks[i-1]})
|
||||||
|
i--
|
||||||
|
j--
|
||||||
|
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||||
|
segs = append(segs, segment{2, rToks[j-1]})
|
||||||
|
j--
|
||||||
|
default:
|
||||||
|
segs = append(segs, segment{1, lToks[i-1]})
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for lo, hi := 0, len(segs)-1; lo < hi; lo, hi = lo+1, hi-1 {
|
||||||
|
segs[lo], segs[hi] = segs[hi], segs[lo]
|
||||||
|
}
|
||||||
|
|
||||||
|
s := style.S
|
||||||
|
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true)
|
||||||
|
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true)
|
||||||
|
dim := lipgloss.NewStyle().Foreground(s.Subtle)
|
||||||
|
|
||||||
|
var lb, rb strings.Builder
|
||||||
|
for _, seg := range segs {
|
||||||
|
switch seg.kind {
|
||||||
|
case 0:
|
||||||
|
lb.WriteString(dim.Render(seg.tok))
|
||||||
|
rb.WriteString(dim.Render(seg.tok))
|
||||||
|
case 1:
|
||||||
|
lb.WriteString(boldErr.Render(seg.tok))
|
||||||
|
case 2:
|
||||||
|
rb.WriteString(boldOk.Render(seg.tok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lb.String(), rb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pairAndHighlight collapses adjacent removed/added blocks onto the same rows
|
||||||
|
// (eliminating the interleaved padding lines) and applies word-level diff
|
||||||
|
// highlighting to each paired line. Unpaired excess removals/additions keep
|
||||||
|
// their original single-sided padding row.
|
||||||
|
func pairAndHighlight(left, right []diffLine) ([]diffLine, []diffLine) {
|
||||||
|
newLeft := make([]diffLine, 0, len(left))
|
||||||
|
newRight := make([]diffLine, 0, len(right))
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(left) {
|
||||||
|
if left[i].kind != lineRemoved {
|
||||||
|
newLeft = append(newLeft, left[i])
|
||||||
|
newRight = append(newRight, right[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rStart := i
|
||||||
|
for i < len(left) && left[i].kind == lineRemoved {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
rEnd := i
|
||||||
|
|
||||||
|
aStart := i
|
||||||
|
for i < len(left) && left[i].kind == lineAdded {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
aEnd := i
|
||||||
|
|
||||||
|
nRemoved := rEnd - rStart
|
||||||
|
nAdded := aEnd - aStart
|
||||||
|
pairs := nRemoved
|
||||||
|
if nAdded < pairs {
|
||||||
|
pairs = nAdded
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := 0; k < pairs; k++ {
|
||||||
|
lLine := left[rStart+k]
|
||||||
|
rLine := right[aStart+k]
|
||||||
|
lLine.text, rLine.text = wordDiff(lLine.plainText, rLine.plainText)
|
||||||
|
newLeft = append(newLeft, lLine)
|
||||||
|
newRight = append(newRight, rLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := pairs; k < nRemoved; k++ {
|
||||||
|
newLeft = append(newLeft, left[rStart+k])
|
||||||
|
newRight = append(newRight, diffLine{kind: lineRemoved})
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := pairs; k < nAdded; k++ {
|
||||||
|
newLeft = append(newLeft, diffLine{kind: lineAdded})
|
||||||
|
newRight = append(newRight, right[aStart+k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newLeft, newRight
|
||||||
|
}
|
||||||
|
|
||||||
type slot struct {
|
type slot struct {
|
||||||
label string
|
label string
|
||||||
raw string
|
raw string
|
||||||
@@ -38,8 +189,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type diffLine struct {
|
type diffLine struct {
|
||||||
text string
|
text string // displayed text (highlighted, possibly word-diff decorated)
|
||||||
kind lineKind
|
plainText string // plain text for word-diff pairing (empty for padding lines)
|
||||||
|
kind lineKind
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -126,6 +278,7 @@ func (m *Model) computeDiff() {
|
|||||||
leftHL := hlLines(leftNorm)
|
leftHL := hlLines(leftNorm)
|
||||||
rightHL := hlLines(rightNorm)
|
rightHL := hlLines(rightNorm)
|
||||||
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
||||||
|
m.leftLines, m.rightLines = pairAndHighlight(m.leftLines, m.rightLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normRaw(s string) string {
|
func normRaw(s string) string {
|
||||||
@@ -149,7 +302,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.leftViewport.Width(), m.leftViewport.Height(),
|
m.leftViewport.Width(), m.leftViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(" <(^_^)>\nsend two entries here to compare"),
|
s.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")),
|
||||||
)
|
)
|
||||||
m.leftViewport.SetContent(placeholder)
|
m.leftViewport.SetContent(placeholder)
|
||||||
m.rightViewport.SetContent("")
|
m.rightViewport.SetContent("")
|
||||||
@@ -161,7 +314,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.rightViewport.Width(), m.rightViewport.Height(),
|
m.rightViewport.Width(), m.rightViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(" (・3・)\nwaiting for second entry…"),
|
s.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")),
|
||||||
)
|
)
|
||||||
m.rightViewport.SetContent(placeholder)
|
m.rightViewport.SetContent(placeholder)
|
||||||
return
|
return
|
||||||
@@ -227,10 +380,10 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
|
|||||||
j--
|
j--
|
||||||
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||||
left = append(left, diffLine{kind: lineAdded})
|
left = append(left, diffLine{kind: lineAdded})
|
||||||
right = append(right, diffLine{text: hlB(j - 1), kind: lineAdded})
|
right = append(right, diffLine{text: hlB(j - 1), plainText: b[j-1], kind: lineAdded})
|
||||||
j--
|
j--
|
||||||
default:
|
default:
|
||||||
left = append(left, diffLine{text: hlA(i - 1), kind: lineRemoved})
|
left = append(left, diffLine{text: hlA(i - 1), plainText: a[i-1], kind: lineRemoved})
|
||||||
right = append(right, diffLine{kind: lineRemoved})
|
right = append(right, diffLine{kind: lineRemoved})
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
@@ -252,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
|
||||||
@@ -51,8 +58,8 @@ func (m *Model) renderPanels(panelH int) string {
|
|||||||
rightBorder = s.PanelFocused
|
rightBorder = s.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
|
left := style.RenderWithTitle(leftBorder, leftTitle, style.ViewportView(&m.leftViewport), leftW, panelH)
|
||||||
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
|
right := style.RenderWithTitle(rightBorder, rightTitle, style.ViewportView(&m.rightViewport), rightW, panelH)
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -15,18 +15,24 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
database *db.DB
|
database *db.DB
|
||||||
findings []db.Finding
|
findings []db.Finding
|
||||||
cursor int
|
cursor int
|
||||||
|
hasUnread bool
|
||||||
|
knownCount int
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
bodyViewport viewport.Model
|
bodyViewport viewport.Model
|
||||||
pager paginator.Model
|
pager paginator.Model
|
||||||
help help.Model
|
help help.Model
|
||||||
|
|
||||||
|
renderer *glamour.TermRenderer
|
||||||
|
rendererWidth int
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
@@ -42,6 +48,17 @@ func New() Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) HasUnread() bool { return m.hasUnread }
|
||||||
|
func (m *Model) ClearUnread() { m.hasUnread = false; m.knownCount = len(m.findings) }
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -76,6 +93,11 @@ func (m *Model) recalcSizes() {
|
|||||||
m.bodyViewport.SetWidth(inner)
|
m.bodyViewport.SetWidth(inner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
m.bodyViewport.SetHeight(bodyVH)
|
||||||
|
|
||||||
|
if m.rendererWidth != inner {
|
||||||
|
m.renderer = nil
|
||||||
|
m.rendererWidth = 0
|
||||||
|
}
|
||||||
|
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
@@ -104,19 +126,29 @@ 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
|
||||||
}
|
}
|
||||||
f := m.findings[m.cursor]
|
f := m.findings[m.cursor]
|
||||||
rendered := renderMarkdown(f.Description, m.bodyViewport.Width())
|
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
|
||||||
m.bodyViewport.SetContent(rendered)
|
m.bodyViewport.SetContent(rendered)
|
||||||
m.bodyViewport.GotoTop()
|
if reset {
|
||||||
|
m.bodyViewport.GotoTop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMarkdown(src string, width int) string {
|
func (m *Model) renderMarkdownCached(src string, width int) string {
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description")
|
return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
|
||||||
}
|
}
|
||||||
tmpl, err := template.New("").Parse(src)
|
tmpl, err := template.New("").Parse(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,14 +161,21 @@ func renderMarkdown(src string, width int) string {
|
|||||||
if width < 10 {
|
if width < 10 {
|
||||||
width = 80
|
width = 80
|
||||||
}
|
}
|
||||||
r, err := glamour.NewTermRenderer(
|
// Rebuild renderer if width changed or not yet built.
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
if m.renderer == nil || m.rendererWidth != width {
|
||||||
glamour.WithWordWrap(width),
|
r, err := glamour.NewTermRenderer(
|
||||||
)
|
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
||||||
if err != nil {
|
glamour.WithWordWrap(width),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
m.renderer = r
|
||||||
|
m.rendererWidth = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.renderer == nil {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
out, err := r.Render(buf.String())
|
out, err := m.renderer.Render(buf.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
@@ -148,12 +187,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,7 +16,14 @@ 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 len(m.findings) > m.knownCount {
|
||||||
|
m.hasUnread = true
|
||||||
|
}
|
||||||
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 +34,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 +81,33 @@ 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 {
|
case key.Matches(msg, g.GotoTop):
|
||||||
step = 1
|
m.cursor = 0
|
||||||
}
|
m.pager.Page = 0
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.GotoBottom):
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.findings))
|
||||||
|
m.pager.Page = util.CursorGotoBottom(m.pager.TotalPages)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, false)
|
||||||
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, true)
|
||||||
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
case key.Matches(msg, g.Help):
|
case key.Matches(msg, g.Help):
|
||||||
m.help.ShowAll = !m.help.ShowAll
|
m.help.ShowAll = !m.help.ShowAll
|
||||||
m.recalcSizes()
|
m.recalcSizes()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (m *Model) renderBodyPanel(h int) string {
|
|||||||
if len(m.findings) > 0 {
|
if len(m.findings) > 0 {
|
||||||
title = m.findings[m.cursor].Title
|
title = m.findings[m.cursor].Title
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
@@ -54,17 +54,11 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"),
|
s.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.findings))
|
start, end := util.PageBounds(m.pager, len(m.findings))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, f := range m.findings[start:end] {
|
for i, f := range m.findings[start:end] {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type panel int
|
type panel int
|
||||||
@@ -59,10 +60,22 @@ 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) CurrentScheme() string { return "https" }
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
return m.focusedPanel == panelResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) CurrentScheme() string {
|
||||||
|
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
return util.InferScheme(m.entries[m.cursor].Host)
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshCmd returns the appropriate load command given the current search state.
|
// RefreshCmd returns the appropriate load command given the current search state.
|
||||||
// The app model should call this instead of LoadEntriesCmd directly so that
|
// The app model should call this instead of LoadEntriesCmd directly so that
|
||||||
@@ -153,7 +166,7 @@ func (m historyKeyMap) FullHelp() [][]key.Binding {
|
|||||||
h := keys.Keys.History
|
h := keys.Keys.History
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs}
|
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs}
|
||||||
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
|
all := []key.Binding{h.Flag, h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
|
||||||
all = append(all, pageGlobals...)
|
all = append(all, pageGlobals...)
|
||||||
all = append(all, g.CommonBindings()...)
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -230,6 +231,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, h.Flag):
|
||||||
|
if len(m.entries) > 0 && m.database != nil {
|
||||||
|
m.database.ToggleFlag(m.entries[m.cursor].ID)
|
||||||
|
return m, m.RefreshCmd()
|
||||||
|
}
|
||||||
|
|
||||||
case key.Matches(msg, h.DeleteEntry):
|
case key.Matches(msg, h.DeleteEntry):
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
id := m.entries[m.cursor].ID
|
id := m.entries[m.cursor].ID
|
||||||
@@ -252,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)
|
||||||
@@ -271,6 +270,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, g.Right):
|
case key.Matches(msg, g.Right):
|
||||||
m.bodyViewport.ScrollRight(6)
|
m.bodyViewport.ScrollRight(6)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.GotoTop):
|
||||||
|
m.cursor = 0
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.GotoBottom):
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.entries))
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Help):
|
case key.Matches(msg, keys.Keys.Global.Help):
|
||||||
m.help.ShowAll = !m.help.ShowAll
|
m.help.ShowAll = !m.help.ShowAll
|
||||||
m.recalcSizes()
|
m.recalcSizes()
|
||||||
@@ -307,7 +335,7 @@ func (m *Model) refreshBody() {
|
|||||||
}
|
}
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
||||||
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (˘・_・˘)\nno response stored")))
|
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored"))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
|
|||||||
+23
-11
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -45,7 +46,7 @@ func (m *Model) renderBodyPanel(h int) string {
|
|||||||
if m.focusedPanel == panelResponse {
|
if m.focusedPanel == panelResponse {
|
||||||
title = icons.I.Response + "Response"
|
title = icons.I.Response + "Response"
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
@@ -84,9 +85,9 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if len(m.entries) == 0 {
|
if len(m.entries) == 0 {
|
||||||
msg := " (⌐■_■)\nno history yet"
|
msg := util.CenterLines("(⌐■_■)", "no history yet")
|
||||||
if m.searchKind != searchKindOff {
|
if m.searchKind != searchKindOff {
|
||||||
msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results"
|
msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
@@ -95,13 +96,7 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
@@ -112,7 +107,7 @@ func (m *Model) renderList() string {
|
|||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
|
|
||||||
statusStr := fmt.Sprintf("%3d", e.StatusCode)
|
statusStr := fmt.Sprintf("%3d", e.StatusCode)
|
||||||
const fixedW = 2 + 7 + 1 + 3 + 1 + 10 + 1
|
const fixedW = 2 + 2 + 7 + 1 + 3 + 1 + 10 + 1
|
||||||
hostPathW := w - fixedW
|
hostPathW := w - fixedW
|
||||||
if hostPathW < 0 {
|
if hostPathW < 0 {
|
||||||
hostPathW = 0
|
hostPathW = 0
|
||||||
@@ -120,12 +115,21 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
ts := e.Timestamp.Format("15:04:05")
|
ts := e.Timestamp.Format("15:04:05")
|
||||||
statusSt := style.StatusStyle(e.StatusCode, 3)
|
statusSt := style.StatusStyle(e.StatusCode, 3)
|
||||||
|
flagSt := lipgloss.NewStyle().Foreground(s.Primary)
|
||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
if icons.I.Flag == "" {
|
||||||
|
flagStr = "★ "
|
||||||
|
}
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
||||||
|
bg.Foreground(s.Primary).Width(2).Render(flagStr),
|
||||||
s.Method(e.Method).Background(selBg).Render(e.Method),
|
s.Method(e.Method).Background(selBg).Render(e.Method),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
statusSt.Background(selBg).Render(statusStr),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
@@ -135,8 +139,16 @@ func (m *Model) renderList() string {
|
|||||||
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
if icons.I.Flag == "" {
|
||||||
|
flagStr = "★ "
|
||||||
|
}
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
|
flagSt.Width(2).Render(flagStr),
|
||||||
s.Method(e.Method).Render(e.Method),
|
s.Method(e.Method).Render(e.Method),
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ type Project struct {
|
|||||||
ModTime time.Time
|
ModTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectSelectedMsg is emitted when the user picks a project from the home screen.
|
||||||
|
type ProjectSelectedMsg struct {
|
||||||
|
Project *Project
|
||||||
|
}
|
||||||
|
|
||||||
type inputMode int
|
type inputMode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -161,16 +166,11 @@ type Model struct {
|
|||||||
list list.Model
|
list list.Model
|
||||||
projectDir string
|
projectDir string
|
||||||
nameInput textinput.Model
|
nameInput textinput.Model
|
||||||
selected *Project
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
teapotFrame int
|
teapotFrame int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected returns the project chosen by the user, or nil if the program was
|
|
||||||
// quit without making a selection.
|
|
||||||
func (m Model) Selected() *Project { return m.selected }
|
|
||||||
|
|
||||||
func New(projectDir string) Model {
|
func New(projectDir string) Model {
|
||||||
projects := loadProjects(projectDir)
|
projects := loadProjects(projectDir)
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ func (m Model) renderHelpLine() string {
|
|||||||
}
|
}
|
||||||
parts = append(parts, binding(k.Open))
|
parts = append(parts, binding(k.Open))
|
||||||
parts = append(parts, binding(k.Delete))
|
parts = append(parts, binding(k.Delete))
|
||||||
parts = append(parts, item("q", "quit"))
|
parts = append(parts, item(keys.Keys.Global.Quit.Help().Key, "quit"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(parts, sep)
|
return strings.Join(parts, sep)
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
|
p := &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||||
default:
|
default:
|
||||||
m.selected = &Project{Name: item.name, Path: item.path}
|
p := &Project{Name: item.name, Path: item.path}
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +117,8 @@ func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")}
|
p := &Project{Name: name, Path: filepath.Join(dir, "data.db")}
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||||
default:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.nameInput, cmd = m.nameInput.Update(msg)
|
m.nameInput, cmd = m.nameInput.Update(msg)
|
||||||
@@ -138,14 +138,14 @@ func sanitizeName(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsValidProjectName(s string) bool {
|
func IsValidProjectName(s string) bool {
|
||||||
if s == "tmp" {
|
if s == "tmp" || s == "temp" || s == "temporary" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s != "" && s == sanitizeName(s)
|
return s != "" && s == sanitizeName(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenProject(projectDir, name string) (*Project, error) {
|
func OpenProject(projectDir, name string) (*Project, error) {
|
||||||
if name == "tmp" {
|
if name == "tmp" || name == "temp" || name == "temporary" {
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,112 +4,37 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func formatRawRequest(req *intercept.PendingRequest) string {
|
|
||||||
r := req.Flow.Request
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
|
||||||
|
|
||||||
keys := make([]string, 0, len(r.Header))
|
|
||||||
for k := range r.Header {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
|
||||||
if len(r.Body) > 0 {
|
|
||||||
sb.Write(r.Body)
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatRawResponse(resp *intercept.PendingResponse) string {
|
|
||||||
r := resp.Flow.Response
|
|
||||||
if r == nil {
|
|
||||||
return "(no response)"
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
proto := resp.Flow.Request.Proto
|
|
||||||
if proto == "" {
|
|
||||||
proto = "HTTP/1.1"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
|
|
||||||
|
|
||||||
keys := make([]string, 0, len(r.Header))
|
|
||||||
for k := range r.Header {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
|
||||||
if len(r.Body) > 0 {
|
|
||||||
sb.Write(r.Body)
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRawRequest(content string, req *intercept.PendingRequest) {
|
func parseRawRequest(content string, req *intercept.PendingRequest) {
|
||||||
|
parsed := util.ParseRawRequest(content)
|
||||||
r := req.Flow.Request
|
r := req.Flow.Request
|
||||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
if parsed.Method != "" {
|
||||||
if len(lines) == 0 {
|
r.Method = parsed.Method
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if parsed.Path != "" {
|
||||||
parts := strings.SplitN(lines[0], " ", 3)
|
if u, err := url.ParseRequestURI(parsed.Path); err == nil {
|
||||||
if len(parts) >= 1 {
|
|
||||||
r.Method = strings.TrimSpace(parts[0])
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
if u, err := url.ParseRequestURI(strings.TrimSpace(parts[1])); err == nil {
|
|
||||||
r.URL.Path = u.Path
|
r.URL.Path = u.Path
|
||||||
r.URL.RawQuery = u.RawQuery
|
r.URL.RawQuery = u.RawQuery
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(parts) >= 3 {
|
if parsed.Proto != "" {
|
||||||
r.Proto = strings.TrimSpace(parts[2])
|
r.Proto = parsed.Proto
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Header = make(http.Header)
|
r.Header = make(http.Header)
|
||||||
i := 1
|
for _, h := range parsed.Headers {
|
||||||
for i < len(lines) {
|
r.Header.Set(h.Key, h.Value)
|
||||||
line := strings.TrimRight(lines[i], "\r")
|
|
||||||
if line == "" {
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
|
||||||
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
|
if parsed.Body != "" {
|
||||||
if i < len(lines) {
|
r.Body = []byte(parsed.Body)
|
||||||
body := strings.Join(lines[i:], "\n")
|
} else {
|
||||||
body = strings.TrimRight(body, "\n")
|
r.Body = nil
|
||||||
if body != "" {
|
|
||||||
r.Body = []byte(body)
|
|
||||||
} else {
|
|
||||||
r.Body = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +268,7 @@ func (m *Model) loadIntoTextarea() {
|
|||||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||||
m.textarea.SetValue(edited)
|
m.textarea.SetValue(edited)
|
||||||
} else {
|
} else {
|
||||||
m.textarea.SetValue(formatRawResponse(resp))
|
m.textarea.SetValue(intercept.FormatRawResponse(resp.Flow))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
@@ -353,7 +278,7 @@ func (m *Model) loadIntoTextarea() {
|
|||||||
if edited, ok := m.pendingEdits[req]; ok {
|
if edited, ok := m.pendingEdits[req]; ok {
|
||||||
m.textarea.SetValue(edited)
|
m.textarea.SetValue(edited)
|
||||||
} else {
|
} else {
|
||||||
m.textarea.SetValue(formatRawRequest(req))
|
m.textarea.SetValue(intercept.FormatRawRequest(req.Flow))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,7 +295,7 @@ func (m *Model) refreshBody() {
|
|||||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||||
raw = edited
|
raw = edited
|
||||||
} else {
|
} else {
|
||||||
raw = formatRawResponse(resp)
|
raw = intercept.FormatRawResponse(resp.Flow)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
@@ -381,7 +306,7 @@ func (m *Model) refreshBody() {
|
|||||||
if edited, ok := m.pendingEdits[req]; ok {
|
if edited, ok := m.pendingEdits[req]; ok {
|
||||||
raw = edited
|
raw = edited
|
||||||
} else {
|
} else {
|
||||||
raw = formatRawRequest(req)
|
raw = intercept.FormatRawRequest(req.Flow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Model struct {
|
|||||||
|
|
||||||
editing bool
|
editing bool
|
||||||
interceptEnabled bool
|
interceptEnabled bool
|
||||||
|
hasUnread bool
|
||||||
pendingEdits map[*intercept.PendingRequest]string
|
pendingEdits map[*intercept.PendingRequest]string
|
||||||
pendingResponseEdits map[*intercept.PendingResponse]string
|
pendingResponseEdits map[*intercept.PendingResponse]string
|
||||||
|
|
||||||
@@ -76,8 +77,15 @@ func New(broker *intercept.Broker) Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) HasUnread() bool { return m.hasUnread }
|
||||||
|
func (m *Model) ClearUnread() { m.hasUnread = false }
|
||||||
|
|
||||||
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"
|
||||||
@@ -98,7 +106,7 @@ func (m Model) CurrentRaw() string {
|
|||||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||||
return edited
|
return edited
|
||||||
}
|
}
|
||||||
return formatRawResponse(resp)
|
return intercept.FormatRawResponse(resp.Flow)
|
||||||
}
|
}
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -107,7 +115,7 @@ func (m Model) CurrentRaw() string {
|
|||||||
if edited, ok := m.pendingEdits[req]; ok {
|
if edited, ok := m.pendingEdits[req]; ok {
|
||||||
return edited
|
return edited
|
||||||
}
|
}
|
||||||
return formatRawRequest(req)
|
return intercept.FormatRawRequest(req.Flow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) SetSize(w, h int) {
|
func (m *Model) SetSize(w, h int) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
wasEmpty := len(m.queue) == 0
|
wasEmpty := len(m.queue) == 0
|
||||||
m.queue = append(m.queue, msg.Req)
|
m.queue = append(m.queue, msg.Req)
|
||||||
|
m.hasUnread = true
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
@@ -52,24 +53,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 +111,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)
|
||||||
@@ -146,9 +122,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
case key.Matches(msg, keys.Keys.Global.Right):
|
case key.Matches(msg, keys.Keys.Global.Right):
|
||||||
m.bodyViewport.ScrollRight(6)
|
m.bodyViewport.ScrollRight(6)
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Quit):
|
|
||||||
return m, tea.Quit
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
|
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
|
||||||
if onResponses {
|
if onResponses {
|
||||||
if len(m.responseQueue) > 0 {
|
if len(m.responseQueue) > 0 {
|
||||||
@@ -237,10 +210,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Intercept.EditExternal):
|
case key.Matches(msg, keys.Keys.Intercept.EditExternal):
|
||||||
if !onResponses && len(m.queue) > 0 {
|
if !onResponses && len(m.queue) > 0 {
|
||||||
return m, util.OpenExternalEditor(formatRawRequest(m.queue[m.cursor]))
|
return m, util.OpenExternalEditor(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
|
||||||
}
|
}
|
||||||
if onResponses && len(m.responseQueue) > 0 {
|
if onResponses && len(m.responseQueue) > 0 {
|
||||||
return m, util.OpenExternalEditor(formatRawResponse(m.responseQueue[m.responseCursor]))
|
return m, util.OpenExternalEditor(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.SendToReplay):
|
case key.Matches(msg, keys.Keys.Global.SendToReplay):
|
||||||
@@ -268,6 +241,46 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
|
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoTop):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = 0
|
||||||
|
} else {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = util.CursorGotoBottom(len(m.responseQueue))
|
||||||
|
} else {
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.queue))
|
||||||
|
}
|
||||||
|
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.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, tea.Batch(*cmds...)
|
return m, tea.Batch(*cmds...)
|
||||||
@@ -287,12 +300,12 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
|
|||||||
if onResponses {
|
if onResponses {
|
||||||
if len(m.responseQueue) > 0 {
|
if len(m.responseQueue) > 0 {
|
||||||
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
|
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
|
||||||
m.textarea.SetValue(formatRawResponse(m.responseQueue[m.responseCursor]))
|
m.textarea.SetValue(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(m.queue) > 0 {
|
if len(m.queue) > 0 {
|
||||||
delete(m.pendingEdits, m.queue[m.cursor])
|
delete(m.pendingEdits, m.queue[m.cursor])
|
||||||
m.textarea.SetValue(formatRawRequest(m.queue[m.cursor]))
|
m.textarea.SetValue(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -86,7 +87,7 @@ func (m *Model) renderBodyPanel(h int) string {
|
|||||||
if m.editing {
|
if m.editing {
|
||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
} else {
|
} else {
|
||||||
body = m.bodyViewport.View()
|
body = style.ViewportView(&m.bodyViewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
border := s.Panel
|
border := s.Panel
|
||||||
@@ -104,17 +105,11 @@ func (m *Model) renderStatusBar() string {
|
|||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (。◕‿‿◕。)\nwaiting for a request"))
|
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(。◕‿‿◕。)", "waiting for a request")))
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
s := style.S
|
||||||
start, end := m.pager.GetSliceBounds(len(m.queue))
|
start, end := util.PageBounds(m.pager, len(m.queue))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, req := range m.queue[start:end] {
|
for i, req := range m.queue[start:end] {
|
||||||
@@ -160,17 +155,11 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
func (m *Model) renderResponseList() string {
|
func (m *Model) renderResponseList() string {
|
||||||
if len(m.responseQueue) == 0 {
|
if len(m.responseQueue) == 0 {
|
||||||
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (҂◡_◡)\nno response yet"))
|
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(҂◡_◡)", "no response yet")))
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
s := style.S
|
||||||
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
|
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, resp := range m.responseQueue[start:end] {
|
for i, resp := range m.responseQueue[start:end] {
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func (m *Model) recalcSizes() {
|
|||||||
m.syncDetailViewport()
|
m.syncDetailViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh reloads the plugin list from the manager.
|
|
||||||
func (m *Model) Refresh() {
|
func (m *Model) Refresh() {
|
||||||
if m.manager == nil {
|
if m.manager == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,23 +4,10 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PluginsChangedMsg is sent when the plugin list should be refreshed.
|
|
||||||
type PluginsChangedMsg struct{}
|
|
||||||
|
|
||||||
// RefreshCmd returns a command that triggers a list refresh.
|
|
||||||
func RefreshCmd() tea.Cmd {
|
|
||||||
return func() tea.Msg { return PluginsChangedMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
|
||||||
case PluginsChangedMsg:
|
|
||||||
m.Refresh()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route non-key messages to textarea when editing so internal
|
// Route non-key messages to textarea when editing so internal
|
||||||
// textarea messages (e.g. clipboard paste) are handled correctly.
|
// textarea messages (e.g. clipboard paste) are handled correctly.
|
||||||
if m.editing {
|
if m.editing {
|
||||||
@@ -34,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:
|
||||||
@@ -77,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
if info, ok := m.selected(); ok && m.manager != nil {
|
||||||
val := m.textarea.Value()
|
val := m.textarea.Value()
|
||||||
m.manager.SaveConfig(info.Name, val)
|
m.manager.SaveConfig(info.ID, val)
|
||||||
// Update cached info.
|
// Update cached info.
|
||||||
m.filtered[m.cursor].ConfigText = val
|
m.filtered[m.cursor].ConfigText = val
|
||||||
for i := range m.items {
|
for i := range m.items {
|
||||||
if m.items[i].Name == info.Name {
|
if m.items[i].ID == info.ID {
|
||||||
m.items[i].ConfigText = val
|
m.items[i].ConfigText = val
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -125,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, pk.Toggle):
|
case key.Matches(msg, pk.Toggle):
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
if info, ok := m.selected(); ok && m.manager != nil {
|
||||||
m.manager.TogglePlugin(info.Name)
|
m.manager.TogglePlugin(info.ID)
|
||||||
m.filtered[m.cursor].Enabled = !info.Enabled
|
m.filtered[m.cursor].Enabled = !info.Enabled
|
||||||
for i := range m.items {
|
for i := range m.items {
|
||||||
if m.items[i].Name == info.Name {
|
if m.items[i].ID == info.ID {
|
||||||
m.items[i].Enabled = !info.Enabled
|
m.items[i].Enabled = !info.Enabled
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -142,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
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 || m.manager == nil {
|
if m.width == 0 || m.manager == nil {
|
||||||
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (._.)~*.'\n no plugins loaded")))
|
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
|
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
|
||||||
@@ -72,7 +73,7 @@ func (m *Model) renderDetailPanel(h int) string {
|
|||||||
s.Faint.Render(filepath.Base(info.FilePath)),
|
s.Faint.Render(filepath.Base(info.FilePath)),
|
||||||
)
|
)
|
||||||
|
|
||||||
parts := []string{header, m.detailViewport.View()}
|
parts := []string{header, style.ViewportView(&m.detailViewport)}
|
||||||
|
|
||||||
if m.hasConfig() {
|
if m.hasConfig() {
|
||||||
var configLabel string
|
var configLabel string
|
||||||
@@ -131,9 +132,9 @@ func (m *Model) renderStatusBar() string {
|
|||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
s := style.S
|
s := style.S
|
||||||
if len(m.filtered) == 0 {
|
if len(m.filtered) == 0 {
|
||||||
msg := " (ง •̀_•́)ง\nno plugins"
|
msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
||||||
if m.filter != "" {
|
if m.filter != "" {
|
||||||
msg = " = _ =\nno results"
|
msg = util.CenterLines("= _ =", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
@@ -142,13 +143,7 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.filtered))
|
start, end := util.PageBounds(m.pager, len(m.filtered))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, p := range m.filtered[start:end] {
|
for i, p := range m.filtered[start:end] {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+166
-97
@@ -2,21 +2,27 @@ package replay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"compress/zlib"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
|
"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/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 {
|
||||||
@@ -92,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)
|
||||||
@@ -125,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):
|
||||||
@@ -167,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)
|
||||||
@@ -213,6 +233,45 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoTop):
|
||||||
|
m.cursor = 0
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.entries))
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
|
||||||
|
m.refreshListViewport()
|
||||||
|
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()
|
||||||
@@ -240,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 {
|
||||||
@@ -265,69 +347,46 @@ func (m *Model) refreshBody() {
|
|||||||
m.requestViewport.SetXOffset(0)
|
m.requestViewport.SetXOffset(0)
|
||||||
|
|
||||||
if e.Sending {
|
if e.Sending {
|
||||||
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (ノ◕ヮ◕)ノ*:・゚\n sending...")))
|
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
|
||||||
} else if e.ResponseRaw != "" {
|
} else if e.ResponseRaw != "" {
|
||||||
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
||||||
} else {
|
} else {
|
||||||
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" ( •_•)>⌐■\npress send to fire")))
|
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
|
||||||
}
|
}
|
||||||
m.responseViewport.SetYOffset(0)
|
m.responseViewport.SetYOffset(0)
|
||||||
m.responseViewport.SetXOffset(0)
|
m.responseViewport.SetXOffset(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
||||||
lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n")
|
parsed := util.ParseRawRequest(entry.RequestRaw)
|
||||||
if len(lines) == 0 {
|
if parsed.Method == "" {
|
||||||
return "", 0, fmt.Errorf("empty request")
|
return "", 0, fmt.Errorf("empty request")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(lines[0], " ", 3)
|
host := parsed.Host
|
||||||
if len(parts) < 2 {
|
if host == "" {
|
||||||
return "", 0, fmt.Errorf("invalid request line")
|
host = entry.Host
|
||||||
}
|
}
|
||||||
method := strings.TrimSpace(parts[0])
|
|
||||||
path := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
headers := make(http.Header)
|
headers := make(http.Header)
|
||||||
host := entry.Host
|
for _, h := range parsed.Headers {
|
||||||
i := 1
|
if strings.EqualFold(h.Key, "host") {
|
||||||
for i < len(lines) {
|
continue
|
||||||
line := strings.TrimRight(lines[i], "\r")
|
|
||||||
if line == "" {
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
headers.Add(h.Key, h.Value)
|
||||||
k := strings.TrimSpace(kv[0])
|
|
||||||
v := strings.TrimSpace(kv[1])
|
|
||||||
if strings.ToLower(k) == "host" {
|
|
||||||
host = v
|
|
||||||
} else {
|
|
||||||
headers.Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyBytes []byte
|
|
||||||
if i < len(lines) {
|
|
||||||
b := strings.Join(lines[i:], "\n")
|
|
||||||
b = strings.TrimRight(b, "\n")
|
|
||||||
bodyBytes = []byte(b)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scheme := entry.Scheme
|
scheme := entry.Scheme
|
||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
urlStr := scheme + "://" + host + path
|
urlStr := scheme + "://" + host + parsed.Path
|
||||||
|
|
||||||
var bodyReader io.Reader
|
var bodyReader io.Reader
|
||||||
if len(bodyBytes) > 0 {
|
if parsed.Body != "" {
|
||||||
bodyReader = bytes.NewReader(bodyBytes)
|
bodyReader = strings.NewReader(parsed.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(method, urlStr, bodyReader)
|
req, err := http.NewRequest(parsed.Method, urlStr, bodyReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
@@ -349,19 +408,21 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||||
|
|
||||||
|
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))
|
||||||
sortedKeys := make([]string, 0, len(resp.Header))
|
for _, line := range util.SortedHeaderLines(resp.Header) {
|
||||||
for k := range resp.Header {
|
sb.WriteString(line)
|
||||||
sortedKeys = append(sortedKeys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(sortedKeys)
|
|
||||||
for _, k := range sortedKeys {
|
|
||||||
for _, v := range resp.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.Write(respBody)
|
sb.Write(respBody)
|
||||||
@@ -369,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 {
|
||||||
@@ -390,7 +480,11 @@ func entryToDB(e Entry) db.ReplayEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func entryFromMsg(msg SendToReplayMsg) Entry {
|
func entryFromMsg(msg SendToReplayMsg) Entry {
|
||||||
method, host, path := parseFirstLine(msg.RequestRaw, msg.Host)
|
parsed := util.ParseRawRequest(msg.RequestRaw)
|
||||||
|
host := parsed.Host
|
||||||
|
if host == "" {
|
||||||
|
host = msg.Host
|
||||||
|
}
|
||||||
scheme := msg.Scheme
|
scheme := msg.Scheme
|
||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
scheme = util.InferScheme(host)
|
scheme = util.InferScheme(host)
|
||||||
@@ -398,34 +492,9 @@ func entryFromMsg(msg SendToReplayMsg) Entry {
|
|||||||
return Entry{
|
return Entry{
|
||||||
Scheme: scheme,
|
Scheme: scheme,
|
||||||
Host: host,
|
Host: host,
|
||||||
Path: path,
|
Path: parsed.Path,
|
||||||
Method: method,
|
Method: parsed.Method,
|
||||||
OriginalRaw: msg.RequestRaw,
|
OriginalRaw: msg.RequestRaw,
|
||||||
RequestRaw: msg.RequestRaw,
|
RequestRaw: msg.RequestRaw,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseFirstLine(raw, fallbackHost string) (method, host, path string) {
|
|
||||||
host = fallbackHost
|
|
||||||
path = "/"
|
|
||||||
lines := strings.SplitN(raw, "\n", 2)
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parts := strings.Fields(lines[0])
|
|
||||||
if len(parts) >= 1 {
|
|
||||||
method = parts[0]
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
path = parts[1]
|
|
||||||
}
|
|
||||||
if len(lines) > 1 {
|
|
||||||
for _, line := range strings.Split(lines[1], "\n") {
|
|
||||||
if strings.HasPrefix(strings.ToLower(line), "host:") {
|
|
||||||
host = strings.TrimSpace(line[5:])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-13
@@ -8,6 +8,7 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -33,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 {
|
||||||
@@ -56,14 +57,21 @@ func (m *Model) renderRequestPanel(w, h int) string {
|
|||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
border = s.PanelFocused
|
border = s.PanelFocused
|
||||||
} else {
|
} else {
|
||||||
body = m.requestViewport.View()
|
body = style.ViewportView(&m.requestViewport)
|
||||||
|
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", style.ViewportView(&m.responseViewport), w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
@@ -75,18 +83,12 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
style.S.Faint.Render(" (╥﹏╥)\nsend a request from History or Intercept"),
|
style.S.Faint.Render(util.CenterLines("(╥﹏╥)", "send a request from History or Intercept")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
s := style.S
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+10
-2
@@ -1,10 +1,13 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EditorFinishedMsg struct {
|
type EditorFinishedMsg struct {
|
||||||
@@ -13,7 +16,10 @@ type EditorFinishedMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func OpenExternalEditor(content string) tea.Cmd {
|
func OpenExternalEditor(content string) tea.Cmd {
|
||||||
editor := os.Getenv("EDITOR")
|
editor := config.Global.App.ExternalEditor
|
||||||
|
if editor == "" {
|
||||||
|
editor = os.Getenv("EDITOR")
|
||||||
|
}
|
||||||
if editor == "" {
|
if editor == "" {
|
||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
@@ -22,7 +28,9 @@ func OpenExternalEditor(content string) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tmpPath := f.Name()
|
tmpPath := f.Name()
|
||||||
_, _ = f.WriteString(content)
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
log.Printf("editor: writing temp file: %v", err)
|
||||||
|
}
|
||||||
f.Close()
|
f.Close()
|
||||||
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
|
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
|
||||||
defer os.Remove(tmpPath)
|
defer os.Remove(tmpPath)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RawRequest holds a parsed raw HTTP request string.
|
||||||
|
type RawRequest struct {
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Proto string
|
||||||
|
Host string
|
||||||
|
Headers []RawHeader
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawHeader is a single header key/value pair preserving insertion order.
|
||||||
|
type RawHeader struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRawRequest parses a raw HTTP request string (as produced by
|
||||||
|
// FormatRawRequest). The Host header, if present, is extracted into Host
|
||||||
|
// but also kept in Headers.
|
||||||
|
func ParseRawRequest(raw string) RawRequest {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
||||||
|
var r RawRequest
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(lines[0], " ", 3)
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
r.Method = strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
r.Path = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
r.Proto = strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 1
|
||||||
|
for i < len(lines) {
|
||||||
|
line := strings.TrimRight(lines[i], "\r")
|
||||||
|
if line == "" {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
||||||
|
k := strings.TrimSpace(kv[0])
|
||||||
|
v := strings.TrimSpace(kv[1])
|
||||||
|
r.Headers = append(r.Headers, RawHeader{k, v})
|
||||||
|
if strings.EqualFold(k, "host") {
|
||||||
|
r.Host = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < len(lines) {
|
||||||
|
r.Body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedHeaderLines returns header lines sorted by key name, formatted as
|
||||||
|
// "Key: Value\n" strings. Useful for deterministic serialisation.
|
||||||
|
func SortedHeaderLines(h http.Header) []string {
|
||||||
|
keys := make([]string, 0, len(h))
|
||||||
|
for k := range h {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var out []string
|
||||||
|
for _, k := range keys {
|
||||||
|
for _, v := range h[k] {
|
||||||
|
out = append(out, fmt.Sprintf("%s: %s\n", k, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+33
-1
@@ -1,6 +1,11 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/paginator"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
func Truncate(s string, max int) string {
|
func Truncate(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
@@ -9,6 +14,33 @@ func Truncate(s string, max int) string {
|
|||||||
return s[:max-1] + "…"
|
return s[:max-1] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CenterLines centers each line horizontally relative to the longest one.
|
||||||
|
func CenterLines(lines ...string) string {
|
||||||
|
maxWidth := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
if w := lipgloss.Width(l); w > maxWidth {
|
||||||
|
maxWidth = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
centered := make([]string, len(lines))
|
||||||
|
for i, l := range lines {
|
||||||
|
centered[i] = lipgloss.PlaceHorizontal(maxWidth, lipgloss.Center, l)
|
||||||
|
}
|
||||||
|
return strings.Join(centered, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageBounds returns clamped start/end indices for rendering a paginated list.
|
||||||
|
func PageBounds(p paginator.Model, total int) (start, end int) {
|
||||||
|
start, end = p.GetSliceBounds(total)
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if end < start {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// InferScheme returns "http" for port 80, "https" otherwise.
|
// InferScheme returns "http" for port 80, "https" otherwise.
|
||||||
func InferScheme(host string) string {
|
func InferScheme(host string) string {
|
||||||
if strings.HasSuffix(host, ":80") {
|
if strings.HasSuffix(host, ":80") {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
pre-commit:
|
|
||||||
piped: true
|
|
||||||
commands:
|
|
||||||
check-vendor-hash:
|
|
||||||
glob: "{go.mod,go.sum}"
|
|
||||||
run: .github/scripts/check-vendor-hash.sh
|
|
||||||
stage_fixed: true
|
|
||||||
inject-exec-basics:
|
|
||||||
glob: "{docs/basics.md,cmd/**}"
|
|
||||||
run: python3 .github/scripts/inject-exec.py docs/basics.md
|
|
||||||
stage_fixed: true
|
|
||||||
inject-exec:
|
|
||||||
glob: "{README.md,docs/basics.md,cmd/**}"
|
|
||||||
run: python3 .github/scripts/inject-exec.py README.md
|
|
||||||
stage_fixed: true
|
|
||||||
toc:
|
|
||||||
glob: "{README.md,docs/basics.md,cmd/**}"
|
|
||||||
run: doctoc --notitle README.md
|
|
||||||
stage_fixed: true
|
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
schema = 3
|
||||||
|
|
||||||
|
[mod]
|
||||||
|
[mod.'charm.land/bubbles/v2']
|
||||||
|
version = 'v2.1.0'
|
||||||
|
hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA='
|
||||||
|
|
||||||
|
[mod.'charm.land/bubbletea/v2']
|
||||||
|
version = 'v2.0.6'
|
||||||
|
hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434='
|
||||||
|
|
||||||
|
[mod.'charm.land/glamour/v2']
|
||||||
|
version = 'v2.0.0'
|
||||||
|
hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4='
|
||||||
|
|
||||||
|
[mod.'charm.land/lipgloss/v2']
|
||||||
|
version = 'v2.0.3'
|
||||||
|
hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk='
|
||||||
|
|
||||||
|
[mod.'github.com/alecthomas/chroma/v2']
|
||||||
|
version = 'v2.24.1'
|
||||||
|
hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng='
|
||||||
|
|
||||||
|
[mod.'github.com/andybalholm/brotli']
|
||||||
|
version = 'v1.0.4'
|
||||||
|
hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8='
|
||||||
|
|
||||||
|
[mod.'github.com/atotto/clipboard']
|
||||||
|
version = 'v0.1.4'
|
||||||
|
hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk='
|
||||||
|
|
||||||
|
[mod.'github.com/aymerick/douceur']
|
||||||
|
version = 'v0.2.0'
|
||||||
|
hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/colorprofile']
|
||||||
|
version = 'v0.4.3'
|
||||||
|
hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/ultraviolet']
|
||||||
|
version = 'v0.0.0-20260511121909-c840852527f3'
|
||||||
|
hash = 'sha256-oWEEaaetCmXvflD03MWy8qD105Kd62iI62+lxofE9Ns='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/ansi']
|
||||||
|
version = 'v0.11.7'
|
||||||
|
hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/exp/slice']
|
||||||
|
version = 'v0.0.0-20260517005351-920740d613be'
|
||||||
|
hash = 'sha256-bMsbEzP1gHA2OJx4zMYZUI3UFhcTG+mcFm8rRY+Khh8='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/term']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/termios']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/windows']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/displaywidth']
|
||||||
|
version = 'v0.11.0'
|
||||||
|
hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/uax29/v2']
|
||||||
|
version = 'v2.7.0'
|
||||||
|
hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE='
|
||||||
|
|
||||||
|
[mod.'github.com/dlclark/regexp2']
|
||||||
|
version = 'v1.12.0'
|
||||||
|
hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI='
|
||||||
|
|
||||||
|
[mod.'github.com/dustin/go-humanize']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc='
|
||||||
|
|
||||||
|
[mod.'github.com/fsnotify/fsnotify']
|
||||||
|
version = 'v1.10.1'
|
||||||
|
hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU='
|
||||||
|
|
||||||
|
[mod.'github.com/go-viper/mapstructure/v2']
|
||||||
|
version = 'v2.5.0'
|
||||||
|
hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U='
|
||||||
|
|
||||||
|
[mod.'github.com/golang/groupcache']
|
||||||
|
version = 'v0.0.0-20210331224755-41bb18bfe9da'
|
||||||
|
hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0='
|
||||||
|
|
||||||
|
[mod.'github.com/google/uuid']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/css']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/websocket']
|
||||||
|
version = 'v1.5.0'
|
||||||
|
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
|
||||||
|
|
||||||
|
[mod.'github.com/klauspost/compress']
|
||||||
|
version = 'v1.17.8'
|
||||||
|
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
|
||||||
|
|
||||||
|
[mod.'github.com/lqqyt2423/go-mitmproxy']
|
||||||
|
version = 'v1.8.11'
|
||||||
|
hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg='
|
||||||
|
|
||||||
|
[mod.'github.com/lucasb-eyer/go-colorful']
|
||||||
|
version = 'v1.4.0'
|
||||||
|
hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-isatty']
|
||||||
|
version = 'v0.0.22'
|
||||||
|
hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-runewidth']
|
||||||
|
version = 'v0.0.23'
|
||||||
|
hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA='
|
||||||
|
|
||||||
|
[mod.'github.com/microcosm-cc/bluemonday']
|
||||||
|
version = 'v1.0.27'
|
||||||
|
hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es='
|
||||||
|
|
||||||
|
[mod.'github.com/muesli/cancelreader']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ='
|
||||||
|
|
||||||
|
[mod.'github.com/ncruces/go-strftime']
|
||||||
|
version = 'v1.0.0'
|
||||||
|
hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ='
|
||||||
|
|
||||||
|
[mod.'github.com/pelletier/go-toml/v2']
|
||||||
|
version = 'v2.3.1'
|
||||||
|
hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg='
|
||||||
|
|
||||||
|
[mod.'github.com/remyoudompheng/bigfft']
|
||||||
|
version = 'v0.0.0-20230129092748-24d4a6f8daec'
|
||||||
|
hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM='
|
||||||
|
|
||||||
|
[mod.'github.com/rivo/uniseg']
|
||||||
|
version = 'v0.4.7'
|
||||||
|
hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo='
|
||||||
|
|
||||||
|
[mod.'github.com/sagikazarmark/locafero']
|
||||||
|
version = 'v0.12.0'
|
||||||
|
hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws='
|
||||||
|
|
||||||
|
[mod.'github.com/sahilm/fuzzy']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY='
|
||||||
|
|
||||||
|
[mod.'github.com/satori/go.uuid']
|
||||||
|
version = 'v1.2.0'
|
||||||
|
hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg='
|
||||||
|
|
||||||
|
[mod.'github.com/sirupsen/logrus']
|
||||||
|
version = 'v1.9.4'
|
||||||
|
hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/afero']
|
||||||
|
version = 'v1.15.0'
|
||||||
|
hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cast']
|
||||||
|
version = 'v1.10.0'
|
||||||
|
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/pflag']
|
||||||
|
version = 'v1.0.10'
|
||||||
|
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/viper']
|
||||||
|
version = 'v1.21.0'
|
||||||
|
hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI='
|
||||||
|
|
||||||
|
[mod.'github.com/subosito/gotenv']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A='
|
||||||
|
|
||||||
|
[mod.'github.com/xo/terminfo']
|
||||||
|
version = 'v0.0.0-20220910002029-abceb7e1c41e'
|
||||||
|
hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark']
|
||||||
|
version = 'v1.8.2'
|
||||||
|
hash = 'sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark-emoji']
|
||||||
|
version = 'v1.0.6'
|
||||||
|
hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/gopher-lua']
|
||||||
|
version = 'v1.1.2'
|
||||||
|
hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po='
|
||||||
|
|
||||||
|
[mod.'go.uber.org/atomic']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY='
|
||||||
|
|
||||||
|
[mod.'go.yaml.in/yaml/v3']
|
||||||
|
version = 'v3.0.4'
|
||||||
|
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/net']
|
||||||
|
version = 'v0.54.0'
|
||||||
|
hash = 'sha256-/EoIXzTQzK/yP/lxOyx0Z/bhns4FdPTIF4uyt4gIP80='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/sync']
|
||||||
|
version = 'v0.20.0'
|
||||||
|
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/sys']
|
||||||
|
version = 'v0.44.0'
|
||||||
|
hash = 'sha256-JDlj+PKsG6I6kjv5JyOUNreY51u5An0oZ5OZMHZSk+A='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/text']
|
||||||
|
version = 'v0.37.0'
|
||||||
|
hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E='
|
||||||
|
|
||||||
|
[mod.'gopkg.in/yaml.v3']
|
||||||
|
version = 'v3.0.1'
|
||||||
|
hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU='
|
||||||
|
|
||||||
|
[mod.'modernc.org/libc']
|
||||||
|
version = 'v1.72.3'
|
||||||
|
hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI='
|
||||||
|
|
||||||
|
[mod.'modernc.org/mathutil']
|
||||||
|
version = 'v1.7.1'
|
||||||
|
hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc='
|
||||||
|
|
||||||
|
[mod.'modernc.org/memory']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM='
|
||||||
|
|
||||||
|
[mod.'modernc.org/sqlite']
|
||||||
|
version = 'v1.50.1'
|
||||||
|
hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0='
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
buildGoApplication,
|
||||||
|
}: let
|
||||||
|
pname = "spilltea";
|
||||||
|
version = "0.0.6";
|
||||||
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
|
pkg = buildGoApplication {
|
||||||
|
inherit pname version ldflags;
|
||||||
|
src = ../.;
|
||||||
|
modules = ./gomod2nix.toml;
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
|
homepage = "https://github.com/anotherhadi/spilltea";
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
"${pname}" = pkg;
|
||||||
|
default = pkg;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
gitHooksLib,
|
||||||
|
gomod2nixPkgs,
|
||||||
|
}: let
|
||||||
|
hooks = gitHooksLib.run {
|
||||||
|
src = ../.;
|
||||||
|
hooks = {
|
||||||
|
gofmt.enable = true;
|
||||||
|
govet.enable = true;
|
||||||
|
|
||||||
|
gomod2nix = {
|
||||||
|
enable = true;
|
||||||
|
name = "gomod2nix";
|
||||||
|
entry = "gomod2nix --outdir ./nix";
|
||||||
|
language = "system";
|
||||||
|
files = "go\\.(mod|sum)$";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
inject-exec-basics = {
|
||||||
|
enable = true;
|
||||||
|
name = "inject-exec-basics";
|
||||||
|
entry = "python3 .github/scripts/inject-exec.py docs/basics.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
inject-exec = {
|
||||||
|
enable = true;
|
||||||
|
name = "inject-exec";
|
||||||
|
entry = "python3 .github/scripts/inject-exec.py README.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(README\\.md|docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
doctoc = {
|
||||||
|
enable = true;
|
||||||
|
name = "doctoc";
|
||||||
|
entry = "doctoc --notitle README.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(README\\.md|docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = with pkgs;
|
||||||
|
[
|
||||||
|
go
|
||||||
|
python3
|
||||||
|
doctoc
|
||||||
|
trufflehog
|
||||||
|
gomod2nixPkgs.gomod2nix
|
||||||
|
]
|
||||||
|
++ hooks.enabledPackages;
|
||||||
|
|
||||||
|
shellHook = hooks.shellHook;
|
||||||
|
}
|
||||||
@@ -3,20 +3,26 @@ Plugin = {
|
|||||||
description = [[
|
description = [[
|
||||||
Inject custom headers into every intercepted request.
|
Inject custom headers into every intercepted request.
|
||||||
|
|
||||||
**Config**:
|
**Config** (YAML):
|
||||||
- one 'Header-Name: value' per line.
|
```yaml
|
||||||
|
headers:
|
||||||
|
- "X-My-Header: myvalue"
|
||||||
|
```
|
||||||
]],
|
]],
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local headers = {}
|
local headers = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
headers = {}
|
headers = {}
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
local cfg = get_config()
|
||||||
local name, value = line:match("^([^:]+):%s*(.+)$")
|
if cfg and cfg.headers then
|
||||||
if name and value then
|
for _, line in ipairs(cfg.headers) do
|
||||||
table.insert(headers, { name = name, value = value })
|
local name, value = line:match("^([^:]+):%s*(.+)$")
|
||||||
|
if name and value then
|
||||||
|
table.insert(headers, { name = name, value = value })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+24
-28
@@ -3,32 +3,34 @@ Plugin = {
|
|||||||
description = [[
|
description = [[
|
||||||
Checks that the proxy's outbound IP is in an allowed list on startup.
|
Checks that the proxy's outbound IP is in an allowed list on startup.
|
||||||
|
|
||||||
**Config**:
|
**Config** (YAML):
|
||||||
- one IP per line
|
```yaml
|
||||||
- prefix with `!` for a blacklist entry (blocked)
|
ips:
|
||||||
- prefix with `#` to comment it out (ignored)
|
- "1.2.3.4" # whitelist entry
|
||||||
- if no IPs are configured, the check is skipped
|
- "!5.6.7.8" # blacklist entry (blocked)
|
||||||
|
```
|
||||||
|
- If no IPs are configured, the check is skipped.
|
||||||
]],
|
]],
|
||||||
on_start = { sync = false },
|
on_start = { sync = false },
|
||||||
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local whitelist = {}
|
local whitelist = {}
|
||||||
local blacklist = {}
|
local blacklist = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
whitelist = {}
|
whitelist, blacklist = {}, {}
|
||||||
blacklist = {}
|
local cfg = get_config()
|
||||||
|
if cfg and cfg.ips then
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
for _, entry in ipairs(cfg.ips) do
|
||||||
local trimmed = line:match("^%s*(.-)%s*$")
|
local trimmed = entry:match("^%s*(.-)%s*$")
|
||||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
if trimmed ~= "" then
|
||||||
if trimmed:sub(1, 1) == "!" then
|
if trimmed:sub(1, 1) == "!" then
|
||||||
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
||||||
if ip ~= "" then
|
if ip ~= "" then table.insert(blacklist, ip) end
|
||||||
table.insert(blacklist, ip)
|
else
|
||||||
|
table.insert(whitelist, trimmed)
|
||||||
end
|
end
|
||||||
else
|
|
||||||
table.insert(whitelist, trimmed)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -39,16 +41,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
|
||||||
|
|||||||
+28
-29
@@ -3,43 +3,40 @@ Plugin = {
|
|||||||
description = [[
|
description = [[
|
||||||
Auto-forward requests and exclude them from history based on patterns.
|
Auto-forward requests and exclude them from history based on patterns.
|
||||||
|
|
||||||
**Config**:
|
**Config** (YAML):
|
||||||
- `pattern` - whitelist: only intercept matching requests/responses and history entries
|
```yaml
|
||||||
- `!pattern` - blacklist: skip matching requests/responses and history entries
|
patterns:
|
||||||
- `r:pattern` - whitelist for requests/responses only (history unaffected)
|
- "pattern" # whitelist: only intercept matching requests/responses and history
|
||||||
- `r:!pattern` - blacklist for requests/responses only
|
- "!pattern" # blacklist: skip matching requests/responses and history
|
||||||
- `h:pattern` - whitelist for history entries only (requests unaffected)
|
- "r:pattern" # whitelist for requests/responses only
|
||||||
- `h:!pattern` - blacklist for history entries only
|
- "r:!pattern" # blacklist for requests/responses only
|
||||||
- lines starting with `#` are comments
|
- "h:pattern" # whitelist for history only
|
||||||
|
- "h:!pattern" # blacklist for history only
|
||||||
|
```
|
||||||
|
|
||||||
Example (ignore static assets):
|
Example (ignore static assets):
|
||||||
```
|
```yaml
|
||||||
!%.css$
|
patterns:
|
||||||
!%.js$
|
- "!%.css$"
|
||||||
!%.png$
|
- "!%.js$"
|
||||||
|
- "!%.png$"
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (focus on mytarget.com, skip everything else):
|
Example (focus on mytarget.com):
|
||||||
```
|
```yaml
|
||||||
mytarget%.com/
|
patterns:
|
||||||
|
- "mytarget%.com/"
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (intercept mytarget.com except its static assets):
|
Example (disable history):
|
||||||
```
|
```yaml
|
||||||
mytarget%.com/
|
patterns:
|
||||||
!%.css$
|
- "h:^$"
|
||||||
!%.js$
|
|
||||||
!%.png$
|
|
||||||
```
|
|
||||||
|
|
||||||
Example (disable history — h: whitelist never matches any real URL):
|
|
||||||
```
|
|
||||||
h:^$
|
|
||||||
```
|
```
|
||||||
]],
|
]],
|
||||||
priority = 100,
|
priority = 100,
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
on_response = { sync = true },
|
on_response = { sync = true },
|
||||||
on_history_entry = { sync = true },
|
on_history_entry = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +47,13 @@ local whitelist_req = {}
|
|||||||
local blacklist_hist = {}
|
local blacklist_hist = {}
|
||||||
local whitelist_hist = {}
|
local whitelist_hist = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
blacklist, whitelist = {}, {}
|
blacklist, whitelist = {}, {}
|
||||||
blacklist_req, whitelist_req = {}, {}
|
blacklist_req, whitelist_req = {}, {}
|
||||||
blacklist_hist, whitelist_hist = {}, {}
|
blacklist_hist, whitelist_hist = {}, {}
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
local cfg = get_config()
|
||||||
|
if not cfg or not cfg.patterns then return end
|
||||||
|
for _, line in ipairs(cfg.patterns) do
|
||||||
local trimmed = line:match("^%s*(.-)%s*$")
|
local trimmed = line:match("^%s*(.-)%s*$")
|
||||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||||
local scope = trimmed:match("^([rh]):")
|
local scope = trimmed:match("^([rh]):")
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
|
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 result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
|
||||||
|
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
|
||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed docs
|
||||||
|
var DocsFS embed.FS
|
||||||
|
|
||||||
//go:embed plugins/*.lua
|
//go:embed plugins/*.lua
|
||||||
var PluginsFS embed.FS
|
var PluginsFS embed.FS
|
||||||
|
|
||||||
Reference in New Issue
Block a user