mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Compare commits
20 Commits
v0.0.1
..
6aa377acd8
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aa377acd8 | |||
| 2f4765bf37 | |||
| fac335a16e | |||
| 407ca13a33 | |||
| 9ab7f12bf4 | |||
| 7d4f32549e | |||
| bed122fc99 | |||
| 967aab8363 | |||
| a414a51168 | |||
| c7392474b7 | |||
| de254b4e52 | |||
| 47d2cf6845 | |||
| 26994a3a37 | |||
| 4eb9dd53f5 | |||
| dbea0ab0f2 | |||
| 4caecaeec4 | |||
| a6bd5c1071 | |||
| 7879720d07 | |||
| 329216c082 | |||
| 1c66837504 |
@@ -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
|
||||||
@@ -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 |
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -6,6 +6,9 @@ before:
|
|||||||
|
|
||||||
builds:
|
builds:
|
||||||
- binary: spilltea
|
- binary: spilltea
|
||||||
|
main: ./cmd/spilltea
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
goarch:
|
goarch:
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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--
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user