19 Commits

Author SHA1 Message Date
Hadi 6aa377acd8 add demo
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 22:31:29 +02:00
Hadi 2f4765bf37 Add asciimoji
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 22:26:26 +02:00
Hadi fac335a16e Add Installation instructions
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 20:41:53 +02:00
Hadi 407ca13a33 v0.0.3
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:20:57 +02:00
Hadi 9ab7f12bf4 gofmt
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:20:33 +02:00
Hadi 7d4f32549e update vendor hash
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:19:28 +02:00
Hadi bed122fc99 Merge pull request #1 from anotherhadi/dependabot/go_modules/github.com/sirupsen/logrus-1.8.3
Bump github.com/sirupsen/logrus from 1.8.1 to 1.8.3
2026-05-13 18:17:05 +02:00
Hadi 967aab8363 Edit issue templates: md -> yaml, init plugin_request
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:15:42 +02:00
Hadi a414a51168 Update docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:11:47 +02:00
Hadi c7392474b7 CopyRequest -> Copy & CopyAs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:07:23 +02:00
Hadi de254b4e52 Use local timezone for findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 17:36:46 +02:00
Hadi 47d2cf6845 change default config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 17:04:06 +02:00
Hadi 26994a3a37 upstream proxy
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 17:03:00 +02:00
Hadi 4eb9dd53f5 Change plugins behavior
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 16:52:12 +02:00
Hadi dbea0ab0f2 Add cli flags
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 22:54:36 +02:00
dependabot[bot] 4caecaeec4 Bump github.com/sirupsen/logrus from 1.8.1 to 1.8.3
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.8.1 to 1.8.3.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.8.1...v1.8.3)

---
updated-dependencies:
- dependency-name: github.com/sirupsen/logrus
  dependency-version: 1.8.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:50:45 +00:00
Hadi a6bd5c1071 Rename auto-forward -> toggle-intercept
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 22:25:10 +02:00
Hadi 7879720d07 Remove scope page
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 22:01:29 +02:00
Hadi 329216c082 Add issue templates
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 19:38:47 +02:00
59 changed files with 1382 additions and 779 deletions
+57
View File
@@ -0,0 +1,57 @@
name: "🐞 Bug Report"
description: Report a reproducible error
title: "[BUG] "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of the issue.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Step-by-step instructions to trigger the bug.
placeholder: |
1. ...
2. ...
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened instead.
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
placeholder: "e.g. Ubuntu 24.04, macOS 15"
validations:
required: true
- type: input
id: version
attributes:
label: spilltea version
description: Output of `spilltea -v`
placeholder: "e.g. v0.3.1"
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Logs, screenshots, or anything else relevant.
validations:
required: false
@@ -0,0 +1,37 @@
name: "🚀 Feature Request"
description: Suggest an idea for this project
title: "[FEATURE] "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: Is your feature request related to a problem? Describe it.
placeholder: "I'm always frustrated when..."
validations:
required: false
- type: textarea
id: solution
attributes:
label: Proposed solution
description: A clear description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any alternative solutions or features you've thought about.
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional context
description: Mockups, related issues, or anything else relevant.
validations:
required: false
+52
View File
@@ -0,0 +1,52 @@
name: "🧩 Plugin Request"
description: Suggest a plugin idea or propose an existing plugin
title: "[PLUGIN] "
labels: ["plugin"]
body:
- type: dropdown
id: request_type
attributes:
label: Request type
description: Are you proposing an idea or an already-built plugin?
options:
- "Plugin idea — I have an idea but haven't built it"
- "Plugin proposal — I have already built a plugin"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: >
**If this is an idea:** describe what the plugin would do and the problem it solves.
**If this is a proposal:** describe what your plugin does and link to its repository.
placeholder: "This plugin would..."
validations:
required: true
- type: input
id: repo
attributes:
label: Repository (proposals only)
description: Link to the plugin repository, if it exists.
placeholder: "https://github.com/..."
validations:
required: false
- type: textarea
id: use_case
attributes:
label: Use case
description: Who would benefit from this plugin and in what scenario?
placeholder: "Useful when testing APIs that..."
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional context
description: Screenshots, mockups, related issues, or anything else relevant.
validations:
required: false
Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

+41
View File
@@ -0,0 +1,41 @@
Output ./.github/assets/demo.gif
Require spilltea
Set Shell "zsh"
Set FontSize 32
Set Width 1600
Set Height 1900
Type "spilltea"
Sleep 800ms
Enter
Sleep 3s
Down@800ms 2
Up@800ms 1
Enter@800ms 1
Wait+Screen /hadi.icu/
Sleep 3s
Ctrl+Y
Sleep 3s
Down@1.9 4
Escape@1.3 1
Ctrl+R
Sleep 3s
Type "e"
Sleep 1s
Escape
Sleep 1s
Type "s"
Sleep 6s
Type "1"
Sleep 2s
Type "f"
Sleep 2s
Type "2"
Sleep 2s
+65 -30
View File
@@ -1,6 +1,7 @@
# Plugins # Plugins
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic. Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
You can found some pre-built plugins [here](../../plugins/).
## Where to place plugins ## Where to place plugins
@@ -14,26 +15,29 @@ Every plugin must declare a `Plugin` table and implement the hooks it wants to u
```lua ```lua
Plugin = { Plugin = {
name = "My Plugin", name = "My Plugin",
description = "What this plugin does.",
priority = 0, -- higher = runs before other plugins (default: 0)
-- Declare which hooks you use and whether they are synchronous. -- Declare which hooks you use and whether they are synchronous (default: false).
on_start = { sync = true }, -- on_config and on_quit are always sync and do not need to be declared here.
on_request = { sync = true }, on_start = { sync = true },
on_response = { sync = false }, on_request = { sync = true },
on_history_entry = {}, on_response = { sync = false },
on_quit = {}, on_history_entry = { sync = true },
} }
``` ```
### Hook reference ### Hook reference
| Hook | When called | Sync/async | Return value | | Hook | When called | Sync/async | Return value (sync only) |
| ------------------------- | --------------------------- | ------------ | ------------------- | | ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- |
| `on_start(config_text)` | Once at startup | always sync | ignored | | `on_config(config_text)` | At startup and on config save | always sync | ignored |
| `on_quit()` | When the app exits | always sync | ignored | | `on_start()` | Once at startup, after `on_config` | configurable | ignored |
| `on_request(req)` | Every request | declared | `"drop"`, `"forward"`, or `nil` (sync only) | | `on_quit()` | When the app exits | always sync | ignored |
| `on_response(req, res)` | Every response | declared | `"drop"`, `"forward"`, or `nil` (sync only) | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_history_entry(entry)` | After a flow is saved to DB | always async | ignored | | `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) |
## Request and response objects ## Request and response objects
@@ -80,7 +84,8 @@ Plugin = {
log("message") log("message")
-- Send a notification bubble in the TUI -- Send a notification bubble in the TUI
notif("Title", "Body text") -- kind is optional: "info" (default), "success", "warning", "error"
notif("Title", "Body text", "warning")
-- Create a finding (shown on the Findings page, persisted in DB) -- Create a finding (shown on the Findings page, persisted in DB)
create_finding({ create_finding({
@@ -90,8 +95,17 @@ create_finding({
severity = "high", -- info | low | medium | high | critical severity = "high", -- info | low | medium | high | critical
}) })
-- Check if a URL matches the current scope (whitelist/blacklist) -- Run a raw SQL query against the project DB (entries, findings, replay_entries, …)
local ok = is_in_scope("https://example.com/api/v1") -- Returns a table of rows; each row is a table indexed by column name.
-- Returns nil + error string on failure.
local rows, err = db_query("SELECT id, host FROM entries WHERE host = ?", "example.com")
if err then
log("query failed: " .. err)
else
for i = 1, #rows do
log(rows[i].host)
end
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")
@@ -103,25 +117,46 @@ A finding is identified by `(plugin_name, key)`. If a finding with that pair alr
## Configuration ## Configuration
Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_start(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.). Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.).
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI.
## Sync vs async ## Sync vs async
- **`sync = true`**: spilltea waits for the hook to return before continuing. For `on_request`/`on_response` this blocks the proxy goroutine; the hook can return one of the values below. - **`sync = true`**: spilltea waits for the hook to return before continuing. The hook can return a decision value (see below).
- **`sync = false`** (or omitted for supported hooks): the hook runs in a background goroutine. Return values are ignored. Use this for analysis and findings. - **`sync = false`** (default for all configurable hooks): the hook runs in a background goroutine. Return values are ignored.
### Return values for `on_request` and `on_response` (sync only) `on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
| Return value | Effect | ### Return values for sync hooks
| ------------ | ------ |
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. | **`on_request` and `on_response`:**
| `"forward"` | The flow is forwarded immediately without going through the intercept panel. |
| 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. | | `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. |
The `sync` declaration is only meaningful for `on_request` and `on_response`. The other hooks have fixed behaviour: **`on_history_entry` (sync only):**
- `on_start` is **always synchronous**: plugins are initialised one by one before the first request is accepted. | Return value | Effect |
- `on_quit` is **always synchronous**: the app waits for all `on_quit` hooks before exiting. | ------------------- | -------------------------------------- |
- `on_history_entry` is **always asynchronous**. | `"skip"` | The entry is not saved to the DB. |
| `"keep"` or `nil` | The entry is saved normally. |
> A sync `on_request` or `on_response` hook that hangs will block traffic for that flow. There is no automatic timeout. Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
## Priority
Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
```lua
Plugin = {
name = "Scopes",
priority = 100, -- runs before all default-priority plugins
...
}
```
> A sync hook that hangs will block traffic for that flow. There is no automatic timeout.
-19
View File
@@ -1,19 +0,0 @@
## Scopes
Scopes let you control which requests Spilltea intercepts. Patterns are Go regular expressions matched against `host/path` (e.g. `api.example.com/v1/users`).
- **Whitelist**: if non-empty, only matching requests are intercepted.
- **Blacklist**: matching requests are always ignored, even if whitelisted.
When both lists are set, a request must pass the whitelist _and_ not be in the blacklist.
### Examples
| Pattern | Matches |
| -------------------------- | ----------------------------------- |
| `example\.com` | any request to `example.com` |
| `^api\.example\.com` | only the `api` subdomain |
| `example\.com/api/v2` | a specific path prefix |
| `\.(js\|css\|png\|woff2?)` | static assets (useful in blacklist) |
| `googleapis\.com` | all Google API traffic |
| `/graphql$` | any host with a `/graphql` endpoint |
-48
View File
@@ -1,48 +0,0 @@
-- Check that the proxy's outbound IP is in the whitelist before starting.
-- Config: one allowed IP per line. Leave empty to disable the check.
Plugin = {
name = "IP Whitelist",
on_start = {},
}
function on_start(config_text)
local allowed = {}
for line in config_text:gmatch("[^\n]+") do
local ip = line:match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(allowed, ip)
end
end
if #allowed == 0 then
log("no IPs configured, skipping check")
return
end
-- Fetch the current outbound IP via a public API.
local ok, result = pcall(function()
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
if not handle then return nil end
local ip = handle:read("*a")
handle:close()
return ip and ip:match("^%s*(.-)%s*$") or nil
end)
if not ok or not result or result == "" then
log("could not determine outbound IP, skipping check")
return
end
log("outbound IP: " .. result)
for _, ip in ipairs(allowed) do
if result == ip then
log("IP " .. result .. " is whitelisted")
return
end
end
notif("IP Whitelist", "Outbound IP " .. result .. " is NOT in the whitelist!")
quit("outbound IP " .. result .. " not whitelisted")
end
-35
View File
@@ -1,35 +0,0 @@
-- Scan response bodies for common API key / secret patterns.
-- Runs asynchronously so it never delays traffic.
Plugin = {
name = "Secret Finder",
on_response = { sync = false },
}
local PATTERNS = {
{ pattern = "AIza[0-9A-Za-z%-_]{35}", label = "Google API Key" },
{ pattern = "AKIA[0-9A-Z]{16}", label = "AWS Access Key" },
{ pattern = "sk%-[a-zA-Z0-9]{20,}", label = "OpenAI API Key" },
{ pattern = "ghp_[a-zA-Z0-9]{36}", label = "GitHub Personal Token" },
{ pattern = "Bearer%s+[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+",
label = "JWT Bearer Token" },
}
function on_response(req, res)
local body = res:get_body()
if body == "" then return end
for _, p in ipairs(PATTERNS) do
if body:find(p.pattern) then
local key = p.label .. ":" .. req.host
create_finding({
title = p.label .. " in response",
description = "**Host:** `" .. req.host .. "`\n\n" ..
"**Path:** `" .. req.path .. "`\n\n" ..
"Pattern `" .. p.pattern .. "` matched in the response body.",
key = key,
severity = "high",
})
end
end
end
+55 -2
View File
@@ -20,18 +20,58 @@ Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between yo
It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, keyboard-driven tool that gets out of your way. It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, keyboard-driven tool that gets out of your way.
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
## Features ## Features
- **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding. - **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding.
- **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history. - **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history.
- **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration - **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration
- **Scopes**: Keep your history clean by white/blacklisting domains or specific paths.
- **HTTPS Support** (using go-mitmproxy under the hood) - **HTTPS Support** (using go-mitmproxy under the hood)
- Built-in Integrations: - Built-in Integrations:
- **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly. - **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly.
- **cURL / HTTPie**: Copy any request as a curl or httpie command to your clipboard. - **cURL / HTTPie**: Copy any request as a curl or httpie command to your clipboard.
- **Markdown Export**: Export any request and its response as a clean Markdown snippet, ready to drop into a report. - **Markdown Export**: Export any request and its response as a clean Markdown snippet, ready to drop into a report.
## Installation
<details>
<summary>Go install</summary>
```sh
go install github.com/anotherhadi/spilltea/cmd/spilltea@latest
```
Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`).
</details>
<details>
<summary>Nix (temporary run, no install)</summary>
```sh
nix run github:anotherhadi/spilltea
```
</details>
<details>
<summary>NixOS (flake)</summary>
Add spilltea to your flake inputs:
```nix
inputs.spilltea.url = "github:anotherhadi/spilltea";
```
Then add the package to your system or home-manager packages:
```nix
environment.systemPackages = [ inputs.spilltea.packages.${pkgs.system}.default ];
```
</details>
## Project Management ## Project Management
Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files. Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
@@ -45,13 +85,26 @@ On startup, you choose:
## Plugin System ## Plugin System
Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code. Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code.
For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md). For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md) or [plugin examples](./plugins/).
## Configuration ## Configuration
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`. Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
Check the default configuration with all the options [here](./internal/config/default_config.yaml) Check the default configuration with all the options [here](./internal/config/default_config.yaml)
## CLI Flags
| Flag | Short | Description |
| ----------------------- | ----- | ------------------------------------------------------------------------------ |
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
| `--plugin-dir` | | Path to plugins dir, overrides config (default: `~/.config/spilltea/plugins/`) |
| `--host` | | Proxy host, overrides config |
| `--port` | `-p` | Proxy port, overrides config |
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session |
| `--upstream-proxy` | | Upstream proxy URL, overrides config (e.g. `http://user:pass@host:8888`) |
| `--version` | `-v` | Print version and exit |
| `--add-default-plugins` | | Add the default plugins to your plugins dir and exit |
## Deployment ## Deployment
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component. spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
+37 -5
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
spilltea "github.com/anotherhadi/spilltea"
"github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
@@ -22,11 +23,14 @@ var version = "dev"
func main() { func main() {
var ( var (
flagConfig = flag.StringP("config", "c", "", "path to config file") flagConfig = flag.StringP("config", "c", "", "path to config file")
flagHost = flag.String("host", "", "proxy host (overrides config)") flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)") flagHost = flag.String("host", "", "proxy host (overrides config)")
flagVersion = flag.BoolP("version", "v", false, "print version") flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`) flagUpstreamProxy = flag.String("upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
flagVersion = flag.BoolP("version", "v", false, "print version")
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
) )
flag.Parse() flag.Parse()
@@ -35,6 +39,28 @@ func main() {
os.Exit(0) os.Exit(0)
} }
if *flagAddDefaultPlugins {
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
if *flagConfig != "" {
cfgPath = *flagConfig
}
if err := config.Load(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
dir := config.ExpandPath(config.Global.App.PluginsDir)
if *flagPluginsDir != "" {
dir = *flagPluginsDir
}
n, err := spilltea.InstallDefaultPlugins(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err)
os.Exit(1)
}
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
os.Exit(0)
}
if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) { if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) {
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject) fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
os.Exit(1) os.Exit(1)
@@ -51,12 +77,18 @@ func main() {
} }
config.Global.Version = version config.Global.Version = version
if *flagPluginsDir != "" {
config.Global.App.PluginsDir = *flagPluginsDir
}
if *flagHost != "" { if *flagHost != "" {
config.Global.App.Host = *flagHost config.Global.App.Host = *flagHost
} }
if *flagPort != 0 { if *flagPort != 0 {
config.Global.App.Port = *flagPort config.Global.App.Port = *flagPort
} }
if *flagUpstreamProxy != "" {
config.Global.App.UpstreamProxy = *flagUpstreamProxy
}
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port) addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
// Check if the proxy port is available before starting the UI. // Check if the proxy port is available before starting the UI.
+2 -2
View File
@@ -14,7 +14,7 @@
(system: f system (import nixpkgs {inherit system;})); (system: f system (import nixpkgs {inherit system;}));
pname = "spilltea"; pname = "spilltea";
version = "0.0.1"; version = "0.0.3";
ldflags = ["-s" "-w" "-X main.version=${version}"]; ldflags = ["-s" "-w" "-X main.version=${version}"];
in { in {
@@ -25,7 +25,7 @@
src = ./.; src = ./.;
outputs = ["out"]; outputs = ["out"];
vendorHash = "sha256-u+u38Qr5Dugk5eFmxTK4vKUEv2SlXcfY6ZFlu1cPqVk="; vendorHash = "sha256-v37RFS/T6KGZTO1tHmtUqBrRcCqNS3+ACBcsd7tl50c=";
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players."; description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
+1 -1
View File
@@ -9,7 +9,7 @@ require (
charm.land/lipgloss/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3
github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/ansi v0.11.7
github.com/lqqyt2423/go-mitmproxy v1.8.11 github.com/lqqyt2423/go-mitmproxy v1.8.11
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.3
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/yuin/gopher-lua v1.1.2 github.com/yuin/gopher-lua v1.1.2
+7 -4
View File
@@ -42,6 +42,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
@@ -108,8 +109,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -120,7 +121,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -146,7 +148,7 @@ golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -157,6 +159,7 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
+9 -7
View File
@@ -18,11 +18,12 @@ 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"`
} `mapstructure:"app"` } `mapstructure:"app"`
TUI struct { TUI struct {
@@ -33,8 +34,9 @@ type Config struct {
} `mapstructure:"tui"` } `mapstructure:"tui"`
Intercept struct { Intercept struct {
DefaultAutoForward bool `mapstructure:"default_auto_forward"` DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
DefaultCaptureResponse bool `mapstructure:"default_capture_response"` DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
} `mapstructure:"intercept"` } `mapstructure:"intercept"`
Replay struct { Replay struct {
+10 -6
View File
@@ -4,19 +4,22 @@ app:
cert_dir: ~/.local/share/spilltea cert_dir: ~/.local/share/spilltea
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
intercept: intercept:
default_auto_forward: false default_intercept_enabled: true
default_capture_response: false default_capture_response: false
auto_forward_regex:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
replay: replay:
switch_to_page_on_send: false switch_to_page_on_send: true
history: history:
skip_duplicates: false # if true, skip saving entries with the same method, host, path and body skip_duplicates: true # if true, skip saving entries with the same method, host, path and body
tui: tui:
use_nerdfont_icons: true use_nerdfont_icons: false
default_sidebar_state: "expanded" # hidden, collapsed, expanded default_sidebar_state: "expanded" # hidden, collapsed, expanded
pretty_print_body: true # auto-indent JSON and HTML response bodies pretty_print_body: true # auto-indent JSON and HTML response bodies
colors: colors:
@@ -48,7 +51,8 @@ keybindings:
left: "left,h" left: "left,h"
right: "right,l" right: "right,l"
cycle_focus: "tab" cycle_focus: "tab"
copy_request: "ctrl+y" copy_as: "ctrl+y"
copy: "y"
send_to_replay: "ctrl+r" send_to_replay: "ctrl+r"
scroll_up: "pgup" scroll_up: "pgup"
scroll_down: "pgdown" scroll_down: "pgdown"
@@ -59,7 +63,7 @@ keybindings:
forward_all: "F" forward_all: "F"
drop: "d" drop: "d"
drop_all: "D" drop_all: "D"
auto_forward: "a" toggle_intercept: "i"
capture_response: "r" capture_response: "r"
undo_edits: "ctrl+z" undo_edits: "ctrl+z"
edit: "e,enter" edit: "e,enter"
+3 -2
View File
@@ -10,7 +10,8 @@ type GlobalKeys struct {
Left string `mapstructure:"left"` Left string `mapstructure:"left"`
Right string `mapstructure:"right"` Right string `mapstructure:"right"`
CycleFocus string `mapstructure:"cycle_focus"` CycleFocus string `mapstructure:"cycle_focus"`
CopyRequest string `mapstructure:"copy_request"` CopyAs string `mapstructure:"copy_as"`
Copy string `mapstructure:"copy"`
SendToReplay string `mapstructure:"send_to_replay"` SendToReplay string `mapstructure:"send_to_replay"`
ScrollUp string `mapstructure:"scroll_up"` ScrollUp string `mapstructure:"scroll_up"`
ScrollDown string `mapstructure:"scroll_down"` ScrollDown string `mapstructure:"scroll_down"`
@@ -22,7 +23,7 @@ type InterceptKeys struct {
ForwardAll string `mapstructure:"forward_all"` ForwardAll string `mapstructure:"forward_all"`
Drop string `mapstructure:"drop"` Drop string `mapstructure:"drop"`
DropAll string `mapstructure:"drop_all"` DropAll string `mapstructure:"drop_all"`
AutoForward string `mapstructure:"auto_forward"` ToggleIntercept string `mapstructure:"toggle_intercept"`
CaptureResponse string `mapstructure:"capture_response"` CaptureResponse string `mapstructure:"capture_response"`
UndoEdits string `mapstructure:"undo_edits"` UndoEdits string `mapstructure:"undo_edits"`
Edit string `mapstructure:"edit"` Edit string `mapstructure:"edit"`
+7 -9
View File
@@ -35,12 +35,7 @@ func (d *DB) migrate() error {
request_raw TEXT NOT NULL, request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL response_raw TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS scope ( CREATE TABLE IF NOT EXISTS replay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('whitelist','blacklist')),
pattern TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS replay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL, timestamp DATETIME NOT NULL,
scheme TEXT NOT NULL, scheme TEXT NOT NULL,
@@ -69,13 +64,16 @@ func (d *DB) migrate() error {
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
UNIQUE(plugin_name, dedup_key) UNIQUE(plugin_name, dedup_key)
); );
INSERT INTO scope (kind, pattern)
SELECT 'blacklist', '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
WHERE NOT EXISTS (SELECT 1 FROM scope);
`) `)
return err return err
} }
// Query executes a SQL query and returns the rows. The caller must close the
// returned rows. Args are passed as positional parameters.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
return d.conn.Query(query, args...)
}
func (d *DB) Close() error { func (d *DB) Close() error {
if d == nil { if d == nil {
return nil return nil
+1 -1
View File
@@ -48,7 +48,7 @@ func (d *DB) LoadFindings() ([]Finding, error) {
} }
for _, layout := range findingTimeFormats { for _, layout := range findingTimeFormats {
if t, err := time.Parse(layout, ts); err == nil { if t, err := time.Parse(layout, ts); err == nil {
f.CreatedAt = t f.CreatedAt = t.Local()
break break
} }
} }
-45
View File
@@ -1,45 +0,0 @@
package db
func (d *DB) SaveScope(whitelist, blacklist []string) error {
tx, err := d.conn.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM scope`); err != nil {
tx.Rollback()
return err
}
for _, p := range whitelist {
if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('whitelist', ?)`, p); err != nil {
tx.Rollback()
return err
}
}
for _, p := range blacklist {
if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('blacklist', ?)`, p); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
func (d *DB) LoadScope() (whitelist, blacklist []string, err error) {
rows, err := d.conn.Query(`SELECT kind, pattern FROM scope`)
if err != nil {
return nil, nil, err
}
defer rows.Close()
for rows.Next() {
var kind, pattern string
if err := rows.Scan(&kind, &pattern); err != nil {
return nil, nil, err
}
if kind == "whitelist" {
whitelist = append(whitelist, pattern)
} else {
blacklist = append(blacklist, pattern)
}
}
return whitelist, blacklist, rows.Err()
}
+40 -61
View File
@@ -14,9 +14,9 @@ import (
type Decision int type Decision int
const ( const (
Forward Decision = iota // forward without showing in intercept Forward Decision = iota // forward without showing in intercept
Drop // drop the flow Drop // drop the flow
Intercept // pass to the TUI for user decision Intercept // pass to the TUI for user decision
) )
type PendingRequest struct { type PendingRequest struct {
@@ -41,87 +41,59 @@ type Broker struct {
droppedFlows sync.Map // *proxy.Flow → struct{} droppedFlows sync.Map // *proxy.Flow → struct{}
outOfScope sync.Map // *proxy.Flow → struct{} outOfScope sync.Map // *proxy.Flow → struct{}
scopeMu sync.RWMutex autoFwdMu sync.RWMutex
whitelist []*regexp.Regexp autoFwdRegexes []*regexp.Regexp
blacklist []*regexp.Regexp
onNewEntry func(db.Entry) onBeforeNewEntry func(db.Entry) bool
onNewEntry func(db.Entry)
}
func (b *Broker) SetOnBeforeNewEntry(cb func(db.Entry) bool) {
b.onBeforeNewEntry = cb
} }
func (b *Broker) SetOnNewEntry(cb func(db.Entry)) { func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
b.onNewEntry = cb b.onNewEntry = cb
} }
// IsInScope reports whether the given target string (host+path) matches the
// current scope rules. Used by the plugin API.
func (b *Broker) IsInScope(target string) bool {
b.scopeMu.RLock()
wl := b.whitelist
bl := b.blacklist
b.scopeMu.RUnlock()
return scopeMatches(wl, bl, target)
}
func NewBroker() *Broker { func NewBroker() *Broker {
return &Broker{ b := &Broker{
Incoming: make(chan *PendingRequest, 64), Incoming: make(chan *PendingRequest, 64),
IncomingResponse: make(chan *PendingResponse, 64), IncomingResponse: make(chan *PendingResponse, 64),
} }
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
return b
} }
func (b *Broker) SetCaptureResponse(v bool) { func (b *Broker) SetCaptureResponse(v bool) {
b.captureResponse.Store(v) b.captureResponse.Store(v)
} }
// SetScope compiles and stores whitelist/blacklist regex patterns. // SetAutoForwardRegex compiles and stores patterns for requests that should
// be forwarded automatically without interception or history logging.
// Invalid patterns are silently skipped. // Invalid patterns are silently skipped.
func (b *Broker) SetScope(whitelist, blacklist []string) { func (b *Broker) SetAutoForwardRegex(patterns []string) {
wl := compilePatterns(whitelist) compiled := make([]*regexp.Regexp, 0, len(patterns))
bl := compilePatterns(blacklist)
b.scopeMu.Lock()
b.whitelist = wl
b.blacklist = bl
b.scopeMu.Unlock()
}
func compilePatterns(patterns []string) []*regexp.Regexp {
out := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns { for _, p := range patterns {
if r, err := regexp.Compile(p); err == nil { if r, err := regexp.Compile(p); err == nil {
out = append(out, r) compiled = append(compiled, r)
} }
} }
return out b.autoFwdMu.Lock()
b.autoFwdRegexes = compiled
b.autoFwdMu.Unlock()
} }
func (b *Broker) matchesScope(f *proxy.Flow) bool { func (b *Broker) isAutoForwarded(target string) bool {
target := f.Request.URL.Host + f.Request.URL.Path b.autoFwdMu.RLock()
b.scopeMu.RLock() regexes := b.autoFwdRegexes
wl := b.whitelist b.autoFwdMu.RUnlock()
bl := b.blacklist for _, r := range regexes {
b.scopeMu.RUnlock()
return scopeMatches(wl, bl, target)
}
func scopeMatches(wl, bl []*regexp.Regexp, target string) bool {
if len(wl) > 0 {
matched := false
for _, r := range wl {
if r.MatchString(target) {
matched = true
break
}
}
if !matched {
return false
}
}
for _, r := range bl {
if r.MatchString(target) { if r.MatchString(target) {
return false return true
} }
} }
return true return false
} }
func (b *Broker) SetDB(d *db.DB) { func (b *Broker) SetDB(d *db.DB) {
@@ -132,7 +104,8 @@ func (b *Broker) SetDB(d *db.DB) {
// Hold is called from the proxy addon: it blocks until a decision is made in the TUI. // Hold is called from the proxy addon: it blocks until a decision is made in the TUI.
func (b *Broker) Hold(f *proxy.Flow) Decision { func (b *Broker) Hold(f *proxy.Flow) Decision {
if !b.matchesScope(f) { target := f.Request.URL.Host + f.Request.URL.Path
if b.isAutoForwarded(target) {
b.outOfScope.Store(f, struct{}{}) b.outOfScope.Store(f, struct{}{})
return Forward return Forward
} }
@@ -168,7 +141,7 @@ func (b *Broker) HoldResponse(f *proxy.Flow) Decision {
// SaveEntry persists the completed flow to the history DB. // SaveEntry persists the completed flow to the history DB.
// It must be called after HoldResponse and before modifying f.Response. // It must be called after HoldResponse and before modifying f.Response.
// Flows that were dropped at the request phase are silently skipped. // Flows that were dropped or auto-forwarded are silently skipped.
func (b *Broker) SaveEntry(f *proxy.Flow) { func (b *Broker) SaveEntry(f *proxy.Flow) {
b.dbMu.RLock() b.dbMu.RLock()
d := b.database d := b.database
@@ -197,7 +170,7 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
return return
} }
} }
entry, err := d.InsertEntry(db.Entry{ pending := db.Entry{
Timestamp: time.Now(), Timestamp: time.Now(),
Method: r.Method, Method: r.Method,
Host: r.URL.Host, Host: r.URL.Host,
@@ -205,7 +178,13 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
StatusCode: status, StatusCode: status,
RequestRaw: FormatRawRequest(f), RequestRaw: FormatRawRequest(f),
ResponseRaw: FormatRawResponse(f), ResponseRaw: FormatRawResponse(f),
}) }
if cb := b.onBeforeNewEntry; cb != nil {
if !cb(pending) {
return
}
}
entry, err := d.InsertEntry(pending)
if err == nil { if err == nil {
if cb := b.onNewEntry; cb != nil { if cb := b.onNewEntry; cb != nil {
go cb(entry) go cb(entry)
+5 -3
View File
@@ -15,7 +15,8 @@ type GlobalKeyMap struct {
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
CycleFocus key.Binding CycleFocus key.Binding
CopyRequest key.Binding CopyAs key.Binding
Copy key.Binding
Escape key.Binding Escape key.Binding
SendToReplay key.Binding SendToReplay key.Binding
ScrollUp key.Binding ScrollUp key.Binding
@@ -34,7 +35,8 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
Left: binding(cfg.Left, "scroll left"), Left: binding(cfg.Left, "scroll left"),
Right: binding(cfg.Right, "scroll right"), Right: binding(cfg.Right, "scroll right"),
CycleFocus: binding(cfg.CycleFocus, "cycle focus"), CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
CopyRequest: binding(cfg.CopyRequest, "copy as..."), CopyAs: binding(cfg.CopyAs, "copy as..."),
Copy: binding(cfg.Copy, "copy..."),
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
SendToReplay: binding(cfg.SendToReplay, "send to replay"), SendToReplay: binding(cfg.SendToReplay, "send to replay"),
ScrollUp: binding(cfg.ScrollUp, "scroll up"), ScrollUp: binding(cfg.ScrollUp, "scroll up"),
@@ -47,7 +49,7 @@ func (g GlobalKeyMap) Bindings() []key.Binding {
return []key.Binding{ return []key.Binding{
g.Up, g.Down, g.Left, g.Right, g.CycleFocus, g.Up, g.Down, g.Left, g.Right, g.CycleFocus,
g.Quit, g.Escape, g.Help, g.Quit, g.Escape, g.Help,
g.OpenLogs, g.ToggleSidebar, g.CopyRequest, g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
g.SendToReplay, g.SendToDiff, g.SendToReplay, g.SendToDiff,
g.ScrollUp, g.ScrollDown, g.ScrollUp, g.ScrollDown,
} }
+3 -3
View File
@@ -10,7 +10,7 @@ type InterceptKeyMap struct {
ForwardAll key.Binding ForwardAll key.Binding
Drop key.Binding Drop key.Binding
DropAll key.Binding DropAll key.Binding
AutoForward key.Binding ToggleIntercept key.Binding
CaptureResponse key.Binding CaptureResponse key.Binding
UndoEdits key.Binding UndoEdits key.Binding
Edit key.Binding Edit key.Binding
@@ -23,7 +23,7 @@ func newInterceptKeyMap(cfg config.InterceptKeys) InterceptKeyMap {
ForwardAll: binding(cfg.ForwardAll, "forward all"), ForwardAll: binding(cfg.ForwardAll, "forward all"),
Drop: binding(cfg.Drop, "drop"), Drop: binding(cfg.Drop, "drop"),
DropAll: binding(cfg.DropAll, "drop all"), DropAll: binding(cfg.DropAll, "drop all"),
AutoForward: binding(cfg.AutoForward, "auto forward"), ToggleIntercept: binding(cfg.ToggleIntercept, "toggle intercept"),
CaptureResponse: binding(cfg.CaptureResponse, "capture response"), CaptureResponse: binding(cfg.CaptureResponse, "capture response"),
UndoEdits: binding(cfg.UndoEdits, "undo edits"), UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Edit: binding(cfg.Edit, "edit"), Edit: binding(cfg.Edit, "edit"),
@@ -36,6 +36,6 @@ func (ic InterceptKeyMap) Bindings() []key.Binding {
ic.Forward, ic.ForwardAll, ic.Forward, ic.ForwardAll,
ic.Drop, ic.DropAll, ic.Drop, ic.DropAll,
ic.Edit, ic.EditExternal, ic.UndoEdits, ic.Edit, ic.EditExternal, ic.UndoEdits,
ic.AutoForward, ic.CaptureResponse, ic.ToggleIntercept, ic.CaptureResponse,
} }
} }
+76 -15
View File
@@ -2,7 +2,6 @@ package plugins
import ( import (
"log" "log"
"net/url"
"strings" "strings"
"time" "time"
@@ -27,8 +26,9 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int {
title := L.CheckString(1) title := L.CheckString(1)
body := L.CheckString(2) body := L.CheckString(2)
kind := L.OptString(3, "info")
select { select {
case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}: case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body, Kind: kind}:
default: default:
} }
return 0 return 0
@@ -65,23 +65,84 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
return 0 return 0
})) }))
L.SetGlobal("is_in_scope", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("db_query", L.NewFunction(func(L *lua.LState) int {
raw := L.CheckString(1) if mgr.db == nil {
if mgr.broker == nil { L.Push(lua.LNil)
L.Push(lua.LTrue) L.Push(lua.LString("db not available"))
return 1 return 2
} }
u, err := url.Parse(raw) query := L.CheckString(1)
var args []any
for i := 2; i <= L.GetTop(); i++ {
switch v := L.Get(i).(type) {
case lua.LString:
args = append(args, string(v))
case lua.LNumber:
args = append(args, float64(v))
case lua.LBool:
args = append(args, bool(v))
default:
args = append(args, nil)
}
}
rows, err := mgr.db.Query(query, args...)
if err != nil { if err != nil {
L.Push(lua.LFalse) L.Push(lua.LNil)
return 1 L.Push(lua.LString(err.Error()))
return 2
} }
path := u.Path defer rows.Close()
if path == "" { cols, err := rows.Columns()
path = "/" if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
} }
L.Push(lua.LBool(mgr.broker.IsInScope(u.Host + path))) result := L.NewTable()
return 1 rowIdx := 1
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
row := L.NewTable()
for i, col := range cols {
switch v := vals[i].(type) {
case int64:
L.SetField(row, col, lua.LNumber(v))
case float64:
L.SetField(row, col, lua.LNumber(v))
case string:
L.SetField(row, col, lua.LString(v))
case []byte:
L.SetField(row, col, lua.LString(string(v)))
case bool:
if v {
L.SetField(row, col, lua.LTrue)
} else {
L.SetField(row, col, lua.LFalse)
}
case nil:
L.SetField(row, col, lua.LNil)
}
}
L.RawSetInt(result, rowIdx, row)
rowIdx++
}
if err := rows.Err(); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(result)
L.Push(lua.LNil)
return 2
})) }))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
+97 -39
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
@@ -27,12 +28,13 @@ type Manager struct {
func NewManager(broker *intercept.Broker) *Manager { func NewManager(broker *intercept.Broker) *Manager {
mgr := &Manager{ mgr := &Manager{
broker: broker, broker: broker,
Notifs: make(chan PluginNotifMsg, 64), Notifs: make(chan PluginNotifMsg, 64),
Quit: make(chan string, 4), Quit: make(chan string, 4),
} }
if broker != nil { if broker != nil {
broker.SetOnNewEntry(mgr.RunOnHistoryEntry) broker.SetOnBeforeNewEntry(mgr.RunSyncOnHistoryEntry)
broker.SetOnNewEntry(mgr.RunAsyncOnHistoryEntry)
} }
return mgr return mgr
} }
@@ -107,27 +109,41 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua") p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
} }
// Defaults when not overridden by the Plugin table. if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
hookDefaults := map[string]bool{ p.Description = string(s)
"on_start": true, // always sync
"on_request": false, // async
"on_response": false, // async
"on_quit": true, // always sync
"on_history_entry": false, // always async
} }
for hookName, defaultSync := range hookDefaults {
// Plugin table entry overrides the default (except on_start/on_quit/on_history_entry which are fixed). if n, ok := pluginTable.RawGetString("priority").(lua.LNumber); ok {
if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" { p.Priority = int(n)
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { }
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue // Hooks configurable via the Plugin table (sync field).
} configurableHooks := map[string]bool{
"on_start": false, // async by default
"on_request": false,
"on_response": false,
"on_history_entry": false,
}
// Fixed-sync hooks: always sync, not configurable.
fixedSyncHooks := map[string]struct{}{
"on_config": {},
"on_quit": {},
}
for hookName, defaultSync := range configurableHooks {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue
} }
// Auto-detect: register the hook if the function exists as a global.
if p.L.GetGlobal(hookName) != lua.LNil { if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: defaultSync} p.hooks[hookName] = HookConfig{Sync: defaultSync}
} }
} }
for hookName := range fixedSyncHooks {
if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: true}
}
}
return p, nil return p, nil
} }
@@ -137,6 +153,7 @@ func (m *Manager) GetPlugins() []*Plugin {
defer m.mu.RUnlock() defer m.mu.RUnlock()
out := make([]*Plugin, len(m.plugins)) out := make([]*Plugin, len(m.plugins))
copy(out, m.plugins) copy(out, m.plugins)
sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority })
return out return out
} }
@@ -179,46 +196,62 @@ func (m *Manager) SaveConfig(name, configText string) {
found.mu.Lock() found.mu.Lock()
found.ConfigText = configText found.ConfigText = configText
enabled := found.Enabled enabled := found.Enabled
hc, hasOnStart := found.hooks["on_start"] _, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText) _ = m.db.SavePluginState(name, enabled, configText)
} }
if !hasOnStart { if !hasOnConfig {
return return
} }
// Re-run on_start so the plugin can re-parse the new config. // on_config is always sync.
if hc.Sync { found.mu.Lock()
found.mu.Lock() if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { log.Printf("plugin %s on_config (config reload): %v", name, err)
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
found.mu.Unlock()
} else {
go func() {
found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
found.mu.Unlock()
}()
} }
found.mu.Unlock()
} }
func (m *Manager) RunOnStart() { func (m *Manager) RunOnStart() {
// on_config runs first, always sync, for every enabled plugin that has it.
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_start"]; !ok { if _, ok := p.hooks["on_config"]; !ok {
continue continue
} }
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil { if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err) log.Printf("plugin %s on_config: %v", p.Name, err)
} }
p.mu.Unlock() p.mu.Unlock()
} }
// on_start runs after, sync or async depending on plugin config.
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_start"]
if !ok {
continue
}
if hc.Sync {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
}
p.mu.Unlock()
} else {
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
}
} }
func (m *Manager) RunOnQuit() { func (m *Manager) RunOnQuit() {
@@ -327,12 +360,37 @@ func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
} }
} }
func (m *Manager) RunOnHistoryEntry(e db.Entry) { // RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_history_entry"]; !ok { hc, ok := p.hooks["on_history_entry"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_history_entry", pushEntry(p.L, e))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
continue
}
if result == "skip" {
return false
}
}
return true
}
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_history_entry"]
if !ok || hc.Sync {
continue continue
} }
go func(p *Plugin) { go func(p *Plugin) {
+26 -14
View File
@@ -11,10 +11,12 @@ type HookConfig struct {
} }
type Plugin struct { type Plugin struct {
Name string Name string
FilePath string Description string
Enabled bool FilePath string
ConfigText string Enabled bool
ConfigText string
Priority int
L *lua.LState L *lua.LState
mu sync.Mutex mu sync.Mutex
@@ -35,30 +37,40 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
} }
type Info struct { type Info struct {
Name string Name string
FilePath string Description string
Enabled bool FilePath string
ConfigText string Enabled bool
Hooks map[string]HookConfig ConfigText string
Priority int
Hooks map[string]HookConfig
} }
func (p *Plugin) Info() Info { func (p *Plugin) Info() Info {
p.mu.Lock()
enabled := p.Enabled
configText := p.ConfigText
p.mu.Unlock()
hooks := make(map[string]HookConfig, len(p.hooks)) hooks := make(map[string]HookConfig, len(p.hooks))
for k, v := range p.hooks { for k, v := range p.hooks {
hooks[k] = v hooks[k] = v
} }
return Info{ return Info{
Name: p.Name, Name: p.Name,
FilePath: p.FilePath, Description: p.Description,
Enabled: p.Enabled, FilePath: p.FilePath,
ConfigText: p.ConfigText, Enabled: enabled,
Hooks: hooks, ConfigText: configText,
Priority: p.Priority,
Hooks: hooks,
} }
} }
type PluginNotifMsg struct { type PluginNotifMsg struct {
Title string Title string
Body string Body string
Kind string // "info", "success", "warning", "error"
} }
type PluginQuitMsg struct { type PluginQuitMsg struct {
+1
View File
@@ -108,6 +108,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
Addr: addr, Addr: addr,
StreamLargeBodies: 1024 * 1024 * 5, StreamLargeBodies: 1024 * 1024 * 5,
CaRootPath: caPath, CaRootPath: caPath,
Upstream: cfg.UpstreamProxy,
} }
p, err := goproxy.NewProxy(opts) p, err := goproxy.NewProxy(opts)
+2 -2
View File
@@ -36,8 +36,8 @@ func RenderWithTitle(border lipgloss.Style, title, content string, width, height
if fillW < 0 { if fillW < 0 {
fillW = 0 fillW = 0
} }
topLine := "╭" + label + strings.Repeat("─", fillW) + "╮" bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine = lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()).Render(topLine) topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
return lipgloss.JoinVertical(lipgloss.Left, topLine, box) return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
} }
+7 -2
View File
@@ -28,15 +28,20 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
ta.Prompt = "" ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0 ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles() ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle() ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle() ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text) ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg) ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ta.SetStyles(ts) ta.SetStyles(ts)
return ta return ta
} }
+6
View File
@@ -24,6 +24,7 @@ type Styles struct {
Panel lipgloss.Style Panel lipgloss.Style
PanelFocused lipgloss.Style PanelFocused lipgloss.Style
PanelEditing lipgloss.Style
PagerDotActive string PagerDotActive string
PagerDotInactive string PagerDotInactive string
@@ -43,6 +44,7 @@ func Init(cfg *config.Config) {
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
success := lipgloss.Color("#" + c.Base0B) // Green: success success := lipgloss.Color("#" + c.Base0B) // Green: success
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
S = &Styles{ S = &Styles{
Primary: primary, Primary: primary,
@@ -66,6 +68,10 @@ func Init(cfg *config.Config) {
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(primary), BorderForeground(primary),
PanelEditing: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(purple),
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
} }
+3 -7
View File
@@ -13,6 +13,7 @@ import (
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/plugins" "github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyUI "github.com/anotherhadi/spilltea/internal/ui/components/copy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
@@ -22,7 +23,6 @@ import (
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins" pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@@ -64,10 +64,10 @@ type Model struct {
replay replayUI.Model replay replayUI.Model
diff diffUI.Model diff diffUI.Model
docs docsUI.Model docs docsUI.Model
scope scopeUI.Model
pluginsPage pluginsUI.Model pluginsPage pluginsUI.Model
findingsPage findingsUI.Model findingsPage findingsUI.Model
copyAs copyasUI.Model copyAs copyasUI.Model
copy copyUI.Model
notifications notificationsUI.Model notifications notificationsUI.Model
} }
@@ -86,10 +86,10 @@ func New(broker *intercept.Broker, name, path string) Model {
replay: replayUI.New(), replay: replayUI.New(),
diff: diffUI.New(), diff: diffUI.New(),
docs: docsUI.New(), docs: docsUI.New(),
scope: scopeUI.New(name, path),
pluginsPage: pluginsUI.New(mgr), pluginsPage: pluginsUI.New(mgr),
findingsPage: findingsUI.New(), findingsPage: findingsUI.New(),
copyAs: copyasUI.New(), copyAs: copyasUI.New(),
copy: copyUI.New(),
notifications: notificationsUI.New(), notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
} }
@@ -101,10 +101,6 @@ func New(broker *intercept.Broker, name, path string) Model {
m.replay.SetDB(d) m.replay.SetDB(d)
m.findingsPage.SetDB(d) m.findingsPage.SetDB(d)
mgr.SetDB(d) mgr.SetDB(d)
if wl, bl, err := d.LoadScope(); err == nil {
broker.SetScope(wl, bl)
m.scope.SetScope(wl, bl)
}
} }
pluginsDir := config.ExpandPath(cfg.App.PluginsDir) pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
-15
View File
@@ -10,7 +10,6 @@ import (
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins" pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
) )
type page string type page string
@@ -20,7 +19,6 @@ const (
pageHistory page = "History" pageHistory page = "History"
pageReplay page = "Replay" pageReplay page = "Replay"
pageDiff page = "Diff" pageDiff page = "Diff"
pageScopes page = "Scopes"
pagePlugins page = "Plugins" pagePlugins page = "Plugins"
pageFindings page = "Findings" pageFindings page = "Findings"
pageDocs page = "Docs" pageDocs page = "Docs"
@@ -93,19 +91,6 @@ var pageRegistry = []pageEntry{
}, },
resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) }, resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) },
}, },
{
id: pageScopes,
icon: func() string { return icons.I.Scope },
render: func(m *Model) string { return m.scope.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.scope.Update(msg)
m.scope = updated.(scopeUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.scope.IsEditing() },
resize: func(m *Model, w, h int) { m.scope.SetSize(w, h) },
},
{ {
id: pagePlugins, id: pagePlugins,
icon: func() string { return icons.I.Plugin }, icon: func() string { return icons.I.Plugin },
+47 -12
View File
@@ -13,6 +13,7 @@ import (
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins" "github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyUI "github.com/anotherhadi/spilltea/internal/ui/components/copy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
@@ -20,7 +21,6 @@ import (
historyUI "github.com/anotherhadi/spilltea/internal/ui/history" historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -45,11 +45,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case plugins.PluginNotifMsg: case plugins.PluginNotifMsg:
cmd := plugins.WaitForNotif(m.pluginManager) cmd := plugins.WaitForNotif(m.pluginManager)
kind := notificationsUI.KindInfo
switch msg.Kind {
case "success":
kind = notificationsUI.KindSuccess
case "warning":
kind = notificationsUI.KindWarning
case "error":
kind = notificationsUI.KindError
}
notifCmd := func() tea.Msg { notifCmd := func() tea.Msg {
return notificationsUI.NotificationMsg{ return notificationsUI.NotificationMsg{
Title: msg.Title, Title: msg.Title,
Body: msg.Body, Body: msg.Body,
Kind: notificationsUI.KindInfo, Kind: kind,
} }
} }
return m, tea.Batch(cmd, notifCmd) return m, tea.Batch(cmd, notifCmd)
@@ -73,21 +82,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
if m.copy.IsOpen() {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
m.copy.SetSize(ws.Width, ws.Height)
m.resizeChildren()
return m, nil
}
var cmd tea.Cmd
m.copy, cmd = m.copy.Update(msg)
return m, cmd
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.resizeChildren() m.resizeChildren()
case scopeUI.ScopeChangedMsg:
m.broker.SetScope(msg.Whitelist, msg.Blacklist)
if m.database != nil {
if err := m.database.SaveScope(msg.Whitelist, msg.Blacklist); err != nil {
log.Printf("failed to persist scope: %v", err)
}
}
return m, nil
case proxyPkg.ErrMsg: 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)
@@ -162,7 +175,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.activeIsEditing() { if !m.activeIsEditing() {
switch { switch {
case key.Matches(msg, keys.Keys.Global.CopyRequest): case key.Matches(msg, keys.Keys.Global.CopyAs):
if m.page == pageDiff { if m.page == pageDiff {
if raw := m.diff.CurrentRaw(); raw != "" { if raw := m.diff.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height) m.copyAs.SetSize(m.width, m.height)
@@ -182,6 +195,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case key.Matches(msg, keys.Keys.Global.Copy):
var raw, scheme string
switch m.page {
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
}
if raw != "" {
m.copy.SetSize(m.width, m.height)
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme})
}
return m, nil
case key.Matches(msg, keys.Keys.Global.ToggleSidebar): case key.Matches(msg, keys.Keys.Global.ToggleSidebar):
m.cycleSidebarState() m.cycleSidebarState()
m.resizeChildren() m.resizeChildren()
+7
View File
@@ -22,6 +22,13 @@ func (m Model) View() tea.View {
return v return v
} }
if m.copy.IsOpen() {
v := tea.NewView(m.copy.View(normal))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
rendered := normal rendered := normal
if m.notifications.HasNotifications() { if m.notifications.HasNotifications() {
rendered = m.notifications.View(normal) rendered = m.notifications.View(normal)
+165
View File
@@ -0,0 +1,165 @@
package copy
import (
"encoding/base64"
"fmt"
"os"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
const popupInnerW = 40
func writeClipboard(text string) {
encoded := base64.StdEncoding.EncodeToString([]byte(text))
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
}
type OpenMsg struct {
RawRequest string
Scheme string
}
type copyItem struct {
id string
title string
desc string
}
func (c copyItem) Title() string { return c.title }
func (c copyItem) Description() string { return c.desc }
func (c copyItem) FilterValue() string { return c.title }
var allItems = []list.Item{
copyItem{"raw", "Raw", "full HTTP request"},
copyItem{"headers", "Headers", "request headers only"},
copyItem{"body", "Body", "request body only"},
copyItem{"url", "URL", "request URL"},
}
type Model struct {
open bool
list list.Model
rawRequest string
scheme string
width int
height int
}
func New() Model {
s := style.S
delegate := list.NewDefaultDelegate()
delegate.SetSpacing(0)
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(1)
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(1)
l := list.New(allItems, delegate, popupInnerW, 8)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.KeyMap.Quit.SetEnabled(false)
l.KeyMap.ForceQuit.SetEnabled(false)
l.KeyMap.ShowFullHelp.SetEnabled(false)
l.KeyMap.CloseFullHelp.SetEnabled(false)
return Model{list: l}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsOpen() bool { return m.open }
func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme
m.open = true
m.list.ResetFilter()
m.list.Select(0)
m.list.SetSize(popupInnerW, m.listHeight())
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.list.SetSize(popupInnerW, m.listHeight())
}
func (m Model) popupHeight() int {
h := 12
if m.height > 0 && m.height-4 < h {
h = m.height - 4
}
if h < 6 {
h = 6
}
return h
}
func (m Model) listHeight() int {
return style.PanelContentH(m.popupHeight()) - 1
}
func (m Model) extract(id string) string {
raw := m.rawRequest
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
switch id {
case "raw":
return raw
case "headers":
var sb strings.Builder
for _, l := range lines[1:] {
if l == "" {
break
}
sb.WriteString(l + "\n")
}
return strings.TrimRight(sb.String(), "\n")
case "body":
for i, l := range lines {
if l == "" && i > 0 {
return strings.TrimRight(strings.Join(lines[i+1:], "\n"), "\n")
}
}
return ""
case "url":
scheme := m.scheme
if scheme == "" {
scheme = "https"
}
var host, path string
if len(lines) > 0 {
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 2 {
path = parts[1]
}
}
for _, l := range lines[1:] {
if l == "" {
break
}
if kv := strings.SplitN(l, ": ", 2); len(kv) == 2 && strings.EqualFold(kv[0], "host") {
host = strings.TrimSpace(kv[1])
}
}
return scheme + "://" + host + path
}
return raw
}
+30
View File
@@ -0,0 +1,30 @@
package copy
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if kp, ok := msg.(tea.KeyPressMsg); ok {
switch {
case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(copyItem); ok {
writeClipboard(m.extract(item.id))
}
m.open = false
return m, nil
case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() {
break
}
m.open = false
return m, nil
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
+28
View File
@@ -0,0 +1,28 @@
package copy
import (
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
)
func (m *Model) View(background string) string {
s := style.S
hint := lipgloss.NewStyle().Foreground(s.Subtle).
Render(" enter: copy • /: filter • esc: cancel")
inner := lipgloss.JoinVertical(lipgloss.Left,
m.list.View(),
hint,
)
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary)
popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy", inner, popupInnerW+2, popupH)
return copyasUI.OverlayCenter(background, popup, m.width, m.height)
}
+2
View File
@@ -66,6 +66,8 @@ func (pr parsedRequest) fullURL() string {
func formatAs(id, raw, scheme string) string { func formatAs(id, raw, scheme string) string {
pr := parseRaw(raw, scheme) pr := parseRaw(raw, scheme)
switch id { switch id {
case "raw":
return raw
case "curl": case "curl":
return toCurl(pr) return toCurl(pr)
case "python": case "python":
+1
View File
@@ -36,6 +36,7 @@ func (f formatItem) Description() string { return f.desc }
func (f formatItem) FilterValue() string { return f.title } func (f formatItem) FilterValue() string { return f.title }
var allFormats = []list.Item{ var allFormats = []list.Item{
formatItem{"raw", "Raw", "raw HTTP request"},
formatItem{"curl", "cURL", "command line HTTP request"}, formatItem{"curl", "cURL", "command line HTTP request"},
formatItem{"python", "Python", "requests library"}, formatItem{"python", "Python", "requests library"},
formatItem{"go", "Go", "net/http package"}, formatItem{"go", "Go", "net/http package"},
+2 -2
View File
@@ -26,10 +26,10 @@ func (m *Model) View(background string) string {
popupH := m.popupHeight() popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH) popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH)
return overlayCenter(background, popup, m.width, m.height) return OverlayCenter(background, popup, m.width, m.height)
} }
func overlayCenter(bg, popup string, w, h int) string { func OverlayCenter(bg, popup string, w, h int) string {
s := style.S s := style.S
stripped := ansi.Strip(bg) stripped := ansi.Strip(bg)
+4 -4
View File
@@ -221,16 +221,16 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
for i > 0 || j > 0 { for i > 0 || j > 0 {
switch { switch {
case i > 0 && j > 0 && a[i-1] == b[j-1]: case i > 0 && j > 0 && a[i-1] == b[j-1]:
left = append(left, diffLine{text: hlA(i-1), kind: lineUnchanged}) left = append(left, diffLine{text: hlA(i - 1), kind: lineUnchanged})
right = append(right, diffLine{text: hlB(j-1), kind: lineUnchanged}) right = append(right, diffLine{text: hlB(j - 1), kind: lineUnchanged})
i-- i--
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), kind: lineAdded})
j-- j--
default: default:
left = append(left, diffLine{text: hlA(i-1), kind: lineRemoved}) left = append(left, diffLine{text: hlA(i - 1), kind: lineRemoved})
right = append(right, diffLine{kind: lineRemoved}) right = append(right, diffLine{kind: lineRemoved})
i-- i--
} }
-1
View File
@@ -19,7 +19,6 @@ var contentMarkdown = strings.Join([]string{
readDoc("proxy.md"), readDoc("proxy.md"),
readDoc("certificate.md"), readDoc("certificate.md"),
readDoc("history.md"), readDoc("history.md"),
readDoc("scopes.md"),
}, "\n") }, "\n")
type Model struct { type Model struct {
+9
View File
@@ -55,6 +55,15 @@ func (m Model) IsEditing() bool {
return m.searchKind != searchKindOff && !m.searchAccepted return m.searchKind != searchKindOff && !m.searchAccepted
} }
func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) CurrentScheme() string { return "https" }
// 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
// background refreshes re-run the active search rather than resetting it. // background refreshes re-run the active search rather than resetting it.
+13 -14
View File
@@ -14,7 +14,7 @@ import (
type panel int type panel int
const ( const (
panelRequests panel = iota panelRequests panel = iota
panelResponses panelResponses
) )
@@ -28,8 +28,8 @@ type Model struct {
responseQueue []*intercept.PendingResponse responseQueue []*intercept.PendingResponse
responseCursor int responseCursor int
editing bool editing bool
autoForward bool interceptEnabled bool
pendingEdits map[*intercept.PendingRequest]string pendingEdits map[*intercept.PendingRequest]string
pendingResponseEdits map[*intercept.PendingResponse]string pendingResponseEdits map[*intercept.PendingResponse]string
@@ -37,9 +37,9 @@ type Model struct {
responseViewport viewport.Model responseViewport viewport.Model
bodyViewport viewport.Model bodyViewport viewport.Model
textarea textarea.Model textarea textarea.Model
pager paginator.Model pager paginator.Model
responsePager paginator.Model responsePager paginator.Model
help help.Model help help.Model
width int width int
height int height int
@@ -59,13 +59,13 @@ func New(broker *intercept.Broker) Model {
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse) broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
return Model{ return Model{
broker: broker, broker: broker,
autoForward: cfg.Intercept.DefaultAutoForward, interceptEnabled: cfg.Intercept.DefaultInterceptEnabled,
captureResponse: cfg.Intercept.DefaultCaptureResponse, captureResponse: cfg.Intercept.DefaultCaptureResponse,
listViewport: lv, listViewport: lv,
responseViewport: rv, responseViewport: rv,
bodyViewport: bv, bodyViewport: bv,
textarea: ta, textarea: ta,
pager: p, pager: p,
responsePager: rp, responsePager: rp,
help: newHelp(), help: newHelp(),
@@ -115,4 +115,3 @@ func (m *Model) SetSize(w, h int) {
m.height = h m.height = h
m.recalcSizes() m.recalcSizes()
} }
+15 -5
View File
@@ -5,17 +5,27 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
"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) {
var cmds []tea.Cmd var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case intercept.RequestArrivedMsg: case intercept.RequestArrivedMsg:
if m.autoForward { if !m.interceptEnabled {
m.broker.Decide(msg.Req, intercept.Forward) m.broker.Decide(msg.Req, intercept.Forward)
break break
} }
@@ -152,9 +162,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
} }
} }
case key.Matches(msg, keys.Keys.Intercept.AutoForward): case key.Matches(msg, keys.Keys.Intercept.ToggleIntercept):
m.autoForward = !m.autoForward m.interceptEnabled = !m.interceptEnabled
if m.autoForward { if !m.interceptEnabled {
for len(m.queue) > 0 { for len(m.queue) > 0 {
m.applyAndDecide(intercept.Forward) m.applyAndDecide(intercept.Forward)
} }
+2 -2
View File
@@ -52,8 +52,8 @@ func (m *Model) renderListPanel(w, h int) string {
) )
title := icons.I.Request + "Requests" title := icons.I.Request + "Requests"
if m.autoForward { if !m.interceptEnabled {
title += " [auto forward]" title += " " + lipgloss.NewStyle().Foreground(style.S.Error).Render("[intercept off]")
} }
return style.RenderWithTitle(border, title, inner, w, h) return style.RenderWithTitle(border, title, inner, w, h)
} }
+64 -24
View File
@@ -1,7 +1,6 @@
package plugins package plugins
import ( import (
"os"
"strings" "strings"
"charm.land/bubbles/v2/help" "charm.land/bubbles/v2/help"
@@ -24,19 +23,20 @@ type Model struct {
filter string filter string
filtered []plugins.Info filtered []plugins.Info
listViewport viewport.Model listViewport viewport.Model
textarea textarea.Model detailViewport viewport.Model
filterInput textinput.Model textarea textarea.Model
filtering bool filterInput textinput.Model
pager paginator.Model filtering bool
help help.Model pager paginator.Model
help help.Model
width int width int
height int height int
} }
func New(mgr *plugins.Manager) Model { func New(mgr *plugins.Manager) Model {
ta := style.NewTextarea(false) ta := style.NewTextarea(true)
ta.Placeholder = "plugin configuration..." ta.Placeholder = "plugin configuration..."
ta.Blur() ta.Blur()
@@ -44,12 +44,13 @@ func New(mgr *plugins.Manager) Model {
fi.Prompt = "" fi.Prompt = ""
return Model{ return Model{
manager: mgr, manager: mgr,
listViewport: style.NewViewport(), listViewport: style.NewViewport(),
textarea: ta, detailViewport: style.NewViewport(),
filterInput: fi, textarea: ta,
pager: style.NewPaginator(), filterInput: fi,
help: style.NewHelp(), pager: style.NewPaginator(),
help: style.NewHelp(),
} }
} }
@@ -88,10 +89,27 @@ func (m *Model) recalcSizes() {
} }
m.filterInput.SetWidth(inner - 2) m.filterInput.SetWidth(inner - 2)
detailContentH := style.PanelContentH(detailH)
const headerH = 2
const configFixedH = 2 // blank line + label line
textareaH := max(3, detailContentH/3)
if textareaH > 12 {
textareaH = 12
}
configTotalH := 0
if m.hasConfig() {
configTotalH = configFixedH + textareaH
}
descVH := max(1, detailContentH-headerH-configTotalH)
m.detailViewport.SetWidth(inner)
m.detailViewport.SetHeight(descVH)
m.textarea.SetWidth(max(1, inner-2)) m.textarea.SetWidth(max(1, inner-2))
m.textarea.SetHeight(max(3, detailH-6)) m.textarea.SetHeight(max(3, textareaH))
m.refreshListViewport() m.refreshListViewport()
m.syncDetailViewport()
} }
// Refresh reloads the plugin list from the manager. // Refresh reloads the plugin list from the manager.
@@ -126,6 +144,7 @@ func (m *Model) applyFilter() {
} }
m.refreshListViewport() m.refreshListViewport()
m.syncTextarea() m.syncTextarea()
m.syncDetailViewport()
} }
func (m *Model) selected() (plugins.Info, bool) { func (m *Model) selected() (plugins.Info, bool) {
@@ -135,6 +154,15 @@ func (m *Model) selected() (plugins.Info, bool) {
return m.filtered[m.cursor], true return m.filtered[m.cursor], true
} }
func (m *Model) hasConfig() bool {
info, ok := m.selected()
if !ok {
return false
}
_, has := info.Hooks["on_config"]
return has
}
func (m *Model) syncTextarea() { func (m *Model) syncTextarea() {
if m.editing { if m.editing {
return return
@@ -147,6 +175,16 @@ func (m *Model) syncTextarea() {
m.textarea.SetValue(info.ConfigText) m.textarea.SetValue(info.ConfigText)
} }
func (m *Model) syncDetailViewport() {
info, ok := m.selected()
if !ok || info.Description == "" {
m.detailViewport.SetContent("")
return
}
desc := renderPluginDescription(info.Description, m.width-6)
m.detailViewport.SetContent(desc)
}
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
@@ -155,16 +193,11 @@ func (m *Model) refreshListViewport() {
m.listViewport.SetContent(m.renderList()) m.listViewport.SetContent(m.renderList())
} }
func shortenPath(p string) string { type pluginsKeyMap struct {
home := os.Getenv("HOME") editing bool
if home != "" && strings.HasPrefix(p, home) { hasConfig bool
return "~" + p[len(home):]
}
return p
} }
type pluginsKeyMap struct{ editing bool }
func (k pluginsKeyMap) ShortHelp() []key.Binding { func (k pluginsKeyMap) ShortHelp() []key.Binding {
pk := keys.Keys.Plugins pk := keys.Keys.Plugins
g := keys.Keys.Global g := keys.Keys.Global
@@ -172,7 +205,14 @@ func (k pluginsKeyMap) ShortHelp() []key.Binding {
esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit")) esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit"))
return []key.Binding{esc} return []key.Binding{esc}
} }
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter} scrollHint := key.NewBinding(
key.WithKeys(g.ScrollUp.Keys()...),
key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"),
)
if k.hasConfig {
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter, scrollHint}
}
return []key.Binding{pk.Toggle, pk.Filter, scrollHint}
} }
func (k pluginsKeyMap) FullHelp() [][]key.Binding { func (k pluginsKeyMap) FullHelp() [][]key.Binding {
+39 -3
View File
@@ -21,7 +21,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
}
}
case tea.KeyPressMsg: case tea.KeyPressMsg:
pk := keys.Keys.Plugins pk := keys.Keys.Plugins
g := keys.Keys.Global g := keys.Keys.Global
@@ -90,15 +110,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
m.refreshListViewport() m.recalcSizes()
m.syncTextarea() m.syncTextarea()
m.detailViewport.GotoTop()
} }
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
if m.cursor < len(m.filtered)-1 { if m.cursor < len(m.filtered)-1 {
m.cursor++ m.cursor++
m.refreshListViewport() m.recalcSizes()
m.syncTextarea() m.syncTextarea()
m.detailViewport.GotoTop()
} }
case key.Matches(msg, pk.Toggle): case key.Matches(msg, pk.Toggle):
@@ -115,11 +137,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, pk.EditConfig): case key.Matches(msg, pk.EditConfig):
if _, ok := m.selected(); ok { if _, ok := m.selected(); ok && m.hasConfig() {
m.editing = true m.editing = true
m.textarea.Focus() m.textarea.Focus()
} }
case key.Matches(msg, g.ScrollUp):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
+58 -19
View File
@@ -1,10 +1,13 @@
package plugins package plugins
import ( import (
"path/filepath"
"strings" "strings"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
@@ -12,7 +15,7 @@ import (
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(style.S.Faint.Render("\nno plugins loaded")) return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (._.)~*.'\n no plugins loaded")))
} }
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4) listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
@@ -27,23 +30,29 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
dots := s.Faint.Render(m.pager.View()) dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
) )
return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h) return style.RenderWithTitle(panelStyle, icons.I.Plugin+"Plugins", inner, w, h)
} }
func (m *Model) renderDetailPanel(h int) string { func (m *Model) renderDetailPanel(h int) string {
s := style.S s := style.S
panelStyle := s.Panel
if m.editing {
panelStyle = s.PanelFocused
}
info, ok := m.selected() info, ok := m.selected()
if !ok { if !ok {
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h) return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", "", m.width, h)
} }
var sb strings.Builder
statusSt := lipgloss.NewStyle().Foreground(s.Error) statusSt := lipgloss.NewStyle().Foreground(s.Error)
if info.Enabled { if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success) statusSt = lipgloss.NewStyle().Foreground(s.Success)
@@ -52,22 +61,52 @@ func (m *Model) renderDetailPanel(h int) string {
if info.Enabled { if info.Enabled {
status = "enabled" status = "enabled"
} }
sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n")
sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n")
if m.editing { pad := lipgloss.NewStyle().Padding(0, 1)
escKey := keys.Keys.Global.Escape.Help().Key
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):")) header := pad.Render(
} else { s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
editKey := keys.Keys.Plugins.EditConfig.Help().Key s.Faint.Render(filepath.Base(info.FilePath)),
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):")) )
parts := []string{header, m.detailViewport.View()}
if m.hasConfig() {
var configLabel string
if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key
configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
} else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key
configLabel = pad.Render(s.Faint.Render("config (" + editKey + " to edit):"))
}
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
} }
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()), return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", inner, m.width, h)
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()), }
func renderPluginDescription(desc string, width int) string {
desc = strings.TrimSpace(desc)
lines := strings.Split(desc, "\n")
for i, l := range lines {
lines[i] = strings.TrimLeft(l, " \t")
}
desc = strings.Join(lines, "\n")
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width),
) )
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h) if err != nil {
return desc
}
out, err := r.Render(desc)
if err != nil {
return desc
}
return strings.Trim(out, "\n")
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
@@ -81,9 +120,9 @@ func (m *Model) renderStatusBar() string {
escKey := keys.Keys.Global.Escape.Help().Key escKey := keys.Keys.Global.Escape.Help().Key
accent := lipgloss.NewStyle().Foreground(s.Primary) accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear")) filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))) return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()})))
} }
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})) return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()}))
} }
func (m *Model) renderList() string { func (m *Model) renderList() string {
+17
View File
@@ -68,6 +68,23 @@ 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) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https"
}
if s := m.entries[m.cursor].Scheme; s != "" {
return s
}
return "https"
}
func (m *Model) SetDB(d *db.DB) { func (m *Model) SetDB(d *db.DB) {
m.database = d m.database = d
if d == nil { if d == nil {
+16 -3
View File
@@ -12,6 +12,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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"
@@ -33,6 +34,18 @@ func sendCmd(entry Entry, index int) tea.Cmd {
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case SendToReplayMsg: case SendToReplayMsg:
entry := entryFromMsg(msg) entry := entryFromMsg(msg)
@@ -104,7 +117,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateNormalMode(msg) return m.updateNormalMode(msg)
} }
return m, nil return m, tea.Batch(cmds...)
} }
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
@@ -247,11 +260,11 @@ func (m *Model) refreshBody() {
m.requestViewport.SetXOffset(0) m.requestViewport.SetXOffset(0)
if e.Sending { if e.Sending {
m.responseViewport.SetContent(style.HighlightHTTP("Sending...")) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (ノ◕ヮ◕)ノ*:・゚\n 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("") 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.SetYOffset(0) m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0) m.responseViewport.SetXOffset(0)
+5 -1
View File
@@ -33,12 +33,16 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
dots := s.Faint.Render(m.pager.View()) dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
) )
return style.RenderWithTitle(s.PanelFocused, icons.I.Replay+"Replay", inner, w, h) return style.RenderWithTitle(panelStyle, icons.I.Replay+"Replay", inner, w, h)
} }
func (m *Model) renderRequestPanel(w, h int) string { func (m *Model) renderRequestPanel(w, h int) string {
-150
View File
@@ -1,150 +0,0 @@
package scope
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
const (
fieldNone = -1
fieldWhitelist = 0
fieldBlacklist = 1
)
const (
minTaH = 3
maxTaH = 12
fixedH = 8 // (blank + label + desc + blank) x2
)
type ScopeChangedMsg struct {
Whitelist []string
Blacklist []string
}
type Model struct {
focusIdx int
wlTextarea textarea.Model
blTextarea textarea.Model
innerH int
width int
height int
help help.Model
}
func New(name, path string) Model {
wl := style.NewTextarea(true)
wl.Placeholder = "one pattern per line..."
bl := style.NewTextarea(true)
bl.Placeholder = "one pattern per line..."
bl.Blur()
return Model{
focusIdx: fieldNone,
wlTextarea: wl,
blTextarea: bl,
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetScope(whitelist, blacklist []string) {
m.wlTextarea.SetValue(strings.Join(whitelist, "\n"))
m.blTextarea.SetValue(strings.Join(blacklist, "\n"))
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.syncLayout()
}
func (m *Model) syncLayout() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
m.innerH = max(1, style.PanelContentH(panelH))
taH := (m.innerH - fixedH) / 2
if taH < minTaH {
taH = minTaH
}
if taH > maxTaH {
taH = maxTaH
}
// width - 2 (panel border) - 1 (leading space in view) - 3 (right margin + cursor)
taW := max(1, m.width-6)
m.wlTextarea.SetWidth(taW)
m.wlTextarea.SetHeight(taH)
m.blTextarea.SetWidth(taW)
m.blTextarea.SetHeight(taH)
}
func (m Model) IsEditing() bool {
return m.focusIdx == fieldWhitelist || m.focusIdx == fieldBlacklist
}
func (m *Model) scopeChangedCmd() tea.Cmd {
wl := parseLines(m.wlTextarea.Value())
bl := parseLines(m.blTextarea.Value())
return func() tea.Msg {
return ScopeChangedMsg{Whitelist: wl, Blacklist: bl}
}
}
func parseLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
out = append(out, t)
}
}
return out
}
func (m Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(
m.help.View(formKeyMap{focusIdx: m.focusIdx}),
)
}
type formKeyMap struct {
focusIdx int
}
func (k formKeyMap) ShortHelp() []key.Binding {
cycle := keys.Keys.Global.CycleFocus
hlp := keys.Keys.Global.Help
switch k.focusIdx {
case fieldWhitelist, fieldBlacklist:
esc := keys.Keys.Global.Escape
escBinding := key.NewBinding(key.WithKeys(esc.Keys()...), key.WithHelp(esc.Help().Key, "unfocus"))
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")),
escBinding,
cycle,
}
}
return []key.Binding{cycle, hlp}
}
func (k formKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
-70
View File
@@ -1,70 +0,0 @@
package scope
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
kp, isKey := msg.(tea.KeyPressMsg)
if !isKey {
return m, nil
}
if key.Matches(kp, keys.Keys.Global.CycleFocus) {
return m.cycleFocus()
}
if key.Matches(kp, keys.Keys.Global.Help) && !m.IsEditing() {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
switch m.focusIdx {
case fieldWhitelist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.wlTextarea, cmd = m.wlTextarea.Update(kp)
return m, cmd
case fieldBlacklist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.blTextarea, cmd = m.blTextarea.Update(kp)
return m, cmd
}
return m, nil
}
func (m Model) blurAll() (tea.Model, tea.Cmd) {
m.wlTextarea.Blur()
m.blTextarea.Blur()
m.focusIdx = fieldNone
m.syncLayout()
return m, m.scopeChangedCmd()
}
func (m Model) cycleFocus() (tea.Model, tea.Cmd) {
scopeCmd := m.scopeChangedCmd()
var focusCmd tea.Cmd
switch m.focusIdx {
case fieldNone, fieldBlacklist:
m.blTextarea.Blur()
m.focusIdx = fieldWhitelist
focusCmd = m.wlTextarea.Focus()
case fieldWhitelist:
m.wlTextarea.Blur()
m.focusIdx = fieldBlacklist
focusCmd = m.blTextarea.Focus()
}
m.syncLayout()
return m, tea.Batch(focusCmd, scopeCmd)
}
-84
View File
@@ -1,84 +0,0 @@
package scope
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("")
}
s := style.S
statusBar := m.renderStatusBar()
statusH := strings.Count(statusBar, "\n") + 1
panelH := m.height - statusH
innerH := max(1, style.PanelContentH(panelH))
taH := (innerH - fixedH) / 2
if taH < minTaH {
taH = minTaH
}
if taH > maxTaH {
taH = maxTaH
}
var lines []string
add := func(l string) { lines = append(lines, l) }
add("")
add(fieldLabel("Whitelist", m.focusIdx == fieldWhitelist))
add(" " + s.Faint.Render("If non-empty, only matching requests are intercepted."))
add("")
wlContentLines := strings.Count(m.wlTextarea.Value(), "\n") + 1
for _, l := range taLines(m.wlTextarea.View(), taH, wlContentLines) {
add(" " + l)
}
add("")
add(fieldLabel("Blacklist", m.focusIdx == fieldBlacklist))
add(" " + s.Faint.Render("Matching requests are always excluded from history."))
add("")
blContentLines := strings.Count(m.blTextarea.Value(), "\n") + 1
for _, l := range taLines(m.blTextarea.View(), taH, blContentLines) {
add(" " + l)
}
for len(lines) < innerH {
lines = append(lines, "")
}
content := strings.Join(lines[:innerH], "\n")
panel := style.RenderWithTitle(s.PanelFocused, icons.I.Scope+"Scopes", content, m.width, panelH)
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, panel, statusBar))
}
func fieldLabel(name string, focused bool) string {
s := style.S
c := s.MutedFg
if focused {
c = s.Primary
}
return " " + lipgloss.NewStyle().Foreground(c).Bold(focused).Render(name)
}
func taLines(view string, h int, contentLines int) []string {
raw := strings.Split(strings.TrimRight(view, "\n"), "\n")
tilde := style.S.Faint.Render("~")
for len(raw) < h {
raw = append(raw, tilde)
}
if len(raw) > h {
raw = raw[:h]
}
for i := contentLines; i < len(raw); i++ {
raw[i] = tilde
}
return raw
}
+45
View File
@@ -0,0 +1,45 @@
package spilltea
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
)
//go:embed plugins/*.lua
var PluginsFS embed.FS
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
// files that already exist. Returns the number of files written.
func InstallDefaultPlugins(dir string) (int, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return 0, fmt.Errorf("create plugins dir: %w", err)
}
entries, err := fs.ReadDir(PluginsFS, "plugins")
if err != nil {
return 0, err
}
written := 0
for _, e := range entries {
if e.IsDir() {
continue
}
dst := filepath.Join(dir, e.Name())
if _, err := os.Stat(dst); err == nil {
continue
}
data, err := PluginsFS.ReadFile("plugins/" + e.Name())
if err != nil {
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return written, fmt.Errorf("write %s: %w", dst, err)
}
written++
}
return written, nil
}
@@ -1,26 +1,28 @@
-- Inject a custom header into every request.
-- Config format (one per line): Header-Name: value
Plugin = { Plugin = {
name = "Inject Header", name = "Inject Header",
on_request = { sync = true }, description = [[
Inject custom headers into every intercepted request.
**Config**:
- one 'Header-Name: value' per line.
]],
on_request = { sync = true },
} }
local headers = {} local headers = {}
function on_start(config_text) function on_config(config_text)
headers = {}
for line in config_text:gmatch("[^\n]+") do for line in config_text:gmatch("[^\n]+") do
local name, value = line:match("^([^:]+):%s*(.+)$") local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then if name and value then
table.insert(headers, { name = name, value = value }) table.insert(headers, { name = name, value = value })
end end
end end
log("loaded " .. #headers .. " header(s)")
end end
function on_request(req) function on_request(req)
for _, h in ipairs(headers) do for _, h in ipairs(headers) do
req:set_header(h.name, h.value) req:set_header(h.name, h.value)
end end
return "forward"
end end
+75
View File
@@ -0,0 +1,75 @@
Plugin = {
name = "IP Filter (Whitelist/Blacklist)",
description = [[
Checks that the proxy's outbound IP is in an allowed list on startup.
**Config**:
- one IP per line
- prefix with `!` for a blacklist entry (blocked)
- prefix with `#` to comment it out (ignored)
- if no IPs are configured, the check is skipped
]],
on_start = { sync = false },
}
local whitelist = {}
local blacklist = {}
function on_config(config_text)
whitelist = {}
blacklist = {}
for line in config_text:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(blacklist, ip)
end
else
table.insert(whitelist, trimmed)
end
end
end
end
function on_start()
if #whitelist == 0 and #blacklist == 0 then
return
end
-- Fetch the current outbound IP via a public API.
local ok, result = pcall(function()
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
if not handle then return nil end
local ip = handle:read("*a")
handle:close()
return ip and ip:match("^%s*(.-)%s*$") or nil
end)
if not ok or not result or result == "" then
log("could not determine outbound IP, skipping check")
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
return
end
for _, ip in ipairs(blacklist) do
if result == ip then
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
return
end
end
if #whitelist == 0 then
return
end
for _, ip in ipairs(whitelist) do
if result == ip then
return
end
end
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
end
+78
View File
@@ -0,0 +1,78 @@
Plugin = {
name = "Scopes",
description = [[
Auto-forward requests and exclude them from history based on patterns.
**Config**:
- `pattern` - whitelist: only intercept matching requests
- `!pattern` - blacklist: don't intercept matching requests and exclude from history
- lines starting with `#` are comments
Example (ignore static assets):
```
!%.css$
!%.js$
!%.png$
```
Example (focus on mytarget.com, skip everything else):
```
mytarget%.com/
```
Example (intercept mytarget.com except its static assets):
```
mytarget%.com/
!%.css$
!%.js$
!%.png$
```
]],
priority = 100,
on_request = { sync = true },
on_response = { sync = true },
on_history_entry = { sync = true },
}
local whitelist = {}
local blacklist = {}
function on_config(config_text)
whitelist = {}
blacklist = {}
for line in config_text:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
if trimmed:sub(1, 1) == "!" then
table.insert(blacklist, trimmed:sub(2))
else
table.insert(whitelist, trimmed)
end
end
end
end
local function should_skip(url)
for _, pattern in ipairs(blacklist) do
if url:match(pattern) then return true end
end
if #whitelist > 0 then
for _, pattern in ipairs(whitelist) do
if url:match(pattern) then return false end
end
return true
end
return false
end
function on_request(req)
if should_skip(req.url) then return "forward" end
end
function on_response(req)
if should_skip(req.url) then return "forward" end
end
function on_history_entry(entry)
if should_skip(entry.host .. entry.path) then return "skip" end
end