mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Compare commits
40 Commits
v0.0.2
...
ed59923b7d
| Author | SHA1 | Date | |
|---|---|---|---|
| ed59923b7d | |||
| aa7b639f82 | |||
| 27e0c418e9 | |||
| 08757a5d1d | |||
| ffee0978e6 | |||
| 4f45a7c061 | |||
| 0fafa52c65 | |||
| 366bb682d2 | |||
| 9fe0c74150 | |||
| 3b6b58ac2b | |||
| 789a513469 | |||
| 615093bd8b | |||
| 7e1b7d3b5a | |||
| e3e89582c1 | |||
| 2705c2882d | |||
| 6ea692754a | |||
| 85c2806604 | |||
| d451965fa0 | |||
| d82a220e91 | |||
| 1ac5eb26e8 | |||
| 969febb14c | |||
| 6aa377acd8 | |||
| 2f4765bf37 | |||
| fac335a16e | |||
| 407ca13a33 | |||
| 9ab7f12bf4 | |||
| 7d4f32549e | |||
| bed122fc99 | |||
| 967aab8363 | |||
| a414a51168 | |||
| c7392474b7 | |||
| de254b4e52 | |||
| 47d2cf6845 | |||
| 26994a3a37 | |||
| 4eb9dd53f5 | |||
| dbea0ab0f2 | |||
| 4caecaeec4 | |||
| a6bd5c1071 | |||
| 7879720d07 | |||
| 329216c082 |
@@ -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
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
# Plugins
|
|
||||||
|
|
||||||
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
|
|
||||||
|
|
||||||
## Where to place plugins
|
|
||||||
|
|
||||||
Put `.lua` files in the directory configured by `plugins_dir` in your config file (default: `~/.config/spilltea/plugins`).
|
|
||||||
|
|
||||||
Each file is loaded as a separate plugin at startup. The plugin list is shown on the **Plugins** page.
|
|
||||||
|
|
||||||
## Plugin structure
|
|
||||||
|
|
||||||
Every plugin must declare a `Plugin` table and implement the hooks it wants to use.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
Plugin = {
|
|
||||||
name = "My Plugin",
|
|
||||||
|
|
||||||
-- Declare which hooks you use and whether they are synchronous.
|
|
||||||
on_start = { sync = true },
|
|
||||||
on_request = { sync = true },
|
|
||||||
on_response = { sync = false },
|
|
||||||
on_history_entry = {},
|
|
||||||
on_quit = {},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook reference
|
|
||||||
|
|
||||||
| Hook | When called | Sync/async | Return value |
|
|
||||||
| ------------------------- | --------------------------- | ------------ | ------------------- |
|
|
||||||
| `on_start(config_text)` | Once at startup | always sync | ignored |
|
|
||||||
| `on_quit()` | When the app exits | always sync | ignored |
|
|
||||||
| `on_request(req)` | Every request | declared | `"drop"`, `"forward"`, or `nil` (sync only) |
|
|
||||||
| `on_response(req, res)` | Every response | declared | `"drop"`, `"forward"`, or `nil` (sync only) |
|
|
||||||
| `on_history_entry(entry)` | After a flow is saved to DB | always async | ignored |
|
|
||||||
|
|
||||||
## Request and response objects
|
|
||||||
|
|
||||||
### `req` (request)
|
|
||||||
|
|
||||||
| Field / method | Type | Description |
|
|
||||||
| ----------------------------- | ------ | ----------------------------------- |
|
|
||||||
| `req.method` | string | HTTP method |
|
|
||||||
| `req.url` | string | Full URL |
|
|
||||||
| `req.host` | string | Host |
|
|
||||||
| `req.path` | string | Path |
|
|
||||||
| `req.headers["Name"]` | string | Request header value |
|
|
||||||
| `req:get_body()` | string | Raw request body (loaded on demand) |
|
|
||||||
| `req:set_header(name, value)` | - | Set a request header |
|
|
||||||
| `req:set_body(body)` | - | Replace the request body |
|
|
||||||
|
|
||||||
### `res` (response)
|
|
||||||
|
|
||||||
| Field / method | Type | Description |
|
|
||||||
| ----------------------------- | ------ | ------------------------- |
|
|
||||||
| `res.status_code` | number | HTTP status code |
|
|
||||||
| `res.headers["Name"]` | string | Response header value |
|
|
||||||
| `res:get_body()` | string | Raw response body |
|
|
||||||
| `res:set_header(name, value)` | - | Set a response header |
|
|
||||||
| `res:set_body(body)` | - | Replace the response body |
|
|
||||||
|
|
||||||
### `entry` (history entry)
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
| -------------------- | ---------------------------- |
|
|
||||||
| `entry.id` | number |
|
|
||||||
| `entry.method` | string |
|
|
||||||
| `entry.host` | string |
|
|
||||||
| `entry.path` | string |
|
|
||||||
| `entry.status_code` | number |
|
|
||||||
| `entry.timestamp` | string (YYYY-MM-DD HH:MM:SS) |
|
|
||||||
| `entry.request_raw` | string |
|
|
||||||
| `entry.response_raw` | string |
|
|
||||||
|
|
||||||
## Utility functions
|
|
||||||
|
|
||||||
```lua
|
|
||||||
-- Log a message to logs.log (prefixed with the plugin name)
|
|
||||||
log("message")
|
|
||||||
|
|
||||||
-- Send a notification bubble in the TUI
|
|
||||||
notif("Title", "Body text")
|
|
||||||
|
|
||||||
-- Create a finding (shown on the Findings page, persisted in DB)
|
|
||||||
create_finding({
|
|
||||||
title = "API Key Found",
|
|
||||||
description = "Markdown description of the finding...",
|
|
||||||
key = "stable-unique-id", -- used for deduplication; defaults to title
|
|
||||||
severity = "high", -- info | low | medium | high | critical
|
|
||||||
})
|
|
||||||
|
|
||||||
-- Check if a URL matches the current scope (whitelist/blacklist)
|
|
||||||
local ok = is_in_scope("https://example.com/api/v1")
|
|
||||||
|
|
||||||
-- Quit the app (useful for startup checks that fail)
|
|
||||||
quit("reason message")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Finding deduplication
|
|
||||||
|
|
||||||
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
|
|
||||||
|
|
||||||
## 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.).
|
|
||||||
|
|
||||||
## 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 = false`** (or omitted for supported hooks): the hook runs in a background goroutine. Return values are ignored. Use this for analysis and findings.
|
|
||||||
|
|
||||||
### Return values for `on_request` and `on_response` (sync only)
|
|
||||||
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
The `sync` declaration is only meaningful for `on_request` and `on_response`. The other hooks have fixed behaviour:
|
|
||||||
|
|
||||||
- `on_start` is **always synchronous**: plugins are initialised one by one before the first request is accepted.
|
|
||||||
- `on_quit` is **always synchronous**: the app waits for all `on_quit` hooks before exiting.
|
|
||||||
- `on_history_entry` is **always asynchronous**.
|
|
||||||
|
|
||||||
> A sync `on_request` or `on_response` 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
|
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CURRENT_HASH=$(grep -oP '(?<=vendorHash = ")[^"]+' flake.nix)
|
||||||
|
|
||||||
|
go mod vendor
|
||||||
|
|
||||||
|
COMPUTED_HASH=$(nix hash path vendor/)
|
||||||
|
|
||||||
|
rm -rf vendor/
|
||||||
|
|
||||||
|
if [ "$CURRENT_HASH" = "$COMPUTED_HASH" ]; then
|
||||||
|
echo "vendorHash is up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Updating vendorHash in flake.nix..."
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
with open('flake.nix', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
content = content.replace('$CURRENT_HASH', '$COMPUTED_HASH')
|
||||||
|
with open('flake.nix', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
"
|
||||||
|
echo " Old: $CURRENT_HASH"
|
||||||
|
echo " New: $COMPUTED_HASH"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PATTERN = re.compile(r"<!-- exec: (.+?) -->.*?<!-- endexec -->", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def replace(match):
|
||||||
|
cmd = match.group(1).strip()
|
||||||
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||||
|
output = result.stdout
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[inject-exec] command failed ({result.returncode}): {cmd}", file=sys.stderr)
|
||||||
|
print(result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
output = re.sub(r"<!-- exec: .+? -->\n?|<!-- endexec -->\n?", "", output)
|
||||||
|
if output and not output.endswith("\n"):
|
||||||
|
output += "\n"
|
||||||
|
return f"<!-- exec: {cmd} -->\n{output}<!-- endexec -->"
|
||||||
|
|
||||||
|
|
||||||
|
def process(path):
|
||||||
|
content = Path(path).read_text()
|
||||||
|
new_content = PATTERN.sub(replace, content)
|
||||||
|
if new_content != content:
|
||||||
|
Path(path).write_text(new_content)
|
||||||
|
print(f"[inject-exec] updated {path}")
|
||||||
|
|
||||||
|
|
||||||
|
for p in sys.argv[1:]:
|
||||||
|
process(p)
|
||||||
@@ -14,24 +14,92 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
|
[](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
|
||||||
|
|
||||||
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||||
|
|
||||||
|
- [What is Spilltea?](#what-is-spilltea)
|
||||||
|
- [Legal Disclaimer](#legal-disclaimer)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Project Management](#project-management)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [CLI Flags](#cli-flags)
|
||||||
|
- [Plugin System](#plugin-system)
|
||||||
|
- [Deployment](#deployment)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
|
||||||
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
|
||||||
## What is Spilltea?
|
## What is Spilltea?
|
||||||
|
|
||||||
Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between your browser and the internet, letting you inspect, modify, and replay traffic without ever leaving your terminal.
|
Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between your browser and the internet, letting you inspect, modify, and replay traffic without ever leaving your terminal.
|
||||||
|
|
||||||
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" />
|
||||||
|
|
||||||
|
<!-- exec: cat ./docs/legal-disclaimer.md -->
|
||||||
|
## Legal Disclaimer
|
||||||
|
|
||||||
|
**This tool is provided for educational purposes and authorized security testing only.**
|
||||||
|
|
||||||
|
Use Spilltea only on systems and networks you own or have explicit written permission to test. Intercepting network traffic without authorization may violate local laws (such as the Computer Fraud and Abuse Act, GDPR, or equivalent legislation in your jurisdiction).
|
||||||
|
|
||||||
|
The author(s) and contributors are not responsible for any misuse, damage, or legal consequences resulting from the use of this software. By using Spilltea, you agree that you are solely responsible for ensuring your usage is lawful and authorized.
|
||||||
|
<!-- endexec -->
|
||||||
|
|
||||||
## 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)
|
||||||
|
- **Vim-like Navigation**: The entire interface is keyboard-driven with Vim-inspired shortcuts. Use `h/j/k/l` to move, `gg`/`G` to jump to the top/bottom, `/` to search, `q` to close panels, and more. All keybindings are fully customizable via the config file.
|
||||||
- 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>
|
||||||
|
|
||||||
|
<!-- exec: cat ./docs/basics.md -->
|
||||||
## 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.
|
||||||
@@ -42,16 +110,33 @@ On startup, you choose:
|
|||||||
- **Existing project**: pick from a list of previous projects
|
- **Existing project**: pick from a list of previous projects
|
||||||
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
||||||
|
|
||||||
## 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.
|
|
||||||
For a full reference and examples, see the [plugin documentation](./.github/docs/plugins.md).
|
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: spilltea [flags]
|
||||||
|
|
||||||
|
--add-default-config copy the default config file to the config path and exit
|
||||||
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
|
-c, --config string path to config file
|
||||||
|
--host string proxy host (overrides config)
|
||||||
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
|
-p, --port int proxy port (overrides config)
|
||||||
|
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||||
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
|
-v, --version print version
|
||||||
|
```
|
||||||
|
<!-- endexec -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
For a full reference and examples, see the [plugin documentation](./docs/plugins.md) or [plugin examples](./plugins/).
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -23,11 +24,20 @@ 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")
|
||||||
|
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
|
||||||
flagHost = flag.String("host", "", "proxy host (overrides config)")
|
flagHost = flag.String("host", "", "proxy host (overrides config)")
|
||||||
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
|
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
|
||||||
|
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")
|
flagVersion = flag.BoolP("version", "v", false, "print version")
|
||||||
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
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")
|
||||||
|
flagAddDefaultConfig = flag.Bool("add-default-config", false, "copy the default config file to the config path and exit")
|
||||||
)
|
)
|
||||||
|
flag.CommandLine.SetOutput(os.Stdout)
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Println("Usage: spilltea [flags]\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *flagVersion {
|
if *flagVersion {
|
||||||
@@ -35,6 +45,41 @@ 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 *flagAddDefaultConfig {
|
||||||
|
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
|
||||||
|
if *flagConfig != "" {
|
||||||
|
cfgPath = *flagConfig
|
||||||
|
}
|
||||||
|
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "add-default-config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("default config written to %s\n", cfgPath)
|
||||||
|
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 +96,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
config.Global.Version = version
|
config.Global.Version = version
|
||||||
|
|
||||||
|
if *flagPluginsDir != "" {
|
||||||
|
config.Global.App.PluginsDir = *flagPluginsDir
|
||||||
|
}
|
||||||
if *flagHost != "" {
|
if *flagHost != "" {
|
||||||
config.Global.App.Host = *flagHost
|
config.Global.App.Host = *flagHost
|
||||||
}
|
}
|
||||||
if *flagPort != 0 {
|
if *flagPort != 0 {
|
||||||
config.Global.App.Port = *flagPort
|
config.Global.App.Port = *flagPort
|
||||||
}
|
}
|
||||||
|
if *flagUpstreamProxy != "" {
|
||||||
|
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
||||||
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
|
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
|
||||||
// Check if the proxy port is available before starting the UI.
|
// Check if the proxy port is available before starting the UI.
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ package spilltea
|
|||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:embed .github/docs
|
//go:embed docs
|
||||||
var DocsFS embed.FS
|
var DocsFS embed.FS
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## 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.
|
||||||
|
|
||||||
|
On startup, you choose:
|
||||||
|
|
||||||
|
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
|
||||||
|
- **Existing project**: pick from a list of previous projects
|
||||||
|
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
## CLI Flags
|
||||||
|
|
||||||
|
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
||||||
|
```
|
||||||
|
Usage: spilltea [flags]
|
||||||
|
|
||||||
|
--add-default-config copy the default config file to the config path and exit
|
||||||
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
|
-c, --config string path to config file
|
||||||
|
--host string proxy host (overrides config)
|
||||||
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
|
-p, --port int proxy port (overrides config)
|
||||||
|
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||||
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
|
-v, --version print version
|
||||||
|
```
|
||||||
|
<!-- endexec -->
|
||||||
@@ -12,3 +12,6 @@
|
|||||||
- Select the "Authorities" tab and click on "Import".
|
- Select the "Authorities" tab and click on "Import".
|
||||||
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
|
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
|
||||||
- When prompted, click the "Trust this CA to identify websites" checkbox, then click on "OK".
|
- When prompted, click the "Trust this CA to identify websites" checkbox, then click on "OK".
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Never install this certificate in a permanent system trust store: it grants decryption of all HTTPS traffic. Remove it from your browser after use.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
## Legal Disclaimer
|
||||||
|
|
||||||
|
**This tool is provided for educational purposes and authorized security testing only.**
|
||||||
|
|
||||||
|
Use Spilltea only on systems and networks you own or have explicit written permission to test. Intercepting network traffic without authorization may violate local laws (such as the Computer Fraud and Abuse Act, GDPR, or equivalent legislation in your jurisdiction).
|
||||||
|
|
||||||
|
The author(s) and contributors are not responsible for any misuse, damage, or legal consequences resulting from the use of this software. By using Spilltea, you agree that you are solely responsible for ensuring your usage is lawful and authorized.
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Put `.lua` files in the directory configured by `plugins_dir` in your config file (default: `~/.config/spilltea/plugins`).
|
||||||
|
|
||||||
|
Each file is loaded as a separate plugin at startup. The plugin list is shown on the **Plugins** page.
|
||||||
|
|
||||||
|
## Plugin structure
|
||||||
|
|
||||||
|
Every plugin must declare a `Plugin` table and implement the hooks it wants to use.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
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 (default: false).
|
||||||
|
-- on_config and on_quit are always sync and do not need to be declared here.
|
||||||
|
on_start = { sync = true },
|
||||||
|
on_request = { sync = true },
|
||||||
|
on_response = { sync = false },
|
||||||
|
on_history_entry = { sync = true },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook reference
|
||||||
|
|
||||||
|
| Hook | When called | Sync/async | Return value (sync only) |
|
||||||
|
| ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- |
|
||||||
|
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
|
||||||
|
| `on_start()` | Once at startup, after `on_config` | configurable | ignored |
|
||||||
|
| `on_quit()` | When the app exits | always sync | ignored |
|
||||||
|
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
### `req` (request)
|
||||||
|
|
||||||
|
| Field / method | Type | Description |
|
||||||
|
| ----------------------------- | ------ | ----------------------------------- |
|
||||||
|
| `req.method` | string | HTTP method |
|
||||||
|
| `req.url` | string | Full URL |
|
||||||
|
| `req.host` | string | Host |
|
||||||
|
| `req.path` | string | Path |
|
||||||
|
| `req.headers["Name"]` | string | Request header value |
|
||||||
|
| `req:get_body()` | string | Raw request body (loaded on demand) |
|
||||||
|
| `req:set_header(name, value)` | - | Set a request header |
|
||||||
|
| `req:set_body(body)` | - | Replace the request body |
|
||||||
|
|
||||||
|
### `res` (response)
|
||||||
|
|
||||||
|
| Field / method | Type | Description |
|
||||||
|
| ----------------------------- | ------ | ------------------------- |
|
||||||
|
| `res.status_code` | number | HTTP status code |
|
||||||
|
| `res.headers["Name"]` | string | Response header value |
|
||||||
|
| `res:get_body()` | string | Raw response body |
|
||||||
|
| `res:set_header(name, value)` | - | Set a response header |
|
||||||
|
| `res:set_body(body)` | - | Replace the response body |
|
||||||
|
|
||||||
|
### `entry` (history entry)
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
| -------------------- | ---------------------------- |
|
||||||
|
| `entry.id` | number |
|
||||||
|
| `entry.method` | string |
|
||||||
|
| `entry.host` | string |
|
||||||
|
| `entry.path` | string |
|
||||||
|
| `entry.status_code` | number |
|
||||||
|
| `entry.timestamp` | string (YYYY-MM-DD HH:MM:SS) |
|
||||||
|
| `entry.request_raw` | string |
|
||||||
|
| `entry.response_raw` | string |
|
||||||
|
|
||||||
|
## Utility functions
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Log a message to logs.log (prefixed with the plugin name)
|
||||||
|
log("message")
|
||||||
|
|
||||||
|
-- Send a notification bubble in the TUI
|
||||||
|
-- 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_finding({
|
||||||
|
title = "API Key Found",
|
||||||
|
description = "Markdown description of the finding...",
|
||||||
|
key = "stable-unique-id", -- used for deduplication; defaults to title
|
||||||
|
severity = "high", -- info | low | medium | high | critical
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Run a raw SQL query against the project DB (entries, findings, replay_entries, …)
|
||||||
|
-- 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("reason message")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding deduplication
|
||||||
|
|
||||||
|
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.).
|
||||||
|
|
||||||
|
`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 = true`**: spilltea waits for the hook to return before continuing. The hook can return a decision value (see below).
|
||||||
|
- **`sync = false`** (default for all configurable hooks): the hook runs in a background goroutine. Return values are ignored.
|
||||||
|
|
||||||
|
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
||||||
|
|
||||||
|
### Return values for sync hooks
|
||||||
|
|
||||||
|
**`on_request` and `on_response`:**
|
||||||
|
|
||||||
|
| Return value | Effect |
|
||||||
|
| ------------ | --------------------------------------------------------------------------------- |
|
||||||
|
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. |
|
||||||
|
| `"forward"` | The flow is forwarded immediately without going through the intercept panel. |
|
||||||
|
| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. |
|
||||||
|
|
||||||
|
**`on_history_entry` (sync only):**
|
||||||
|
|
||||||
|
| Return value | Effect |
|
||||||
|
| ------------------- | -------------------------------------- |
|
||||||
|
| `"skip"` | The entry is not saved to the DB. |
|
||||||
|
| `"keep"` or `nil` | The entry is saved normally. |
|
||||||
|
|
||||||
|
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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.4";
|
||||||
|
|
||||||
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-1iPwFsyzdonak9EWMRnudwcCQZfI+Uvre38+puG4s0s=";
|
||||||
|
|
||||||
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.";
|
||||||
@@ -37,5 +37,20 @@
|
|||||||
"${pname}" = pkg;
|
"${pname}" = pkg;
|
||||||
default = pkg;
|
default = pkg;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (system: pkgs: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
go
|
||||||
|
python3
|
||||||
|
lefthook
|
||||||
|
doctoc
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
lefthook install
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ 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.9.4
|
||||||
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
|
||||||
golang.org/x/net v0.39.0
|
golang.org/x/net v0.54.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.50.0
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,8 +24,8 @@ require (
|
|||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
@@ -33,38 +33,37 @@ require (
|
|||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // indirect
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.8 // indirect
|
github.com/klauspost/compress v1.17.8 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
github.com/satori/go.uuid v1.2.0 // indirect
|
github.com/satori/go.uuid v1.2.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
modernc.org/libc v1.72.0 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
|||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg=
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be h1:O22D2Od8gEsRGTDPKDTRzx2BGrvVcIAJlwBf+1sTeN0=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
@@ -50,10 +50,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
@@ -82,8 +82,8 @@ github.com/lqqyt2423/go-mitmproxy v1.8.11 h1:Au/qwhXSlKKCkDxPVa6aSfCJeoxoH6I+7zm
|
|||||||
github.com/lqqyt2423/go-mitmproxy v1.8.11/go.mod h1:dSGnI17tVZ8dtYu9vnaIz7kxVwJNFH0CoNQwEQlTpxE=
|
github.com/lqqyt2423/go-mitmproxy v1.8.11/go.mod h1:dSGnI17tVZ8dtYu9vnaIz7kxVwJNFH0CoNQwEQlTpxE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
@@ -92,8 +92,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -102,16 +102,14 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
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.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
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/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
@@ -120,18 +118,16 @@ 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/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=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
|
||||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
@@ -140,29 +136,27 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|
||||||
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.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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
@@ -171,18 +165,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
|||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Config struct {
|
|||||||
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 {
|
||||||
@@ -43,6 +45,7 @@ type Config struct {
|
|||||||
|
|
||||||
History struct {
|
History struct {
|
||||||
SkipDuplicates bool `mapstructure:"skip_duplicates"`
|
SkipDuplicates bool `mapstructure:"skip_duplicates"`
|
||||||
|
KeepResponses bool `mapstructure:"keep_responses"`
|
||||||
} `mapstructure:"history"`
|
} `mapstructure:"history"`
|
||||||
|
|
||||||
Keybindings Keybindings `mapstructure:"keybindings"`
|
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||||
@@ -71,6 +74,16 @@ func Load(path string) error {
|
|||||||
return viper.Unmarshal(Global)
|
return viper.Unmarshal(Global)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WriteDefaultConfig(path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ExpandPath(p string) string {
|
func ExpandPath(p string) string {
|
||||||
if strings.HasPrefix(p, "~/") {
|
if strings.HasPrefix(p, "~/") {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
|
|||||||
@@ -4,19 +4,23 @@ 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
|
||||||
|
keep_responses: false # if true, response body and headers are stored in history
|
||||||
|
|
||||||
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 +52,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 +64,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"
|
||||||
@@ -94,3 +99,9 @@ keybindings:
|
|||||||
toggle: "space"
|
toggle: "space"
|
||||||
edit_config: "e,enter"
|
edit_config: "e,enter"
|
||||||
filter: "/"
|
filter: "/"
|
||||||
|
|
||||||
|
docs:
|
||||||
|
search: "/"
|
||||||
|
search_reset: "r"
|
||||||
|
search_next: "n"
|
||||||
|
search_prev: "N"
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -65,6 +66,13 @@ type PluginsKeys struct {
|
|||||||
Filter string `mapstructure:"filter"`
|
Filter string `mapstructure:"filter"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocsKeys struct {
|
||||||
|
Search string `mapstructure:"search"`
|
||||||
|
SearchReset string `mapstructure:"search_reset"`
|
||||||
|
SearchNext string `mapstructure:"search_next"`
|
||||||
|
SearchPrev string `mapstructure:"search_prev"`
|
||||||
|
}
|
||||||
|
|
||||||
type Keybindings struct {
|
type Keybindings struct {
|
||||||
Global GlobalKeys `mapstructure:"global"`
|
Global GlobalKeys `mapstructure:"global"`
|
||||||
Intercept InterceptKeys `mapstructure:"intercept"`
|
Intercept InterceptKeys `mapstructure:"intercept"`
|
||||||
@@ -74,4 +82,5 @@ type Keybindings struct {
|
|||||||
Diff DiffKeys `mapstructure:"diff"`
|
Diff DiffKeys `mapstructure:"diff"`
|
||||||
Findings FindingsKeys `mapstructure:"findings"`
|
Findings FindingsKeys `mapstructure:"findings"`
|
||||||
Plugins PluginsKeys `mapstructure:"plugins"`
|
Plugins PluginsKeys `mapstructure:"plugins"`
|
||||||
|
Docs DocsKeys `mapstructure:"docs"`
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-8
@@ -35,11 +35,6 @@ 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 (
|
|
||||||
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 (
|
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,
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -41,88 +41,60 @@ 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
|
|
||||||
|
|
||||||
|
onBeforeNewEntry func(db.Entry) bool
|
||||||
onNewEntry func(db.Entry)
|
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) {
|
if r.MatchString(target) {
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !matched {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range bl {
|
|
||||||
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) {
|
||||||
b.dbMu.Lock()
|
b.dbMu.Lock()
|
||||||
@@ -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,15 +170,26 @@ 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,
|
||||||
Path: path,
|
Path: path,
|
||||||
StatusCode: status,
|
StatusCode: status,
|
||||||
RequestRaw: FormatRawRequest(f),
|
RequestRaw: FormatRawRequest(f),
|
||||||
ResponseRaw: FormatRawResponse(f),
|
ResponseRaw: func() string {
|
||||||
})
|
if config.Global.History.KeepResponses {
|
||||||
|
return FormatRawResponse(f)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package keys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DocsKeyMap struct {
|
||||||
|
Search key.Binding
|
||||||
|
SearchReset key.Binding
|
||||||
|
SearchNext key.Binding
|
||||||
|
SearchPrev key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDocsKeyMap(cfg config.DocsKeys) DocsKeyMap {
|
||||||
|
return DocsKeyMap{
|
||||||
|
Search: binding(cfg.Search, "search"),
|
||||||
|
SearchReset: binding(cfg.SearchReset, "reset search"),
|
||||||
|
SearchNext: binding(cfg.SearchNext, "next match"),
|
||||||
|
SearchPrev: binding(cfg.SearchPrev, "prev match"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DocsKeyMap) Bindings() []key.Binding {
|
||||||
|
return []key.Binding{d.Search, d.SearchReset, d.SearchNext, d.SearchPrev}
|
||||||
|
}
|
||||||
+10
-3
@@ -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,8 +49,13 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommonBindings returns keys available on every page.
|
||||||
|
func (g GlobalKeyMap) CommonBindings() []key.Binding {
|
||||||
|
return []key.Binding{g.Quit, g.Help, g.OpenLogs, g.ToggleSidebar}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type KeyMap struct {
|
|||||||
Diff DiffKeyMap
|
Diff DiffKeyMap
|
||||||
Findings FindingsKeyMap
|
Findings FindingsKeyMap
|
||||||
Plugins PluginsKeyMap
|
Plugins PluginsKeyMap
|
||||||
|
Docs DocsKeyMap
|
||||||
}
|
}
|
||||||
|
|
||||||
var Keys *KeyMap
|
var Keys *KeyMap
|
||||||
@@ -31,6 +32,7 @@ func Init(cfg *config.Config) {
|
|||||||
Diff: newDiffKeyMap(kb.Diff),
|
Diff: newDiffKeyMap(kb.Diff),
|
||||||
Findings: newFindingsKeyMap(kb.Findings),
|
Findings: newFindingsKeyMap(kb.Findings),
|
||||||
Plugins: newPluginsKeyMap(kb.Plugins),
|
Plugins: newPluginsKeyMap(kb.Plugins),
|
||||||
|
Docs: newDocsKeyMap(kb.Docs),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 {
|
||||||
|
|||||||
+90
-32
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ func NewManager(broker *intercept.Broker) *Manager {
|
|||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
||||||
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
|
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
|
||||||
continue
|
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,45 +196,61 @@ 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_start", lua.LString(configText)); err != nil {
|
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
|
||||||
log.Printf("plugin %s on_start (config reload): %v", name, err)
|
log.Printf("plugin %s on_config (config reload): %v", name, err)
|
||||||
}
|
}
|
||||||
found.mu.Unlock()
|
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()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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_config: %v", p.Name, err)
|
||||||
|
}
|
||||||
|
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)
|
log.Printf("plugin %s on_start: %v", p.Name, err)
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ type HookConfig struct {
|
|||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
Name string
|
Name string
|
||||||
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
ConfigText string
|
ConfigText string
|
||||||
|
Priority int
|
||||||
|
|
||||||
L *lua.LState
|
L *lua.LState
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -36,22 +38,31 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
|
|||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Name string
|
Name string
|
||||||
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
ConfigText string
|
ConfigText string
|
||||||
|
Priority int
|
||||||
Hooks map[string]HookConfig
|
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,
|
||||||
|
Description: p.Description,
|
||||||
FilePath: p.FilePath,
|
FilePath: p.FilePath,
|
||||||
Enabled: p.Enabled,
|
Enabled: enabled,
|
||||||
ConfigText: p.ConfigText,
|
ConfigText: configText,
|
||||||
|
Priority: p.Priority,
|
||||||
Hooks: hooks,
|
Hooks: hooks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +70,7 @@ func (p *Plugin) Info() Info {
|
|||||||
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 },
|
||||||
@@ -141,6 +126,7 @@ var pageRegistry = []pageEntry{
|
|||||||
m.docs = updated.(docsUI.Model)
|
m.docs = updated.(docsUI.Model)
|
||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
|
isEditing: func(m *Model) bool { return m.docs.IsEditing() },
|
||||||
resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func (m *Model) renderSidebar() string {
|
|||||||
titleText = "SPLT"
|
titleText = "SPLT"
|
||||||
}
|
}
|
||||||
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText)
|
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText)
|
||||||
|
|
||||||
divider := strings.Repeat("─", inner)
|
divider := strings.Repeat("─", inner)
|
||||||
|
|
||||||
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
|
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
|
||||||
@@ -75,14 +76,28 @@ func (m *Model) renderSidebar() string {
|
|||||||
label += string(entry.id)
|
label += string(entry.id)
|
||||||
}
|
}
|
||||||
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
|
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
|
||||||
|
if m.sidebarState == sidebarCollapsed && icon == "" {
|
||||||
|
line = " " + line
|
||||||
|
}
|
||||||
items.WriteString(line + "\n")
|
items.WriteString(line + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
body := lipgloss.JoinVertical(lipgloss.Left,
|
maxLen := inner - 2
|
||||||
|
name := m.projectName
|
||||||
|
if m.sidebarState == sidebarCollapsed && name == "temporary" {
|
||||||
|
name = "tmp"
|
||||||
|
} else if len(name) > maxLen {
|
||||||
|
name = name[:maxLen-1] + "…"
|
||||||
|
}
|
||||||
|
parts := []string{
|
||||||
title,
|
title,
|
||||||
|
lipgloss.NewStyle().Width(inner).Foreground(s.Subtle).Padding(0, 1).Render(name),
|
||||||
|
}
|
||||||
|
parts = append(parts,
|
||||||
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
|
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
|
||||||
items.String(),
|
items.String(),
|
||||||
)
|
)
|
||||||
|
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
|
|
||||||
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
|
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-25
@@ -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,23 +175,47 @@ 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 {
|
var raw, scheme string
|
||||||
if raw := m.diff.CurrentRaw(); raw != "" {
|
switch m.page {
|
||||||
m.copyAs.SetSize(m.width, m.height)
|
case pageDiff:
|
||||||
m.copyAs.Open(copyasUI.OpenMsg{
|
raw = m.diff.CurrentRaw()
|
||||||
RawRequest: raw,
|
scheme = "https"
|
||||||
Scheme: "https",
|
case pageIntercept:
|
||||||
})
|
raw = m.intercept.CurrentRaw()
|
||||||
|
scheme = m.intercept.CurrentScheme()
|
||||||
|
case pageHistory:
|
||||||
|
raw = m.history.CurrentRaw()
|
||||||
|
scheme = m.history.CurrentScheme()
|
||||||
|
case pageReplay:
|
||||||
|
raw = m.replay.CurrentRaw()
|
||||||
|
scheme = m.replay.CurrentScheme()
|
||||||
}
|
}
|
||||||
} else if m.page == pageIntercept {
|
if raw != "" {
|
||||||
if raw := m.intercept.CurrentRaw(); raw != "" {
|
|
||||||
m.copyAs.SetSize(m.width, m.height)
|
m.copyAs.SetSize(m.width, m.height)
|
||||||
m.copyAs.Open(copyasUI.OpenMsg{
|
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
|
||||||
RawRequest: raw,
|
|
||||||
Scheme: m.intercept.CurrentScheme(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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
|
return m, nil
|
||||||
|
|
||||||
|
|||||||
@@ -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,176 @@
|
|||||||
|
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 (
|
||||||
|
popupW = 55
|
||||||
|
popupH = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeClipboard(text string) {
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(text))
|
||||||
|
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenMsg struct {
|
||||||
|
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, popupW, 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(m.popupInnerWidth(), m.listHeight())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) SetSize(w, h int) {
|
||||||
|
m.width = w
|
||||||
|
m.height = h
|
||||||
|
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) popupInnerWidth() int {
|
||||||
|
w := popupW
|
||||||
|
if m.width > 0 && m.width-4 < w {
|
||||||
|
w = m.width - 4
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) popupHeight() int {
|
||||||
|
h := popupH
|
||||||
|
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, m.popupInnerWidth()+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":
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
)
|
)
|
||||||
|
|
||||||
const popupInnerW = 46
|
const (
|
||||||
|
popupW = 61
|
||||||
|
popupH = 20
|
||||||
|
)
|
||||||
|
|
||||||
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
|
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
|
||||||
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
|
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
|
||||||
@@ -36,6 +39,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"},
|
||||||
@@ -68,7 +72,7 @@ func New() Model {
|
|||||||
BorderForeground(s.Primary).
|
BorderForeground(s.Primary).
|
||||||
Foreground(s.MutedFg).PaddingLeft(1)
|
Foreground(s.MutedFg).PaddingLeft(1)
|
||||||
|
|
||||||
l := list.New(allFormats, delegate, popupInnerW, 8)
|
l := list.New(allFormats, delegate, popupW, 8)
|
||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
@@ -91,17 +95,25 @@ func (m *Model) Open(msg OpenMsg) {
|
|||||||
m.open = true
|
m.open = true
|
||||||
m.list.ResetFilter()
|
m.list.ResetFilter()
|
||||||
m.list.Select(0)
|
m.list.Select(0)
|
||||||
m.list.SetSize(popupInnerW, m.listHeight())
|
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) SetSize(w, h int) {
|
func (m *Model) SetSize(w, h int) {
|
||||||
m.width = w
|
m.width = w
|
||||||
m.height = h
|
m.height = h
|
||||||
m.list.SetSize(popupInnerW, m.listHeight())
|
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) popupInnerWidth() int {
|
||||||
|
w := popupW
|
||||||
|
if m.width > 0 && m.width-4 < w {
|
||||||
|
w = m.width - 4
|
||||||
|
}
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) popupHeight() int {
|
func (m Model) popupHeight() int {
|
||||||
h := 14
|
h := popupH
|
||||||
if m.height > 0 && m.height-4 < h {
|
if m.height > 0 && m.height-4 < h {
|
||||||
h = m.height - 4
|
h = m.height - 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ func (m *Model) View(background string) string {
|
|||||||
BorderForeground(s.Primary)
|
BorderForeground(s.Primary)
|
||||||
|
|
||||||
popupH := m.popupHeight()
|
popupH := m.popupHeight()
|
||||||
popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH)
|
popup := style.RenderWithTitle(border, "Copy as", inner, m.popupInnerWidth()+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)
|
||||||
|
|||||||
@@ -243,14 +243,6 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
|
|||||||
return left, right
|
return left, right
|
||||||
}
|
}
|
||||||
|
|
||||||
func diffBindings() []key.Binding {
|
|
||||||
g := keys.Keys.Global
|
|
||||||
return []key.Binding{
|
|
||||||
g.Up, g.Down, g.ScrollUp, g.ScrollDown,
|
|
||||||
g.CycleFocus, keys.Keys.Diff.Clear,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type diffKeyMap struct{ width int }
|
type diffKeyMap struct{ width int }
|
||||||
|
|
||||||
func (diffKeyMap) ShortHelp() []key.Binding {
|
func (diffKeyMap) ShortHelp() []key.Binding {
|
||||||
@@ -259,6 +251,9 @@ func (diffKeyMap) ShortHelp() []key.Binding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m diffKeyMap) FullHelp() [][]key.Binding {
|
func (m diffKeyMap) FullHelp() [][]key.Binding {
|
||||||
all := append(diffBindings(), keys.Keys.Global.Bindings()...)
|
g := keys.Keys.Global
|
||||||
|
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Copy, g.CopyAs}
|
||||||
|
all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-2
@@ -1,37 +1,288 @@
|
|||||||
package docs
|
package docs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
spilltea "github.com/anotherhadi/spilltea"
|
spilltea "github.com/anotherhadi/spilltea"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/help"
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/textinput"
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readDoc(name string) string {
|
func readDoc(name string) string {
|
||||||
b, _ := spilltea.DocsFS.ReadFile(".github/docs/" + name)
|
b, _ := spilltea.DocsFS.ReadFile("docs/" + name)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentMarkdown = strings.Join([]string{
|
var contentMarkdown = strings.Join([]string{
|
||||||
readDoc("main.md"),
|
readDoc("main.md"),
|
||||||
|
readDoc("legal-disclaimer.md"),
|
||||||
|
readDoc("basics.md"),
|
||||||
readDoc("proxy.md"),
|
readDoc("proxy.md"),
|
||||||
readDoc("certificate.md"),
|
readDoc("certificate.md"),
|
||||||
readDoc("history.md"),
|
readDoc("history.md"),
|
||||||
readDoc("scopes.md"),
|
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
|
type matchEntry struct {
|
||||||
|
line int
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
|
help help.Model
|
||||||
|
searchInput textinput.Model
|
||||||
|
searching bool
|
||||||
|
|
||||||
|
matches []matchEntry
|
||||||
|
matchIndex int
|
||||||
|
|
||||||
|
renderedLines []string
|
||||||
|
strippedLines []string
|
||||||
|
|
||||||
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Prompt = "/"
|
||||||
|
s := ti.Styles()
|
||||||
|
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||||
|
ti.SetStyles(s)
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
viewport: viewport.New(),
|
viewport: viewport.New(),
|
||||||
|
help: style.NewHelp(),
|
||||||
|
searchInput: ti,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e Model) Init() tea.Cmd {
|
func (e Model) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) IsEditing() bool { return m.searching }
|
||||||
|
|
||||||
|
func (m *Model) SetSize(w, h int) {
|
||||||
|
m.width = w
|
||||||
|
m.height = h
|
||||||
|
m.help.SetWidth(w - 2)
|
||||||
|
m.searchInput.SetWidth(w - 4)
|
||||||
|
|
||||||
|
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
|
||||||
|
frameW := windowStyle().GetHorizontalFrameSize()
|
||||||
|
frameH := windowStyle().GetVerticalFrameSize()
|
||||||
|
|
||||||
|
m.viewport.SetWidth(w - frameW)
|
||||||
|
m.viewport.SetHeight(h - frameH - statusH)
|
||||||
|
m.renderMarkdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) applySearch() {
|
||||||
|
query := m.searchInput.Value()
|
||||||
|
m.matches = nil
|
||||||
|
m.matchIndex = 0
|
||||||
|
|
||||||
|
if query != "" {
|
||||||
|
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(query))
|
||||||
|
if err == nil {
|
||||||
|
for lineIdx, stripped := range m.strippedLines {
|
||||||
|
for _, match := range re.FindAllStringIndex(stripped, -1) {
|
||||||
|
m.matches = append(m.matches, matchEntry{
|
||||||
|
line: lineIdx,
|
||||||
|
start: match[0],
|
||||||
|
end: match[1],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.rebuildViewportContent()
|
||||||
|
if len(m.matches) > 0 {
|
||||||
|
m.viewport.SetYOffset(m.matches[0].line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) searchNext() {
|
||||||
|
if len(m.matches) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.matchIndex = (m.matchIndex + 1) % len(m.matches)
|
||||||
|
m.rebuildViewportContent()
|
||||||
|
m.viewport.SetYOffset(m.matches[m.matchIndex].line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) searchPrev() {
|
||||||
|
if len(m.matches) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.matchIndex = (m.matchIndex - 1 + len(m.matches)) % len(m.matches)
|
||||||
|
m.rebuildViewportContent()
|
||||||
|
m.viewport.SetYOffset(m.matches[m.matchIndex].line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) rebuildViewportContent() {
|
||||||
|
if len(m.matches) == 0 || m.searchInput.Value() == "" {
|
||||||
|
m.viewport.SetContent(strings.Join(m.renderedLines, "\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineInfo struct {
|
||||||
|
intervals [][]int
|
||||||
|
currentIdx int
|
||||||
|
}
|
||||||
|
byLine := make(map[int]*lineInfo)
|
||||||
|
for i, match := range m.matches {
|
||||||
|
li := byLine[match.line]
|
||||||
|
if li == nil {
|
||||||
|
li = &lineInfo{currentIdx: -1}
|
||||||
|
byLine[match.line] = li
|
||||||
|
}
|
||||||
|
li.intervals = append(li.intervals, []int{match.start, match.end})
|
||||||
|
if i == m.matchIndex {
|
||||||
|
li.currentIdx = len(li.intervals) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, len(m.renderedLines))
|
||||||
|
for i, ansiLine := range m.renderedLines {
|
||||||
|
if li, ok := byLine[i]; ok {
|
||||||
|
lines[i] = injectHighlightsInLine(ansiLine, li.intervals, li.currentIdx)
|
||||||
|
} else {
|
||||||
|
lines[i] = ansiLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.viewport.SetContent(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func lipglossAnsiCodes(s lipgloss.Style) (open, close string) {
|
||||||
|
const sentinel = "X"
|
||||||
|
rendered := s.Render(sentinel)
|
||||||
|
idx := strings.Index(rendered, sentinel)
|
||||||
|
if idx < 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return rendered[:idx], rendered[idx+len(sentinel):]
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int) string {
|
||||||
|
if len(intervals) == 0 {
|
||||||
|
return ansiLine
|
||||||
|
}
|
||||||
|
|
||||||
|
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.SubtleBg))
|
||||||
|
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.Primary).Foreground(style.S.Text))
|
||||||
|
|
||||||
|
type injection struct {
|
||||||
|
visPos int
|
||||||
|
code string
|
||||||
|
priority int // 0 = close (emit before opens at same pos), 1 = open
|
||||||
|
}
|
||||||
|
var injections []injection
|
||||||
|
for i, iv := range intervals {
|
||||||
|
open, close := normalOpen, normalClose
|
||||||
|
if i == currentIdx {
|
||||||
|
open, close = currentOpen, currentClose
|
||||||
|
}
|
||||||
|
injections = append(injections, injection{visPos: iv[0], code: open, priority: 1})
|
||||||
|
injections = append(injections, injection{visPos: iv[1], code: close, priority: 0})
|
||||||
|
}
|
||||||
|
sort.SliceStable(injections, func(a, b int) bool {
|
||||||
|
if injections[a].visPos != injections[b].visPos {
|
||||||
|
return injections[a].visPos < injections[b].visPos
|
||||||
|
}
|
||||||
|
return injections[a].priority < injections[b].priority
|
||||||
|
})
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
visPos := 0
|
||||||
|
injIdx := 0
|
||||||
|
i := 0
|
||||||
|
for i < len(ansiLine) {
|
||||||
|
for injIdx < len(injections) && injections[injIdx].visPos == visPos {
|
||||||
|
sb.WriteString(injections[injIdx].code)
|
||||||
|
injIdx++
|
||||||
|
}
|
||||||
|
if ansiLine[i] == '\x1b' {
|
||||||
|
j := i + 1
|
||||||
|
if j < len(ansiLine) {
|
||||||
|
switch ansiLine[j] {
|
||||||
|
case '[':
|
||||||
|
j++
|
||||||
|
for j < len(ansiLine) && (ansiLine[j] < '@' || ansiLine[j] > '~') {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j < len(ansiLine) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
case ']':
|
||||||
|
j++
|
||||||
|
for j < len(ansiLine) {
|
||||||
|
if ansiLine[j] == '\a' {
|
||||||
|
j++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ansiLine[j] == '\x1b' && j+1 < len(ansiLine) && ansiLine[j+1] == '\\' {
|
||||||
|
j += 2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(ansiLine[i:j])
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
_, size := utf8.DecodeRuneInString(ansiLine[i:])
|
||||||
|
if size == 0 {
|
||||||
|
size = 1
|
||||||
|
}
|
||||||
|
sb.WriteString(ansiLine[i : i+size])
|
||||||
|
i += size
|
||||||
|
visPos += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for injIdx < len(injections) {
|
||||||
|
sb.WriteString(injections[injIdx].code)
|
||||||
|
injIdx++
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderStatusBar() string {
|
||||||
|
if m.searching {
|
||||||
|
return lipgloss.NewStyle().Padding(0, 1).Render(m.searchInput.View())
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(docsKeyMap{width: m.width}))
|
||||||
|
}
|
||||||
|
|
||||||
|
type docsKeyMap struct{ width int }
|
||||||
|
|
||||||
|
func (docsKeyMap) ShortHelp() []key.Binding {
|
||||||
|
g := keys.Keys.Global
|
||||||
|
d := keys.Keys.Docs
|
||||||
|
return []key.Binding{g.Up, g.Down, d.Search, g.Help}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m docsKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
g := keys.Keys.Global
|
||||||
|
d := keys.Keys.Docs
|
||||||
|
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown}
|
||||||
|
all := append(d.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
|
return keys.ChunkByWidth(all, m.width)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
|
|
||||||
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
|
d := keys.Keys.Docs
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
switch msg.Button {
|
switch msg.Button {
|
||||||
@@ -18,7 +20,42 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
|
if e.searching {
|
||||||
switch {
|
switch {
|
||||||
|
case key.Matches(msg, d.SearchReset):
|
||||||
|
e.searching = false
|
||||||
|
e.searchInput.Blur()
|
||||||
|
e.searchInput.SetValue("")
|
||||||
|
e.matches = nil
|
||||||
|
e.matchIndex = 0
|
||||||
|
e.SetSize(e.width, e.height)
|
||||||
|
case msg.String() == "enter":
|
||||||
|
e.searching = false
|
||||||
|
e.searchInput.Blur()
|
||||||
|
e.SetSize(e.width, e.height)
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
e.searchInput, cmd = e.searchInput.Update(msg)
|
||||||
|
e.applySearch()
|
||||||
|
return e, cmd
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, d.Search):
|
||||||
|
e.searching = true
|
||||||
|
e.searchInput.SetValue("")
|
||||||
|
e.searchInput.Focus()
|
||||||
|
e.SetSize(e.width, e.height)
|
||||||
|
case key.Matches(msg, d.SearchReset):
|
||||||
|
e.matches = nil
|
||||||
|
e.matchIndex = 0
|
||||||
|
e.rebuildViewportContent()
|
||||||
|
case key.Matches(msg, d.SearchNext):
|
||||||
|
e.searchNext()
|
||||||
|
case key.Matches(msg, d.SearchPrev):
|
||||||
|
e.searchPrev()
|
||||||
case key.Matches(msg, g.Up):
|
case key.Matches(msg, g.Up):
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
|
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
|
||||||
case key.Matches(msg, g.Down):
|
case key.Matches(msg, g.Down):
|
||||||
@@ -35,16 +72,10 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
step = 1
|
step = 1
|
||||||
}
|
}
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() + step)
|
e.viewport.SetYOffset(e.viewport.YOffset() + step)
|
||||||
|
case key.Matches(msg, g.Help):
|
||||||
|
e.help.ShowAll = !e.help.ShowAll
|
||||||
|
e.SetSize(e.width, e.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) SetSize(w, h int) {
|
|
||||||
frameW := windowStyle().GetHorizontalFrameSize()
|
|
||||||
frameH := windowStyle().GetVerticalFrameSize()
|
|
||||||
|
|
||||||
m.viewport.SetWidth(w - frameW)
|
|
||||||
m.viewport.SetHeight(h - frameH)
|
|
||||||
m.renderMarkdown()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package docs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
_ "embed"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func windowStyle() lipgloss.Style {
|
func windowStyle() lipgloss.Style {
|
||||||
@@ -20,7 +22,23 @@ func windowStyle() lipgloss.Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e Model) View() tea.View {
|
func (e Model) View() tea.View {
|
||||||
return tea.NewView(windowStyle().Render(e.viewport.View()))
|
statusBar := e.renderStatusBar()
|
||||||
|
if len(e.matches) > 0 {
|
||||||
|
var countText string
|
||||||
|
if e.searching {
|
||||||
|
countText = fmt.Sprintf("%d matches", len(e.matches))
|
||||||
|
} else {
|
||||||
|
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
|
||||||
|
}
|
||||||
|
count := lipgloss.NewStyle().Padding(0, 1).
|
||||||
|
Foreground(style.S.MutedFg).
|
||||||
|
Render(countText)
|
||||||
|
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
|
||||||
|
}
|
||||||
|
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
windowStyle().Render(e.viewport.View()),
|
||||||
|
statusBar,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderMarkdown() {
|
func (m *Model) renderMarkdown() {
|
||||||
@@ -48,5 +66,10 @@ func (m *Model) renderMarkdown() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
str, _ := renderer.Render(processed.String())
|
str, _ := renderer.Render(processed.String())
|
||||||
m.viewport.SetContent(str)
|
m.renderedLines = strings.Split(str, "\n")
|
||||||
|
m.strippedLines = make([]string, len(m.renderedLines))
|
||||||
|
for i, l := range m.renderedLines {
|
||||||
|
m.strippedLines[i] = ansi.Strip(l)
|
||||||
|
}
|
||||||
|
m.applySearch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (m *Model) recalcSizes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{}))
|
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{width: m.width}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshCmd loads findings from the database.
|
// RefreshCmd loads findings from the database.
|
||||||
@@ -143,14 +143,18 @@ func renderMarkdown(src string, width int) string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
type findingsKeyMap struct{}
|
type findingsKeyMap struct{ width int }
|
||||||
|
|
||||||
func (findingsKeyMap) ShortHelp() []key.Binding {
|
func (findingsKeyMap) ShortHelp() []key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
f := keys.Keys.Findings
|
f := keys.Keys.Findings
|
||||||
return []key.Binding{g.Up, g.Down, f.Dismiss}
|
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Help}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (findingsKeyMap) FullHelp() [][]key.Binding {
|
func (m findingsKeyMap) FullHelp() [][]key.Binding {
|
||||||
return [][]key.Binding{findingsKeyMap{}.ShortHelp()}
|
g := keys.Keys.Global
|
||||||
|
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown}
|
||||||
|
all := append(keys.Keys.Findings.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.cursor >= len(m.findings) {
|
if m.cursor >= len(m.findings) {
|
||||||
m.cursor = max(0, len(m.findings)-1)
|
m.cursor = max(0, len(m.findings)-1)
|
||||||
}
|
}
|
||||||
|
if len(m.findings) == 0 {
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.pager.TotalPages = 0
|
||||||
|
} else {
|
||||||
m.pager.SetTotalPages(len(m.findings))
|
m.pager.SetTotalPages(len(m.findings))
|
||||||
|
}
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -76,6 +81,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
step = 1
|
step = 1
|
||||||
}
|
}
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
||||||
|
case key.Matches(msg, g.Help):
|
||||||
|
m.help.ShowAll = !m.help.ShowAll
|
||||||
|
m.recalcSizes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ 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
|
||||||
dots := s.Faint.Render(m.pager.View())
|
var dots string
|
||||||
|
if len(m.findings) > 0 {
|
||||||
|
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),
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -136,14 +145,16 @@ func (historyKeyMap) ShortHelp() []key.Binding {
|
|||||||
return []key.Binding{
|
return []key.Binding{
|
||||||
g.Up, g.Down, g.CycleFocus,
|
g.Up, g.Down, g.CycleFocus,
|
||||||
h.DeleteEntry, h.DeleteAll,
|
h.DeleteEntry, h.DeleteAll,
|
||||||
h.Filter, h.SqlQuery,
|
|
||||||
g.Help,
|
g.Help,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m historyKeyMap) FullHelp() [][]key.Binding {
|
func (m historyKeyMap) FullHelp() [][]key.Binding {
|
||||||
h := keys.Keys.History
|
h := keys.Keys.History
|
||||||
|
g := keys.Keys.Global
|
||||||
|
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs}
|
||||||
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
|
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
|
||||||
all = append(all, keys.Keys.Global.Bindings()...)
|
all = append(all, pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"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"
|
||||||
@@ -281,9 +282,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *Model) refreshListViewport() {
|
func (m *Model) refreshListViewport() {
|
||||||
if m.pager.PerPage > 0 {
|
if m.pager.PerPage > 0 {
|
||||||
|
if len(m.entries) == 0 {
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.pager.TotalPages = 0
|
||||||
|
} else {
|
||||||
m.pager.Page = m.cursor / m.pager.PerPage
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
m.listViewport.SetContent(m.renderList())
|
m.listViewport.SetContent(m.renderList())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,5 +305,10 @@ func (m *Model) refreshBody() {
|
|||||||
} else {
|
} else {
|
||||||
raw = e.RequestRaw
|
raw = e.RequestRaw
|
||||||
}
|
}
|
||||||
|
if raw == "" {
|
||||||
|
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
||||||
|
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (˘・_・˘)\nno response stored")))
|
||||||
|
return
|
||||||
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ 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
|
||||||
dots := s.Faint.Render(m.pager.View())
|
var dots string
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
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),
|
||||||
|
|||||||
@@ -295,17 +295,27 @@ func (m *Model) recalcSizes() {
|
|||||||
|
|
||||||
func (m *Model) refreshListViewport() {
|
func (m *Model) refreshListViewport() {
|
||||||
if m.pager.PerPage > 0 {
|
if m.pager.PerPage > 0 {
|
||||||
|
if len(m.queue) == 0 {
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.pager.TotalPages = 0
|
||||||
|
} else {
|
||||||
m.pager.Page = m.cursor / m.pager.PerPage
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
m.pager.SetTotalPages(len(m.queue))
|
m.pager.SetTotalPages(len(m.queue))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
m.listViewport.SetContent(m.renderList())
|
m.listViewport.SetContent(m.renderList())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) refreshResponseListViewport() {
|
func (m *Model) refreshResponseListViewport() {
|
||||||
if m.responsePager.PerPage > 0 {
|
if m.responsePager.PerPage > 0 {
|
||||||
|
if len(m.responseQueue) == 0 {
|
||||||
|
m.responsePager.Page = 0
|
||||||
|
m.responsePager.TotalPages = 0
|
||||||
|
} else {
|
||||||
m.responsePager.Page = m.responseCursor / m.responsePager.PerPage
|
m.responsePager.Page = m.responseCursor / m.responsePager.PerPage
|
||||||
m.responsePager.SetTotalPages(len(m.responseQueue))
|
m.responsePager.SetTotalPages(len(m.responseQueue))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
m.responseViewport.SetContent(m.renderResponseList())
|
m.responseViewport.SetContent(m.renderResponseList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func (interceptKeyMap) ShortHelp() []key.Binding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m interceptKeyMap) FullHelp() [][]key.Binding {
|
func (m interceptKeyMap) FullHelp() [][]key.Binding {
|
||||||
all := append(keys.Keys.Intercept.Bindings(), keys.Keys.Global.Bindings()...)
|
g := keys.Keys.Global
|
||||||
|
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs}
|
||||||
|
all := append(keys.Keys.Intercept.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type Model struct {
|
|||||||
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
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ func New(broker *intercept.Broker) Model {
|
|||||||
|
|
||||||
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,
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,15 +45,18 @@ func (m *Model) renderListPanel(w, h int) string {
|
|||||||
border = s.PanelFocused
|
border = s.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
dots := s.Faint.Render(m.pager.View())
|
var dots string
|
||||||
|
if len(m.queue) > 0 {
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
||||||
@@ -25,6 +24,7 @@ type Model struct {
|
|||||||
filtered []plugins.Info
|
filtered []plugins.Info
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
|
detailViewport viewport.Model
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
filterInput textinput.Model
|
filterInput textinput.Model
|
||||||
filtering bool
|
filtering bool
|
||||||
@@ -36,7 +36,7 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ func New(mgr *plugins.Manager) Model {
|
|||||||
return Model{
|
return Model{
|
||||||
manager: mgr,
|
manager: mgr,
|
||||||
listViewport: style.NewViewport(),
|
listViewport: style.NewViewport(),
|
||||||
|
detailViewport: style.NewViewport(),
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
filterInput: fi,
|
filterInput: fi,
|
||||||
pager: style.NewPaginator(),
|
pager: style.NewPaginator(),
|
||||||
@@ -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,23 +175,34 @@ 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 {
|
||||||
|
if len(m.filtered) == 0 {
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.pager.TotalPages = 0
|
||||||
|
} else {
|
||||||
m.pager.Page = m.cursor / m.pager.PerPage
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
m.pager.SetTotalPages(len(m.filtered))
|
m.pager.SetTotalPages(len(m.filtered))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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):]
|
width int
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -172,9 +211,25 @@ 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, scrollHint, g.Help}
|
||||||
|
}
|
||||||
|
return []key.Binding{pk.Toggle, scrollHint, g.Help}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k pluginsKeyMap) FullHelp() [][]key.Binding {
|
func (k pluginsKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
g := keys.Keys.Global
|
||||||
|
if k.editing {
|
||||||
return [][]key.Binding{k.ShortHelp()}
|
return [][]key.Binding{k.ShortHelp()}
|
||||||
}
|
}
|
||||||
|
pk := keys.Keys.Plugins
|
||||||
|
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Escape}
|
||||||
|
all := []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter}
|
||||||
|
all = append(all, pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
|
return keys.ChunkByWidth(all, k.width)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
-16
@@ -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,32 @@ 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
|
||||||
dots := s.Faint.Render(m.pager.View())
|
panelStyle := s.PanelFocused
|
||||||
|
if m.editing {
|
||||||
|
panelStyle = s.Panel
|
||||||
|
}
|
||||||
|
var dots string
|
||||||
|
if len(m.filtered) > 0 {
|
||||||
|
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 +64,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")
|
|
||||||
|
|
||||||
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
|
header := pad.Render(
|
||||||
|
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
|
||||||
|
s.Faint.Render(filepath.Base(info.FilePath)),
|
||||||
|
)
|
||||||
|
|
||||||
|
parts := []string{header, m.detailViewport.View()}
|
||||||
|
|
||||||
|
if m.hasConfig() {
|
||||||
|
var configLabel string
|
||||||
if m.editing {
|
if m.editing {
|
||||||
escKey := keys.Keys.Global.Escape.Help().Key
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):"))
|
configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
|
||||||
} else {
|
} else {
|
||||||
editKey := keys.Keys.Plugins.EditConfig.Help().Key
|
editKey := keys.Keys.Plugins.EditConfig.Help().Key
|
||||||
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):"))
|
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 +123,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(), width: m.width})))
|
||||||
}
|
}
|
||||||
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))
|
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig(), width: m.width}))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -170,6 +187,9 @@ func (replayKeyMap) ShortHelp() []key.Binding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m replayKeyMap) FullHelp() [][]key.Binding {
|
func (m replayKeyMap) FullHelp() [][]key.Binding {
|
||||||
all := append(keys.Keys.Replay.Bindings(), keys.Keys.Global.Bindings()...)
|
g := keys.Keys.Global
|
||||||
|
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs}
|
||||||
|
all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -229,9 +242,14 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *Model) refreshListViewport() {
|
func (m *Model) refreshListViewport() {
|
||||||
if m.pager.PerPage > 0 {
|
if m.pager.PerPage > 0 {
|
||||||
|
if len(m.entries) == 0 {
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.pager.TotalPages = 0
|
||||||
|
} else {
|
||||||
m.pager.Page = m.cursor / m.pager.PerPage
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
m.listViewport.SetContent(m.renderList())
|
m.listViewport.SetContent(m.renderList())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,11 +265,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,19 @@ 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
|
||||||
dots := s.Faint.Render(m.pager.View())
|
panelStyle := s.PanelFocused
|
||||||
|
if m.editing {
|
||||||
|
panelStyle = s.Panel
|
||||||
|
}
|
||||||
|
var dots string
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pre-commit:
|
||||||
|
piped: true
|
||||||
|
commands:
|
||||||
|
check-vendor-hash:
|
||||||
|
glob: "{go.mod,go.sum}"
|
||||||
|
run: .github/scripts/check-vendor-hash.sh
|
||||||
|
stage_fixed: true
|
||||||
|
inject-exec-basics:
|
||||||
|
glob: "{docs/basics.md,cmd/**}"
|
||||||
|
run: python3 .github/scripts/inject-exec.py docs/basics.md
|
||||||
|
stage_fixed: true
|
||||||
|
inject-exec:
|
||||||
|
glob: "{README.md,docs/basics.md,cmd/**}"
|
||||||
|
run: python3 .github/scripts/inject-exec.py README.md
|
||||||
|
stage_fixed: true
|
||||||
|
toc:
|
||||||
|
glob: "{README.md,docs/basics.md,cmd/**}"
|
||||||
|
run: doctoc --notitle README.md
|
||||||
|
stage_fixed: true
|
||||||
+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",
|
||||||
|
description = [[
|
||||||
|
Inject custom headers into every intercepted request.
|
||||||
|
|
||||||
|
**Config**:
|
||||||
|
- one 'Header-Name: value' per line.
|
||||||
|
]],
|
||||||
on_request = { sync = true },
|
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,104 @@
|
|||||||
|
Plugin = {
|
||||||
|
name = "Scopes",
|
||||||
|
description = [[
|
||||||
|
Auto-forward requests and exclude them from history based on patterns.
|
||||||
|
|
||||||
|
**Config**:
|
||||||
|
- `pattern` - whitelist: only intercept matching requests/responses and history entries
|
||||||
|
- `!pattern` - blacklist: skip matching requests/responses and history entries
|
||||||
|
- `r:pattern` - whitelist for requests/responses only (history unaffected)
|
||||||
|
- `r:!pattern` - blacklist for requests/responses only
|
||||||
|
- `h:pattern` - whitelist for history entries only (requests unaffected)
|
||||||
|
- `h:!pattern` - blacklist for history entries only
|
||||||
|
- 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$
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (disable history — h: whitelist never matches any real URL):
|
||||||
|
```
|
||||||
|
h:^$
|
||||||
|
```
|
||||||
|
]],
|
||||||
|
priority = 100,
|
||||||
|
on_request = { sync = true },
|
||||||
|
on_response = { sync = true },
|
||||||
|
on_history_entry = { sync = true },
|
||||||
|
}
|
||||||
|
|
||||||
|
local blacklist = {}
|
||||||
|
local whitelist = {}
|
||||||
|
local blacklist_req = {}
|
||||||
|
local whitelist_req = {}
|
||||||
|
local blacklist_hist = {}
|
||||||
|
local whitelist_hist = {}
|
||||||
|
|
||||||
|
function on_config(config_text)
|
||||||
|
blacklist, whitelist = {}, {}
|
||||||
|
blacklist_req, whitelist_req = {}, {}
|
||||||
|
blacklist_hist, whitelist_hist = {}, {}
|
||||||
|
for line in config_text:gmatch("[^\n]+") do
|
||||||
|
local trimmed = line:match("^%s*(.-)%s*$")
|
||||||
|
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||||
|
local scope = trimmed:match("^([rh]):")
|
||||||
|
local rest = scope and trimmed:sub(3) or trimmed
|
||||||
|
local is_bl = rest:sub(1, 1) == "!"
|
||||||
|
local pattern = is_bl and rest:sub(2) or rest
|
||||||
|
if scope == "r" then
|
||||||
|
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
||||||
|
elseif scope == "h" then
|
||||||
|
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
||||||
|
else
|
||||||
|
table.insert(is_bl and blacklist or whitelist, pattern)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function check_skip(url, bl_extra, wl_extra)
|
||||||
|
for _, p in ipairs(blacklist) do
|
||||||
|
if url:match(p) then return true end
|
||||||
|
end
|
||||||
|
for _, p in ipairs(bl_extra) do
|
||||||
|
if url:match(p) then return true end
|
||||||
|
end
|
||||||
|
local wl = {}
|
||||||
|
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end
|
||||||
|
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end
|
||||||
|
if #wl > 0 then
|
||||||
|
for _, p in ipairs(wl) do
|
||||||
|
if url:match(p) then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_request(req)
|
||||||
|
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_response(req)
|
||||||
|
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_history_entry(entry)
|
||||||
|
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then return "skip" end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user