75 Commits

Author SHA1 Message Date
Hadi 6e673f5c11 v0.0.6
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 15:12:25 +02:00
Hadi 2c63cdbeff Edit plugins id & docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 15:09:09 +02:00
Hadi 0e982c6ade add pages "update" label
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:49:01 +02:00
Hadi 04ba32cbd5 order by asc
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:48:34 +02:00
Hadi f7e9da94cc Add scroll icon on viewports
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:36:27 +02:00
Hadi 9253d85c81 init faq.md
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 13:42:02 +02:00
Hadi 1bb547870e add "temp" for temporary projects
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 13:39:11 +02:00
Hadi 4251e4fb2a plugin's config is now in yaml
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 11:43:26 +02:00
Hadi b547a79d6e add trufflehog to dev deps
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:53:42 +02:00
Hadi fe58468abf add direnv
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:45:38 +02:00
Hadi 3542098905 add gomod2nix.toml
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:35:48 +02:00
Hadi f78b3f7174 Move pre-commit hooks to nix
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:33:16 +02:00
Hadi 722021ba02 merge plugins & docs embed
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:27:47 +02:00
Hadi e18f660e83 move the goreleaser config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:27:35 +02:00
Hadi 67fe8eb911 fix: log silent errors, harden proxy auth, optimize db and render pipeline
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:19:37 +02:00
Hadi af872afbe8 gofmt
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:09:00 +02:00
Hadi 2225afd9ee v0.0.5
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:08:18 +02:00
Hadi 6dc959de77 add sendtodiff in replay
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:06:26 +02:00
Hadi 0017f37c33 truncate title
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:06:06 +02:00
Hadi 924cb73afb refactor page/list movement
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:01:04 +02:00
Hadi 746f1afd1b edit write clipboard
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:00:41 +02:00
Hadi 905013943d edit keybind
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:00:19 +02:00
Hadi c6bca887cb Implement prevpage nextpage
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:58:26 +02:00
Hadi dcf9cb4c8e add a notifications when copied to clipboard
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:53:36 +02:00
Hadi ae372d7283 change default keybinds
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:53:13 +02:00
Hadi e20250f0a0 Init secret scan plugin #2
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:30:35 +02:00
Hadi 3463e51739 Copy func in findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:29:41 +02:00
Hadi 87fa9448d6 check if trufflehog is installed on_start
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:02:35 +02:00
Hadi 4240c4ceb9 fix ip filter
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:54:04 +02:00
Hadi d79c9f91d1 Make on_start run when the plugin is toggled
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:52:17 +02:00
Hadi 33e2afe709 Init trufflehog plugin
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:26:16 +02:00
Hadi 2c3e19258f Fix scroll & copy buttons
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:25:50 +02:00
Hadi 69d5d0ffec Add shell exec to plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:00:04 +02:00
Hadi d47f51d2b5 Fix cursor/scroll jump
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 19:59:31 +02:00
Hadi 598455f8d3 Fix SQLite queue
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 15:05:46 +02:00
Hadi 28b070dafc Add flags to history
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:48:15 +02:00
Hadi 6f56e0b26a ui/home is now in the same app
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:34:48 +02:00
Hadi eaa960e6ab edit docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:08:59 +02:00
Hadi f874a70639 edit diff mode
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:01:09 +02:00
Hadi 4643989ab6 Add proxy auth
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 14:00:57 +02:00
Hadi 7bbc00880a feat: word-level diff highlighting in diff view
- tokenize() splits lines into word-char runs and single non-word bytes
- wordDiff() runs LCS on tokens and renders changed tokens with bold colors
- applyWordDiff() post-processes equal-size removed/added line blocks
- lcsAlignedDiff now stores plainText on removed/added lines for pairing
- Unchanged tokens rendered dim; removed tokens bold-red; added tokens bold-green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:39:13 +02:00
Hadi 385b6e84e0 feat: add GotoTop/Bottom/PrevPage/NextPage navigation keys
- New global keybindings: GotoTop (Home), GotoBottom (G/End), PrevPage ([), NextPage (])
- Wired in history, findings, and intercept update handlers
- Removes duplicate tea.Quit case in intercept/update.go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:39:01 +02:00
Hadi 6a9935ec27 feat: add HTTPie export format in copy-as
- New toHTTPie() function builds an httpie command from raw request
- Added "httpie" case in formatAs() switch
- Uses util.ParseRawRequest; model lists httpie as a selectable format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:50 +02:00
Hadi b490c7a0ac fix: use ParseRawRequest and cap response body in replay
- replay/update.go uses util.ParseRawRequest instead of inline parsing
- Response body capped with io.LimitReader at MaxBodySizeMB
- Uses util.SortedHeaderLines for deterministic header order
- Adds navigation key handling (GotoTop/Bottom/PrevPage/NextPage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:41 +02:00
Hadi 1a1c0cff30 refactor: centralize raw HTTP parsing and header serialization
- Add internal/util/rawhttp.go with ParseRawRequest and SortedHeaderLines
- Refactor intercept/format.go and ui/intercept/helpers.go to use them
- Eliminates duplicated bufio.Reader + textproto parsing spread across 3+ files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:30 +02:00
Hadi 172a77e13b fix: security hardening and code quality
- SQL query mode uses read-only SQLite connection with PRAGMA query_only=ON
- Lua sandbox removes dofile/loadfile/load after OpenBase to block file access
- Plugin manager sorts by priority once at load time; GetPlugins is a plain copy
- Proxy appends [body truncated] marker when body hits size limit
- App startup exits with os.Exit(1) on DB open failure
- tickCmd uses tea.Tick instead of time.Sleep in a goroutine
- ErrMsg with non-nil error shows notification then quits
- DB stores path for use by read-only query connection
- WAL journal mode + NORMAL synchronous set in migrate()
- config.go uses errors.Is(err, os.ErrNotExist)
- main.go uses os.UserHomeDir() and removes racy port pre-check
- findings renderer is cached and rebuilt only on width change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:38:10 +02:00
Hadi 41c0e489cf QOC
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:51:38 +02:00
Hadi 79128bb865 typo
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:51:27 +02:00
Hadi 48de2a8e10 add runtime version
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:34:35 +02:00
Hadi b4a45a23e5 Add "disable_by_default" flag for plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:18:16 +02:00
Hadi b5e2721aa1 Center lines for asciimoji+text
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 11:04:52 +02:00
Hadi 0cfba17d3d Edit the config "external_editor" to overwrite $EDITOR
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 10:13:36 +02:00
Hadi a147e8b972 QOL & Security improvement
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 10:09:42 +02:00
Hadi 03260e0947 Init copy as HAR
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 09:39:50 +02:00
Hadi ed59923b7d v0.0.4
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 00:03:37 +02:00
Hadi aa7b639f82 Add pre-commit hook for vendorHash validation #7
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 00:02:21 +02:00
Hadi 27e0c418e9 Add project name in sidebar
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:52:20 +02:00
Hadi 08757a5d1d Remove some keybindings in short help
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:51:54 +02:00
Hadi ffee0978e6 move legal disclaimer 2026-05-18 23:47:00 +02:00
Hadi 4f45a7c061 Add request/history specific regex
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:44:50 +02:00
Hadi 0fafa52c65 Update deps
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:42:29 +02:00
Hadi 366bb682d2 Edit readme
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:33:51 +02:00
Hadi 9fe0c74150 Edit docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:33:46 +02:00
Hadi 3b6b58ac2b Change paginator dots when no entry
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:33:30 +02:00
Hadi 789a513469 add "add-default-config" flag
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:31:49 +02:00
Hadi 615093bd8b Add the config option "keep responses"
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:12:01 +02:00
Hadi 7e1b7d3b5a add warning for CA
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:05:15 +02:00
Hadi e3e89582c1 Add commit hook & markdown scripts 2026-05-18 22:59:48 +02:00
Hadi 2705c2882d Change flag.Usage
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 22:32:12 +02:00
Hadi 6ea692754a move docs to root
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 21:50:32 +02:00
Hadi 85c2806604 Add search in ui/docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 21:22:17 +02:00
Hadi d451965fa0 Add disclaimer & vim-like nav
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 21:17:35 +02:00
Hadi d82a220e91 add extra space when icons are not rendered
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 20:40:28 +02:00
Hadi 1ac5eb26e8 Change popup width/height
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 20:34:52 +02:00
Hadi 969febb14c Change help menus: Only display shortcuts used on the page
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 20:23:56 +02:00
93 changed files with 3534 additions and 1142 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+27
View File
@@ -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"
+33
View File
@@ -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)
+1 -1
View File
@@ -23,6 +23,6 @@ jobs:
- uses: goreleaser/goreleaser-action@v6 - uses: goreleaser/goreleaser-action@v6
with: with:
version: "~> v2" version: "~> v2"
args: release --clean args: release --clean --config .github/.goreleaser.yaml
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -1,3 +1,4 @@
.claude/ .claude/
CLAUDE.md CLAUDE.md
result/ result/
.pre-commit-config.yaml
+47 -15
View File
@@ -14,6 +14,22 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/anotherhadi/spilltea)](https://goreportcard.com/report/github.com/anotherhadi/spilltea) [![Go Report Card](https://goreportcard.com/badge/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.
@@ -22,12 +38,23 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key
<img alt="demo" src="./.github/assets/demo.gif" width="700" /> <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
- **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.
@@ -72,6 +99,7 @@ environment.systemPackages = [ inputs.spilltea.packages.${pkgs.system}.default ]
</details> </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.
@@ -82,11 +110,6 @@ 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) or [plugin examples](./plugins/).
## Configuration ## Configuration
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`. Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
@@ -94,16 +117,25 @@ Check the default configuration with all the options [here](./internal/config/de
## CLI Flags ## CLI Flags
| Flag | Short | Description | ```
| ----------------------- | ----- | ------------------------------------------------------------------------------ | Usage: spilltea [flags]
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
| `--plugin-dir` | | Path to plugins dir, overrides config (default: `~/.config/spilltea/plugins/`) | --add-default-config copy the default config file to the config path and exit
| `--host` | | Proxy host, overrides config | --add-default-plugins copy built-in example plugins into the plugins dir and exit
| `--port` | `-p` | Proxy port, overrides config | -c, --config string path to config file
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session | --host string proxy host (overrides config)
| `--upstream-proxy` | | Upstream proxy URL, overrides config (e.g. `http://user:pass@host:8888`) | --plugins-dir string path to plugins dir (overrides config)
| `--version` | `-v` | Print version and exit | -p, --port int proxy port (overrides config)
| `--add-default-plugins` | | Add the default plugins to your plugins dir and exit | -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
+45 -30
View File
@@ -2,9 +2,9 @@ package main
import ( import (
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
spilltea "github.com/anotherhadi/spilltea" spilltea "github.com/anotherhadi/spilltea"
@@ -21,6 +21,15 @@ import (
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set. // Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
var version = "dev" var version = "dev"
func init() {
if version != "dev" {
return
}
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
}
func main() { func main() {
var ( var (
flagConfig = flag.StringP("config", "c", "", "path to config file") flagConfig = flag.StringP("config", "c", "", "path to config file")
@@ -31,7 +40,13 @@ func main() {
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") 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 {
@@ -40,7 +55,8 @@ func main() {
} }
if *flagAddDefaultPlugins { if *flagAddDefaultPlugins {
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" { if *flagConfig != "" {
cfgPath = *flagConfig cfgPath = *flagConfig
} }
@@ -61,12 +77,27 @@ func main() {
os.Exit(0) os.Exit(0)
} }
if *flagAddDefaultConfig {
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(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)
} }
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" { if *flagConfig != "" {
cfgPath = *flagConfig cfgPath = *flagConfig
} }
@@ -90,48 +121,32 @@ func main() {
config.Global.App.UpstreamProxy = *flagUpstreamProxy config.Global.App.UpstreamProxy = *flagUpstreamProxy
} }
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
// Check if the proxy port is available before starting the UI.
ln, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintf(os.Stderr, "proxy: cannot bind to %s: %v\n", addr, err)
os.Exit(1)
}
ln.Close()
style.Init(config.Global) style.Init(config.Global)
icons.Init(config.Global) icons.Init(config.Global)
keys.Init(config.Global) keys.Init(config.Global)
projectDir := config.ExpandPath(config.Global.App.ProjectDir) projectDir := config.ExpandPath(config.Global.App.ProjectDir)
// Resolve project: either from --project flag or by running the home UI. // If --project flag is set, skip the home screen entirely.
var project *homeUI.Project
if *flagProject != "" { if *flagProject != "" {
p, err := homeUI.OpenProject(projectDir, *flagProject) project, err := homeUI.OpenProject(projectDir, *flagProject)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "project: %v\n", err) fmt.Fprintf(os.Stderr, "project: %v\n", err)
os.Exit(1) os.Exit(1)
} }
project = p
} else {
finalModel, err := tea.NewProgram(homeUI.New(projectDir)).Run()
if err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
os.Exit(1)
}
project = finalModel.(homeUI.Model).Selected()
}
// User quit the home screen without selecting a project.
if project == nil {
return
}
broker := intercept.NewBroker() broker := intercept.NewBroker()
m := appUI.New(broker, project.Name, project.Path) m := appUI.New(broker, project.Name, project.Path)
if _, err := tea.NewProgram(m).Run(); err != nil { if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err) fmt.Fprintf(os.Stderr, "tui: %v\n", err)
os.Exit(1) os.Exit(1)
} }
return
}
// Run home + app in a single program to avoid a blank flash on transition.
root := rootModel{home: homeUI.New(projectDir)}
if _, err := tea.NewProgram(root).Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
os.Exit(1)
}
} }
+60
View File
@@ -0,0 +1,60 @@
package main
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/intercept"
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
)
type rootState int
const (
rootStateHome rootState = iota
rootStateApp
)
type rootModel struct {
state rootState
home homeUI.Model
app tea.Model
width int
height int
}
func (m rootModel) Init() tea.Cmd {
return m.home.Init()
}
func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
}
if m.state == rootStateHome {
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
broker := intercept.NewBroker()
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
m.app = app
m.state = rootStateApp
return m, tea.Batch(app.Init(), func() tea.Msg {
return tea.WindowSizeMsg{Width: m.width, Height: m.height}
})
}
updated, cmd := m.home.Update(msg)
m.home = updated.(homeUI.Model)
return m, cmd
}
updated, cmd := m.app.Update(msg)
m.app = updated
return m, cmd
}
func (m rootModel) View() tea.View {
if m.state == rootStateApp {
return m.app.(interface{ View() tea.View }).View()
}
return m.home.View()
}
-6
View File
@@ -1,6 +0,0 @@
package spilltea
import "embed"
//go:embed .github/docs
var DocsFS embed.FS
+32
View File
@@ -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 -->
@@ -5,10 +5,13 @@
- On Chrome: - On Chrome:
- Open your Chrome settings, search for "Certificates" and click on "Security". - Open your Chrome settings, search for "Certificates" and click on "Security".
- In the security settings page, scroll down and click on "Manage certificates". - In the security settings page, scroll down and click on "Manage certificates".
- Select the "Authorities" tab and click on "Import tab and click on "Import". - Select the "Authorities" tab and click on "Import".
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`. - Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
- On Firefox: - On Firefox:
- Open your Firefox settings, search for "Certificates" and click on "View Certificates". - Open your Firefox settings, search for "Certificates" and click on "View Certificates".
- 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.
+27
View File
@@ -0,0 +1,27 @@
## How do I convert a temporary session into a regular project?
Temporary sessions are stored under `/tmp/spilltea/<random-id>/` and will be lost on reboot. To keep the data, move the directory into the Spilltea projects folder and give it a name.
**1. Find the temporary session directory**
```bash
ls /tmp/spilltea/
```
You will see one or more directories with a random hex name (e.g. `a1b2c3d4`).
**2. Move it to the projects folder**
```bash
mv /tmp/spilltea/<random-id> ~/.local/share/spilltea/<project-name>
```
The project name must only contain lowercase letters, digits, `-`, and `_`.
**3. Open the project**
```bash
spilltea -P my-project
```
Or simply launch Spilltea and pick `my-project` from the project list.
+2 -6
View File
@@ -4,9 +4,7 @@ The History page has a built-in search bar with two modes:
**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies. **Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies.
**SQL mode**: press `:` to open it, then `Enter` to run. You can write either a WHERE expression or a full SELECT query against the `entries` table. **SQL mode**: press `:` to open it, then `Enter` to run. Type a WHERE expression: the full `SELECT … FROM entries WHERE` is added automatically.
WHERE expression (the `SELECT` is added automatically):
```sql ```sql
status_code = 404 status_code = 404
@@ -16,10 +14,8 @@ status_code = 404
host LIKE '%.api.%' AND method = 'POST' host LIKE '%.api.%' AND method = 'POST'
``` ```
Full SELECT query:
```sql ```sql
SELECT * FROM entries WHERE response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20 response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20
``` ```
The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`. The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`.
+7
View File
@@ -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.
+64 -29
View File
@@ -1,5 +1,7 @@
# Plugins # Plugins
> **Warning:** Plugins can execute arbitrary shell commands, read and write files via `shell_pipe`, and access all intercepted traffic. Only load plugins you trust and have reviewed. You are solely responsible for the plugins you run.
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic. Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
You can found some pre-built plugins [here](../../plugins/). You can found some pre-built plugins [here](../../plugins/).
@@ -18,6 +20,7 @@ Plugin = {
name = "My Plugin", name = "My Plugin",
description = "What this plugin does.", description = "What this plugin does.",
priority = 0, -- higher = runs before other plugins (default: 0) priority = 0, -- higher = runs before other plugins (default: 0)
disable_by_default = true, -- if true, plugin starts disabled on first load (default: false)
-- Declare which hooks you use and whether they are synchronous (default: false). -- Declare which hooks you use and whether they are synchronous (default: false).
-- on_config and on_quit are always sync and do not need to be declared here. -- on_config and on_quit are always sync and do not need to be declared here.
@@ -30,14 +33,14 @@ Plugin = {
### Hook reference ### Hook reference
| Hook | When called | Sync/async | Return value (sync only) | | Hook | When called | Sync/async | Return value |
| ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- | | ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
| `on_config(config_text)` | At startup and on config save | always sync | ignored | | `on_config()` | At startup and on config save | always sync | ignored |
| `on_start()` | Once at startup, after `on_config` | configurable | ignored | | `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored |
| `on_quit()` | When the app exits | always sync | ignored | | `on_quit()` | When the app exits | always sync | ignored |
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` | | `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) | | `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) (sync only) |
## Request and response objects ## Request and response objects
@@ -109,17 +112,64 @@ end
-- Quit the app (useful for startup checks that fail) -- Quit the app (useful for startup checks that fail)
quit("reason message") quit("reason message")
-- Run a shell command, optionally piping a string to its stdin.
-- Returns: output string, error string (nil on success).
-- The command runs via "sh -c" with a 30-second timeout.
local out, err = shell_pipe("trufflehog filesystem --no-update --json /dev/stdin", body)
if err then
log("command failed: " .. err)
else
log("output: " .. out)
end
-- Return the plugin's config section as a Lua table (parsed from YAML).
-- Returns an empty table if no config is set.
local cfg = get_config()
``` ```
### Finding deduplication ### Finding deduplication
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again. A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts.
If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
## Configuration ## Configuration
Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.). Plugin configuration is stored in a `plugins.yaml` file alongside the project database.
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI. ```yaml
plugins:
my_plugin:
enable: true
config:
some_key: some_value
list:
- item1
- item2
other_plugin:
enable: false
```
The config block is edited from the **Plugins** page in the TUI.
Inside a plugin, call `get_config()` to retrieve the config as a Lua table.
`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI.
It is the right place to read `get_config()` and populate local variables.
```lua
local items = {}
function on_config()
items = {}
local cfg = get_config()
if cfg and cfg.list then
for _, v in ipairs(cfg.list) do
table.insert(items, v)
end
end
end
```
## Sync vs async ## Sync vs async
@@ -128,28 +178,13 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration. `on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
### Return values for sync hooks Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history.
Async `on_history_entry` runs **after** the insert and cannot affect it.
**`on_request` and `on_response`:**
| Return value | Effect |
| ------------ | --------------------------------------------------------------------------------- |
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. |
| `"forward"` | The flow is forwarded immediately without going through the intercept panel. |
| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. |
**`on_history_entry` (sync only):**
| Return value | Effect |
| ------------------- | -------------------------------------- |
| `"skip"` | The entry is not saved to the DB. |
| `"keep"` or `nil` | The entry is saved normally. |
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
## Priority ## Priority
Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins. Plugins with a higher `priority` value run before plugins with a lower value (default `0`).
This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
```lua ```lua
Plugin = { Plugin = {
+2
View File
@@ -7,3 +7,5 @@ You can install it from the [Google Chrome extension store](https://chromewebsto
2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save". 2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save".
3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button. 3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button.
4. You're all set! You can now use Spilltea. 4. You're all set! You can now use Spilltea.
If `proxy_auth` is set in the config (`user:pass`), enter the same credentials in FoxyProxy under "Username" and "Password" in the proxy settings.
Generated
+115
View File
@@ -1,5 +1,103 @@
{ {
"nodes": { "nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gomod2nix": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1770585520,
"narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=",
"owner": "nix-community",
"repo": "gomod2nix",
"rev": "1201ddd1279c35497754f016ef33d5e060f3da8d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "gomod2nix",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1777954456, "lastModified": 1777954456,
@@ -18,8 +116,25 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"git-hooks": "git-hooks",
"gomod2nix": "gomod2nix",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",
+23 -22
View File
@@ -1,41 +1,42 @@
{ {
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players."; description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";}; inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
gomod2nix = {
url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
git-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { outputs = {
self, self,
nixpkgs, nixpkgs,
gomod2nix,
git-hooks,
}: let }: let
supportedSystems = ["x86_64-linux" "aarch64-linux"]; supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = f: forAllSystems = f:
nixpkgs.lib.genAttrs supportedSystems nixpkgs.lib.genAttrs supportedSystems
(system: f system (import nixpkgs {inherit system;})); (system: f system (import nixpkgs {inherit system;}));
pname = "spilltea";
version = "0.0.3";
ldflags = ["-s" "-w" "-X main.version=${version}"];
in { in {
packages = forAllSystems (system: pkgs: let packages = forAllSystems (system: pkgs:
pkg = pkgs.buildGoModule { import ./nix/package.nix {
inherit pname version ldflags; inherit pkgs;
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
});
src = ./.; devShells = forAllSystems (system: pkgs: {
outputs = ["out"]; default = import ./nix/shell.nix {
inherit pkgs;
vendorHash = "sha256-v37RFS/T6KGZTO1tHmtUqBrRcCqNS3+ACBcsd7tl50c="; gitHooksLib = git-hooks.lib.${system};
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
meta = with pkgs.lib; {
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
homepage = "https://github.com/anotherhadi/spilltea";
platforms = platforms.unix;
}; };
};
in {
"${pname}" = pkg;
default = pkg;
}); });
}; };
} }
+15 -16
View File
@@ -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.3 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
) )
+40 -49
View File
@@ -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=
@@ -42,7 +42,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
@@ -51,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=
@@ -83,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=
@@ -93,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=
@@ -103,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.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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=
@@ -121,19 +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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
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=
@@ -142,30 +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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= modernc.org/cc/v4 v4.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=
@@ -174,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=
+17 -1
View File
@@ -2,6 +2,7 @@ package config
import ( import (
_ "embed" _ "embed"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -24,6 +25,9 @@ type Config struct {
ProjectDir string `mapstructure:"project_dir"` ProjectDir string `mapstructure:"project_dir"`
PluginsDir string `mapstructure:"plugins_dir"` PluginsDir string `mapstructure:"plugins_dir"`
UpstreamProxy string `mapstructure:"upstream_proxy"` UpstreamProxy string `mapstructure:"upstream_proxy"`
ProxyAuth string `mapstructure:"proxy_auth"`
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
ExternalEditor string `mapstructure:"external_editor"`
} `mapstructure:"app"` } `mapstructure:"app"`
TUI struct { TUI struct {
@@ -37,6 +41,7 @@ type Config struct {
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"` DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
DefaultCaptureResponse bool `mapstructure:"default_capture_response"` DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
AutoForwardRegex []string `mapstructure:"auto_forward_regex"` AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
QueueSize int `mapstructure:"queue_size"`
} `mapstructure:"intercept"` } `mapstructure:"intercept"`
Replay struct { Replay struct {
@@ -45,6 +50,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"`
@@ -64,7 +70,7 @@ func Load(path string) error {
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.SetConfigFile(path) viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
if !os.IsNotExist(err) { if !errors.Is(err, os.ErrNotExist) {
return err return err
} }
} }
@@ -73,6 +79,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, 0o600); 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()
+29 -13
View File
@@ -5,10 +5,14 @@ app:
project_dir: ~/.local/share/spilltea project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins plugins_dir: ~/.config/spilltea/plugins
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888 upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
intercept: intercept:
default_intercept_enabled: true default_intercept_enabled: true
default_capture_response: false default_capture_response: false
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
auto_forward_regex: auto_forward_regex:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' - '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
@@ -17,6 +21,7 @@ replay:
history: history:
skip_duplicates: true # 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: false use_nerdfont_icons: false
@@ -43,53 +48,58 @@ tui:
keybindings: keybindings:
global: global:
quit: "q,ctrl+c" quit: "q,ctrl+c"
help: "?"
open_logs: "ctrl+g" open_logs: "ctrl+g"
toggle_sidebar: "ctrl+b" toggle_sidebar: "ctrl+b"
help: "?" cycle_focus: "tab"
send_to_replay: "ctrl+r"
send_to_diff: "ctrl+d"
copy_as: "ctrl+y"
copy: "y"
up: "up,k" up: "up,k"
down: "down,j" down: "down,j"
left: "left,h" left: "left,h"
right: "right,l" right: "right,l"
cycle_focus: "tab" goto_top: "g"
copy_as: "ctrl+y" goto_bottom: "G,end"
copy: "y"
send_to_replay: "ctrl+r"
scroll_up: "pgup" scroll_up: "pgup"
scroll_down: "pgdown" scroll_down: "pgdown"
send_to_diff: "ctrl+d" prev_page: "["
next_page: "]"
intercept: intercept:
toggle_intercept: "i"
capture_response: "r"
forward: "f" forward: "f"
forward_all: "F" forward_all: "F"
drop: "d" drop: "d"
drop_all: "D" drop_all: "D"
toggle_intercept: "i"
capture_response: "r"
undo_edits: "ctrl+z"
edit: "e,enter" edit: "e,enter"
edit_external: "E" edit_external: "E"
undo_edits: "ctrl+z"
history: history:
delete_entry: "x" delete_entry: "x"
delete_all: "X" delete_all: "X"
sql_query: ":" sql_query: ":"
filter: "/" filter: "/"
flag: "m"
home: home:
open: "enter,l" open: "l,enter"
delete: "x" delete: "x"
filter: "/" filter: "/"
replay: replay:
send: "enter,s" send: "s, enter"
edit: "e" edit: "e"
edit_external: "E" edit_external: "E"
undo_edits: "R" undo_edits: "ctrl+z"
delete_entry: "x" delete_entry: "x"
delete_all: "X" delete_all: "X"
diff: diff:
clear: "c" clear: "x"
findings: findings:
dismiss: "x" dismiss: "x"
@@ -98,3 +108,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"
+13
View File
@@ -16,6 +16,10 @@ type GlobalKeys struct {
ScrollUp string `mapstructure:"scroll_up"` ScrollUp string `mapstructure:"scroll_up"`
ScrollDown string `mapstructure:"scroll_down"` ScrollDown string `mapstructure:"scroll_down"`
SendToDiff string `mapstructure:"send_to_diff"` SendToDiff string `mapstructure:"send_to_diff"`
GotoTop string `mapstructure:"goto_top"`
GotoBottom string `mapstructure:"goto_bottom"`
PrevPage string `mapstructure:"prev_page"`
NextPage string `mapstructure:"next_page"`
} }
type InterceptKeys struct { type InterceptKeys struct {
@@ -35,6 +39,7 @@ type HistoryKeys struct {
DeleteAll string `mapstructure:"delete_all"` DeleteAll string `mapstructure:"delete_all"`
Filter string `mapstructure:"filter"` Filter string `mapstructure:"filter"`
SqlQuery string `mapstructure:"sql_query"` SqlQuery string `mapstructure:"sql_query"`
Flag string `mapstructure:"flag"`
} }
type HomeKeys struct { type HomeKeys struct {
@@ -66,6 +71,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"`
@@ -75,4 +87,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"`
} }
+31 -7
View File
@@ -2,12 +2,16 @@ package db
import ( import (
"database/sql" "database/sql"
"sync"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
type DB struct { type DB struct {
conn *sql.DB conn *sql.DB
roConn *sql.DB
path string
dedupMu sync.Mutex
} }
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
@@ -15,15 +19,33 @@ func Open(path string) (*DB, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
d := &DB{conn: conn} // SQLite only supports one concurrent writer; a pool of connections would
// cause SQLITE_BUSY errors when multiple proxy goroutines try to insert
// history entries at the same time.
conn.SetMaxOpenConns(1)
d := &DB{conn: conn, path: path}
if err := d.migrate(); err != nil { if err := d.migrate(); err != nil {
conn.Close() conn.Close()
return nil, err return nil, err
} }
roConn, err := sql.Open("sqlite", path)
if err != nil {
conn.Close()
return nil, err
}
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
conn.Close()
roConn.Close()
return nil, err
}
d.roConn = roConn
return d, nil return d, nil
} }
func (d *DB) migrate() error { func (d *DB) migrate() error {
if _, err := d.conn.Exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;`); err != nil {
return err
}
_, err := d.conn.Exec(` _, err := d.conn.Exec(`
CREATE TABLE IF NOT EXISTS entries ( CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -33,7 +55,9 @@ func (d *DB) migrate() error {
path TEXT NOT NULL, path TEXT NOT NULL,
status_code INTEGER NOT NULL, status_code INTEGER NOT NULL,
request_raw TEXT NOT NULL, request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL response_raw TEXT NOT NULL,
body_hash TEXT NOT NULL DEFAULT '',
flagged INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS replay_entries ( CREATE TABLE IF NOT EXISTS replay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -48,11 +72,6 @@ CREATE TABLE IF NOT EXISTS replay_entries (
status_code INTEGER NOT NULL, status_code INTEGER NOT NULL,
error_msg TEXT NOT NULL error_msg TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS plugins (
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
config_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS findings ( CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_name TEXT NOT NULL, plugin_name TEXT NOT NULL,
@@ -65,6 +84,10 @@ CREATE TABLE IF NOT EXISTS replay_entries (
UNIQUE(plugin_name, dedup_key) UNIQUE(plugin_name, dedup_key)
); );
`) `)
if err != nil {
return err
}
_, err = d.conn.Exec(`CREATE INDEX IF NOT EXISTS idx_entries_dedup ON entries(method, host, path, body_hash)`)
return err return err
} }
@@ -78,6 +101,7 @@ func (d *DB) Close() error {
if d == nil { if d == nil {
return nil return nil
} }
_ = d.roConn.Close()
return d.conn.Close() return d.conn.Close()
} }
+57 -42
View File
@@ -1,8 +1,10 @@
package db package db
import ( import (
"crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"strings" "strings"
"time" "time"
) )
@@ -16,47 +18,57 @@ type Entry struct {
StatusCode int StatusCode int
RequestRaw string RequestRaw string
ResponseRaw string ResponseRaw string
Flagged bool
}
func bodyHash(body string) string {
sum := sha256.Sum256([]byte(body))
return fmt.Sprintf("%x", sum)
} }
// HasDuplicate returns true if an entry with the same method, host, path and // HasDuplicate returns true if an entry with the same method, host, path and
// request body already exists. Used to implement skip_duplicates filtering. // request body hash already exists.
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) { func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
rows, err := d.conn.Query( hash := bodyHash(body)
`SELECT request_raw FROM entries WHERE method = ? AND host = ? AND path = ?`, var exists int
method, host, path, err := d.conn.QueryRow(
) `SELECT 1 FROM entries WHERE method = ? AND host = ? AND path = ? AND body_hash = ? LIMIT 1`,
if err != nil { method, host, path, hash,
return false, err ).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
} }
defer rows.Close() return err == nil, err
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return false, err
}
parts := strings.SplitN(raw, "\n\n", 2)
entryBody := ""
if len(parts) == 2 {
entryBody = parts[1]
}
if entryBody == body {
return true, nil
}
}
return false, rows.Err()
} }
func (d *DB) InsertEntry(e Entry) (Entry, error) { // InsertIfNotDuplicate atomically checks for a duplicate and inserts if none
// exists. Returns (entry, isDuplicate, error).
func (d *DB) InsertIfNotDuplicate(e Entry, body string) (Entry, bool, error) {
d.dedupMu.Lock()
defer d.dedupMu.Unlock()
dup, err := d.HasDuplicate(e.Method, e.Host, e.Path, body)
if err != nil || dup {
return e, dup, err
}
e, err = d.InsertEntry(e, body)
return e, false, err
}
func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
res, err := d.conn.Exec( res, err := d.conn.Exec(
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw) `INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw, body_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UTC().Format(time.RFC3339), e.Timestamp.UTC().Format(time.RFC3339),
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw, e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw, bodyHash(body),
) )
if err != nil { if err != nil {
return e, err return e, err
} }
e.ID, _ = res.LastInsertId() var idErr error
e.ID, idErr = res.LastInsertId()
if idErr != nil {
log.Printf("db: LastInsertId: %v", idErr)
}
return e, nil return e, nil
} }
@@ -65,10 +77,12 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
for rows.Next() { for rows.Next() {
var e Entry var e Entry
var ts string var ts string
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw); err != nil { var flagged int
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
return nil, err return nil, err
} }
e.Timestamp, _ = time.Parse(time.RFC3339, ts) e.Timestamp, _ = time.Parse(time.RFC3339, ts)
e.Flagged = flagged != 0
entries = append(entries, e) entries = append(entries, e)
} }
return entries, rows.Err() return entries, rows.Err()
@@ -76,7 +90,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
func (d *DB) ListEntries() ([]Entry, error) { func (d *DB) ListEntries() ([]Entry, error) {
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw `SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
FROM entries ORDER BY id DESC`, FROM entries ORDER BY id DESC`,
) )
if err != nil { if err != nil {
@@ -89,7 +103,7 @@ func (d *DB) ListEntries() ([]Entry, error) {
func (d *DB) SearchEntries(term string) ([]Entry, error) { func (d *DB) SearchEntries(term string) ([]Entry, error) {
like := "%" + term + "%" like := "%" + term + "%"
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw `SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
FROM entries FROM entries
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ? WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
ORDER BY id DESC`, ORDER BY id DESC`,
@@ -102,17 +116,13 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
return scanEntries(rows) return scanEntries(rows)
} }
// QueryEntries executes a user-supplied query against the entries table. // QueryEntries runs a WHERE expression supplied by the user against the entries
// If the query does not start with SELECT, it is treated as a WHERE expression // table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
// and wrapped automatically (e.g. "status_code = 404" becomes a full SELECT). // Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) { // DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
q := strings.TrimSpace(rawSQL) func (d *DB) QueryEntries(where string) ([]Entry, error) {
if !strings.HasPrefix(strings.ToUpper(q), "SELECT") { q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
q = "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw FROM entries WHERE " + q rows, err := d.roConn.Query(q)
} else if strings.ContainsAny(strings.ToUpper(q), "INSERTDELETEUPDATEDROP") {
return nil, fmt.Errorf("only SELECT queries are allowed")
}
rows, err := d.conn.Query(q)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -120,6 +130,11 @@ func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
return scanEntries(rows) return scanEntries(rows)
} }
func (d *DB) ToggleFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE entries SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
func (d *DB) DeleteEntry(id int64) error { func (d *DB) DeleteEntry(id int64) error {
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id) _, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
return err return err
+3 -1
View File
@@ -17,6 +17,8 @@ type Finding struct {
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does // UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
// not already exist. Returns true when the row was actually inserted. // not already exist. Returns true when the row was actually inserted.
func (d *DB) UpsertFinding(f Finding) (bool, error) { func (d *DB) UpsertFinding(f Finding) (bool, error) {
d.dedupMu.Lock()
defer d.dedupMu.Unlock()
res, err := d.conn.Exec( res, err := d.conn.Exec(
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at) `INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`, VALUES (?, ?, ?, ?, ?, 0, ?)`,
@@ -33,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
func (d *DB) LoadFindings() ([]Finding, error) { func (d *DB) LoadFindings() ([]Finding, error) {
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at `SELECT id, plugin_name, dedup_key, title, description, severity, created_at
FROM findings WHERE dismissed = 0 ORDER BY id DESC`, FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
) )
if err != nil { if err != nil {
return nil, err return nil, err
-35
View File
@@ -1,35 +0,0 @@
package db
type PluginState struct {
Name string
Enabled bool
ConfigText string
}
func (d *DB) SavePluginState(name string, enabled bool, configText string) error {
_, err := d.conn.Exec(
`INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`,
name, enabled, configText,
)
return err
}
func (d *DB) LoadPluginStates() ([]PluginState, error) {
rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PluginState
for rows.Next() {
var s PluginState
var enabled int
if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil {
return nil, err
}
s.Enabled = enabled != 0
out = append(out, s)
}
return out, rows.Err()
}
+2
View File
@@ -20,6 +20,7 @@ type Icons struct {
New string New string
Temp string Temp string
Project string Project string
Flag string
} }
var I *Icons var I *Icons
@@ -44,6 +45,7 @@ func Init(cfg *config.Config) {
New: "󰐕 ", New: "󰐕 ",
Temp: "󰙨 ", Temp: "󰙨 ",
Project: "󰉋 ", Project: "󰉋 ",
Flag: "󰈻 ",
} }
} else { } else {
I = &Icons{} I = &Icons{}
+35 -13
View File
@@ -1,6 +1,7 @@
package intercept package intercept
import ( import (
"log"
"regexp" "regexp"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -57,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
} }
func NewBroker() *Broker { func NewBroker() *Broker {
size := config.Global.Intercept.QueueSize
if size <= 0 {
size = 64
}
b := &Broker{ b := &Broker{
Incoming: make(chan *PendingRequest, 64), Incoming: make(chan *PendingRequest, size),
IncomingResponse: make(chan *PendingResponse, 64), IncomingResponse: make(chan *PendingResponse, size),
} }
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex) b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
return b return b
@@ -75,9 +80,12 @@ func (b *Broker) SetCaptureResponse(v bool) {
func (b *Broker) SetAutoForwardRegex(patterns []string) { func (b *Broker) SetAutoForwardRegex(patterns []string) {
compiled := make([]*regexp.Regexp, 0, len(patterns)) compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns { for _, p := range patterns {
if r, err := regexp.Compile(p); err == nil { r, err := regexp.Compile(p)
compiled = append(compiled, r) if err != nil {
log.Printf("intercept: invalid auto_forward_regex %q: %v", p, err)
continue
} }
compiled = append(compiled, r)
} }
b.autoFwdMu.Lock() b.autoFwdMu.Lock()
b.autoFwdRegexes = compiled b.autoFwdRegexes = compiled
@@ -164,12 +172,7 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
if path == "" { if path == "" {
path = "/" path = "/"
} }
if config.Global.History.SkipDuplicates {
body := string(r.Body) body := string(r.Body)
if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup {
return
}
}
pending := db.Entry{ pending := db.Entry{
Timestamp: time.Now(), Timestamp: time.Now(),
Method: r.Method, Method: r.Method,
@@ -177,20 +180,39 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
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 := b.onBeforeNewEntry; cb != nil {
if !cb(pending) { if !cb(pending) {
return return
} }
} }
entry, err := d.InsertEntry(pending) var (
if err == nil { entry db.Entry
err error
)
if config.Global.History.SkipDuplicates {
var dup bool
entry, dup, err = d.InsertIfNotDuplicate(pending, body)
if dup {
return
}
} else {
entry, err = d.InsertEntry(pending, body)
}
if err != nil {
log.Printf("intercept: failed to save history entry: %v", err)
return
}
if cb := b.onNewEntry; cb != nil { if cb := b.onNewEntry; cb != nil {
go cb(entry) go cb(entry)
} }
} }
}
func (b *Broker) Decide(p *PendingRequest, d Decision) { func (b *Broker) Decide(p *PendingRequest, d Decision) {
p.decision <- d p.decision <- d
+5 -19
View File
@@ -3,9 +3,9 @@ package intercept
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"sort"
"strings" "strings"
"github.com/anotherhadi/spilltea/internal/util"
"github.com/lqqyt2423/go-mitmproxy/proxy" "github.com/lqqyt2423/go-mitmproxy/proxy"
) )
@@ -14,15 +14,8 @@ func FormatRawRequest(f *proxy.Flow) string {
r := f.Request r := f.Request
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto) fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
keys := make([]string, 0, len(r.Header)) for _, line := range util.SortedHeaderLines(r.Header) {
for k := range r.Header { sb.WriteString(line)
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
} }
sb.WriteString("\n") sb.WriteString("\n")
if len(r.Body) > 0 { if len(r.Body) > 0 {
@@ -43,15 +36,8 @@ func FormatRawResponse(f *proxy.Flow) string {
proto = "HTTP/1.1" proto = "HTTP/1.1"
} }
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode)) fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
keys := make([]string, 0, len(r.Header)) for _, line := range util.SortedHeaderLines(r.Header) {
for k := range r.Header { sb.WriteString(line)
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
} }
sb.WriteString("\n") sb.WriteString("\n")
if len(r.Body) > 0 { if len(r.Body) > 0 {
+26
View File
@@ -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}
}
+14
View File
@@ -22,6 +22,10 @@ type GlobalKeyMap struct {
ScrollUp key.Binding ScrollUp key.Binding
ScrollDown key.Binding ScrollDown key.Binding
SendToDiff key.Binding SendToDiff key.Binding
GotoTop key.Binding
GotoBottom key.Binding
PrevPage key.Binding
NextPage key.Binding
} }
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap { func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
@@ -42,6 +46,10 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
ScrollUp: binding(cfg.ScrollUp, "scroll up"), ScrollUp: binding(cfg.ScrollUp, "scroll up"),
ScrollDown: binding(cfg.ScrollDown, "scroll down"), ScrollDown: binding(cfg.ScrollDown, "scroll down"),
SendToDiff: binding(cfg.SendToDiff, "send to diff"), SendToDiff: binding(cfg.SendToDiff, "send to diff"),
GotoTop: binding(cfg.GotoTop, "go to top"),
GotoBottom: binding(cfg.GotoBottom, "go to bottom"),
PrevPage: binding(cfg.PrevPage, "prev page"),
NextPage: binding(cfg.NextPage, "next page"),
} }
} }
@@ -52,5 +60,11 @@ func (g GlobalKeyMap) Bindings() []key.Binding {
g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy, g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
g.SendToReplay, g.SendToDiff, g.SendToReplay, g.SendToDiff,
g.ScrollUp, g.ScrollDown, g.ScrollUp, g.ScrollDown,
g.GotoTop, g.GotoBottom, g.PrevPage, g.NextPage,
} }
} }
// CommonBindings returns keys available on every page.
func (g GlobalKeyMap) CommonBindings() []key.Binding {
return []key.Binding{g.Quit, g.Help, g.OpenLogs, g.ToggleSidebar}
}
+3 -1
View File
@@ -10,6 +10,7 @@ type HistoryKeyMap struct {
DeleteAll key.Binding DeleteAll key.Binding
Filter key.Binding Filter key.Binding
SqlQuery key.Binding SqlQuery key.Binding
Flag key.Binding
} }
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap { func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
@@ -18,9 +19,10 @@ func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
DeleteAll: binding(cfg.DeleteAll, "delete all"), DeleteAll: binding(cfg.DeleteAll, "delete all"),
Filter: binding(cfg.Filter, "filter"), Filter: binding(cfg.Filter, "filter"),
SqlQuery: binding(cfg.SqlQuery, "sql query"), SqlQuery: binding(cfg.SqlQuery, "sql query"),
Flag: binding(cfg.Flag, "flag"),
} }
} }
func (h HistoryKeyMap) Bindings() []key.Binding { func (h HistoryKeyMap) Bindings() []key.Binding {
return []key.Binding{h.DeleteEntry, h.DeleteAll} return []key.Binding{h.DeleteEntry, h.DeleteAll, h.Flag}
} }
+2
View File
@@ -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),
} }
} }
+102 -8
View File
@@ -1,17 +1,39 @@
package plugins package plugins
import ( import (
"bytes"
"context"
"log" "log"
"os/exec"
"strings" "strings"
"time" "time"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
"gopkg.in/yaml.v3"
) )
func newLuaState(mgr *Manager, p *Plugin) *lua.LState { func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
L := lua.NewState() L := lua.NewState(lua.Options{SkipOpenLibs: true})
for _, lib := range []struct {
name string
fn lua.LGFunction
}{
{lua.BaseLibName, lua.OpenBase},
{lua.TabLibName, lua.OpenTable},
{lua.StringLibName, lua.OpenString},
{lua.MathLibName, lua.OpenMath},
{lua.CoroutineLibName, lua.OpenCoroutine},
} {
L.Push(L.NewFunction(lib.fn))
L.Push(lua.LString(lib.name))
L.Call(1, 0)
}
// Remove filesystem-access functions to prevent plugins from reading/executing arbitrary files.
for _, name := range []string{"dofile", "loadfile", "load"} {
L.SetGlobal(name, lua.LNil)
}
registerUtilities(L, mgr, p) registerUtilities(L, mgr, p)
return L return L
} }
@@ -153,6 +175,81 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
} }
return 0 return 0
})) }))
L.SetGlobal("get_config", L.NewFunction(func(L *lua.LState) int {
// p.mu is already held by the hook caller - do not lock again.
configText := p.ConfigText
if configText == "" {
L.Push(L.NewTable())
return 1
}
var data interface{}
if err := yaml.Unmarshal([]byte(configText), &data); err != nil || data == nil {
L.Push(L.NewTable())
return 1
}
lv := goToLuaValue(L, data)
if _, ok := lv.(*lua.LTable); !ok {
L.Push(L.NewTable())
return 1
}
L.Push(lv)
return 1
}))
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
cmd := L.CheckString(1)
input := L.OptString(2, "")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "sh", "-c", cmd)
c.Stdin = strings.NewReader(input)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
L.Push(lua.LString(stdout.String()))
L.Push(lua.LString(err.Error() + ": " + stderr.String()))
return 2
}
L.Push(lua.LString(stdout.String()))
L.Push(lua.LNil)
return 2
}))
}
func goToLuaValue(L *lua.LState, v interface{}) lua.LValue {
switch val := v.(type) {
case map[string]interface{}:
t := L.NewTable()
for k, v2 := range val {
L.SetField(t, k, goToLuaValue(L, v2))
}
return t
case []interface{}:
t := L.NewTable()
for i, v2 := range val {
L.RawSetInt(t, i+1, goToLuaValue(L, v2))
}
return t
case string:
return lua.LString(val)
case int:
return lua.LNumber(val)
case float64:
return lua.LNumber(val)
case bool:
if val {
return lua.LTrue
}
return lua.LFalse
}
return lua.LNil
} }
func luaTableString(t *lua.LTable, key string) string { func luaTableString(t *lua.LTable, key string) string {
@@ -246,22 +343,19 @@ func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
return t return t
} }
func callHook(p *Plugin, hookName string, args ...lua.LValue) (string, error) { func callHook(p *Plugin, hookName string, args ...lua.LValue) (lua.LValue, error) {
fn := p.L.GetGlobal(hookName) fn := p.L.GetGlobal(hookName)
if fn == lua.LNil { if fn == lua.LNil {
return "", nil return lua.LNil, nil
} }
if err := p.L.CallByParam(lua.P{ if err := p.L.CallByParam(lua.P{
Fn: fn, Fn: fn,
NRet: 1, NRet: 1,
Protect: true, Protect: true,
}, args...); err != nil { }, args...); err != nil {
return "", err return lua.LNil, err
} }
ret := p.L.Get(-1) ret := p.L.Get(-1)
p.L.Pop(1) p.L.Pop(1)
if s, ok := ret.(lua.LString); ok { return ret, nil
return string(s), nil
}
return "", nil
} }
+147 -125
View File
@@ -20,6 +20,7 @@ type Manager struct {
plugins []*Plugin plugins []*Plugin
db *db.DB db *db.DB
pluginsFile *PluginsFile
broker *intercept.Broker broker *intercept.Broker
Notifs chan PluginNotifMsg Notifs chan PluginNotifMsg
@@ -43,6 +44,10 @@ func (m *Manager) SetDB(d *db.DB) {
m.db = d m.db = d
} }
func (m *Manager) SetPluginsFile(pf *PluginsFile) {
m.pluginsFile = pf
}
func (m *Manager) LoadFromDir(dir string) error { func (m *Manager) LoadFromDir(dir string) error {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -52,17 +57,6 @@ func (m *Manager) LoadFromDir(dir string) error {
return err return err
} }
var states map[string]db.PluginState
if m.db != nil {
list, err := m.db.LoadPluginStates()
if err == nil {
states = make(map[string]db.PluginState, len(list))
for _, s := range list {
states[s.Name] = s
}
}
}
for _, e := range entries { for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue continue
@@ -73,19 +67,27 @@ func (m *Manager) LoadFromDir(dir string) error {
log.Printf("plugin load error %s: %v", path, err) log.Printf("plugin load error %s: %v", path, err)
continue continue
} }
if s, ok := states[p.Name]; ok { if m.pluginsFile != nil {
p.Enabled = s.Enabled if enabled, configText, found := m.pluginsFile.get(p.ID); found {
p.ConfigText = s.ConfigText p.Enabled = enabled
p.ConfigText = configText
} else {
m.pluginsFile.register(p.ID, p.Enabled)
}
} }
m.mu.Lock() m.mu.Lock()
m.plugins = append(m.plugins, p) m.plugins = append(m.plugins, p)
m.mu.Unlock() m.mu.Unlock()
} }
m.mu.Lock()
sort.Slice(m.plugins, func(i, j int) bool { return m.plugins[i].Priority > m.plugins[j].Priority })
m.mu.Unlock()
return nil return nil
} }
func (m *Manager) loadPlugin(path string) (*Plugin, error) { func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p := &Plugin{ p := &Plugin{
ID: strings.TrimSuffix(filepath.Base(path), ".lua"),
FilePath: path, FilePath: path,
Enabled: true, Enabled: true,
hooks: make(map[string]HookConfig), hooks: make(map[string]HookConfig),
@@ -106,7 +108,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Name = string(s) p.Name = string(s)
} }
if p.Name == "" { if p.Name == "" {
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua") p.Name = p.ID
} }
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok { if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
@@ -117,6 +119,10 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Priority = int(n) p.Priority = int(n)
} }
if pluginTable.RawGetString("disable_by_default") == lua.LTrue {
p.Enabled = false
}
// Hooks configurable via the Plugin table (sync field). // Hooks configurable via the Plugin table (sync field).
configurableHooks := map[string]bool{ configurableHooks := map[string]bool{
"on_start": false, // async by default "on_start": false, // async by default
@@ -124,12 +130,6 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
"on_response": false, "on_response": false,
"on_history_entry": false, "on_history_entry": false,
} }
// Fixed-sync hooks: always sync, not configurable.
fixedSyncHooks := map[string]struct{}{
"on_config": {},
"on_quit": {},
}
for hookName, defaultSync := range configurableHooks { for hookName, defaultSync := range configurableHooks {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue} p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
@@ -139,9 +139,9 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.hooks[hookName] = HookConfig{Sync: defaultSync} p.hooks[hookName] = HookConfig{Sync: defaultSync}
} }
} }
for hookName := range fixedSyncHooks { for _, fixedSync := range []string{"on_config", "on_quit"} {
if p.L.GetGlobal(hookName) != lua.LNil { if p.L.GetGlobal(fixedSync) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: true} p.hooks[fixedSync] = HookConfig{Sync: true}
} }
} }
@@ -153,15 +153,14 @@ func (m *Manager) GetPlugins() []*Plugin {
defer m.mu.RUnlock() defer m.mu.RUnlock()
out := make([]*Plugin, len(m.plugins)) out := make([]*Plugin, len(m.plugins))
copy(out, m.plugins) copy(out, m.plugins)
sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority })
return out return out
} }
func (m *Manager) TogglePlugin(name string) { func (m *Manager) TogglePlugin(id string) {
m.mu.RLock() m.mu.RLock()
var found *Plugin var found *Plugin
for _, p := range m.plugins { for _, p := range m.plugins {
if p.Name == name { if p.ID == id {
found = p found = p
break break
} }
@@ -173,18 +172,57 @@ func (m *Manager) TogglePlugin(name string) {
found.mu.Lock() found.mu.Lock()
found.Enabled = !found.Enabled found.Enabled = !found.Enabled
enabled := found.Enabled enabled := found.Enabled
configText := found.ConfigText
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.pluginsFile != nil {
_ = m.db.SavePluginState(name, enabled, configText) if err := m.pluginsFile.setEnabled(id, enabled); err != nil {
log.Printf("plugin %s: save state: %v", id, err)
}
}
if !enabled {
return
}
hc, ok := found.hooks["on_start"]
if !ok {
return
}
disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse {
p.Enabled = false
if m.pluginsFile != nil {
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
log.Printf("plugin %s: save state: %v", p.ID, err)
}
}
}
}
if hc.Sync {
found.mu.Lock()
ret, err := callHook(found, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", found.Name, err)
} else {
disableIfFalse(found, ret)
}
found.mu.Unlock()
} else {
go func() {
found.mu.Lock()
ret, err := callHook(found, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", found.Name, err)
} else {
disableIfFalse(found, ret)
}
found.mu.Unlock()
}()
} }
} }
func (m *Manager) SaveConfig(name, configText string) { func (m *Manager) SaveConfig(id, configText string) {
m.mu.RLock() m.mu.RLock()
var found *Plugin var found *Plugin
for _, p := range m.plugins { for _, p := range m.plugins {
if p.Name == name { if p.ID == id {
found = p found = p
break break
} }
@@ -195,39 +233,35 @@ func (m *Manager) SaveConfig(name, configText string) {
} }
found.mu.Lock() found.mu.Lock()
found.ConfigText = configText found.ConfigText = configText
enabled := found.Enabled
_, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.pluginsFile != nil {
_ = m.db.SavePluginState(name, enabled, configText) if err := m.pluginsFile.setConfig(id, configText); err != nil {
log.Printf("plugin %s: save config: %v", id, err)
} }
if !hasOnConfig { }
if _, ok := found.hooks["on_config"]; !ok {
return return
} }
// on_config is always sync.
found.mu.Lock() found.mu.Lock()
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil { if _, err := callHook(found, "on_config"); err != nil {
log.Printf("plugin %s on_config (config reload): %v", name, err) log.Printf("plugin %s on_config: %v", id, err)
} }
found.mu.Unlock() found.mu.Unlock()
} }
func (m *Manager) RunOnStart() { func (m *Manager) RunOnStart() {
// on_config runs first, always sync, for every enabled plugin that has it.
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_config"]; !ok { if _, ok := p.hooks["on_config"]; ok {
continue
}
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil { if _, err := callHook(p, "on_config"); err != nil {
log.Printf("plugin %s on_config: %v", p.Name, err) log.Printf("plugin %s on_config: %v", p.Name, err)
} }
p.mu.Unlock() p.mu.Unlock()
} }
// on_start runs after, sync or async depending on plugin config. }
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
@@ -236,17 +270,33 @@ func (m *Manager) RunOnStart() {
if !ok { if !ok {
continue continue
} }
disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse {
p.Enabled = false
if m.pluginsFile != nil {
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
log.Printf("plugin %s: save state: %v", p.ID, err)
}
}
}
}
if hc.Sync { if hc.Sync {
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil { ret, err := callHook(p, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err) log.Printf("plugin %s on_start: %v", p.Name, err)
} else {
disableIfFalse(p, ret)
} }
p.mu.Unlock() p.mu.Unlock()
} else { } else {
go func(p *Plugin) { go func(p *Plugin) {
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil { ret, err := callHook(p, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err) log.Printf("plugin %s on_start: %v", p.Name, err)
} else {
disableIfFalse(p, ret)
} }
p.mu.Unlock() p.mu.Unlock()
}(p) }(p)
@@ -270,94 +320,79 @@ func (m *Manager) RunOnQuit() {
} }
} }
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision { // runSyncDecisionForPlugins runs hookName synchronously for all enabled plugins
// that registered it as sync, and returns the first non-Intercept decision.
func (m *Manager) runSyncDecisionForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) intercept.Decision {
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
hc, ok := p.hooks["on_request"] hc, ok := p.hooks[hookName]
if !ok || !hc.Sync { if !ok || !hc.Sync {
continue continue
} }
p.mu.Lock() p.mu.Lock()
result, err := callHook(p, "on_request", pushRequest(p.L, f)) result, err := callHook(p, hookName, argsFor(p)...)
p.mu.Unlock() p.mu.Unlock()
if err != nil { if err != nil {
log.Printf("plugin %s on_request: %v", p.Name, err) log.Printf("plugin %s %s: %v", p.Name, hookName, err)
continue continue
} }
switch result { if s, ok := result.(lua.LString); ok {
switch string(s) {
case "drop": case "drop":
return intercept.Drop return intercept.Drop
case "forward": case "forward":
return intercept.Forward return intercept.Forward
} }
} }
}
return intercept.Intercept return intercept.Intercept
} }
// runAsyncForPlugins fires hookName asynchronously for all enabled plugins
// that registered it as async.
func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks[hookName]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, hookName, argsFor(p)...); err != nil {
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
}
p.mu.Unlock()
}(p)
}
}
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
return []lua.LValue{pushRequest(p.L, f)}
})
}
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) { func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
for _, p := range m.GetPlugins() { m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushRequest(p.L, f)}
continue })
}
hc, ok := p.hooks["on_request"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_request", pushRequest(p.L, f)); err != nil {
log.Printf("plugin %s on_request: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision { func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
for _, p := range m.GetPlugins() { return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
continue })
}
hc, ok := p.hooks["on_response"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_response: %v", p.Name, err)
continue
}
switch result {
case "drop":
return intercept.Drop
case "forward":
return intercept.Forward
}
}
return intercept.Intercept
} }
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) { func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
for _, p := range m.GetPlugins() { m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
continue })
}
hc, ok := p.hooks["on_response"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)); err != nil {
log.Printf("plugin %s on_response: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving. // RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
@@ -377,7 +412,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
log.Printf("plugin %s on_history_entry: %v", p.Name, err) log.Printf("plugin %s on_history_entry: %v", p.Name, err)
continue continue
} }
if result == "skip" { if s, ok := result.(lua.LString); ok && string(s) == "skip" {
return false return false
} }
} }
@@ -385,20 +420,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
} }
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) { func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
for _, p := range m.GetPlugins() { m.runAsyncForPlugins("on_history_entry", func(p *Plugin) []lua.LValue {
if !p.Enabled { return []lua.LValue{pushEntry(p.L, e)}
continue })
}
hc, ok := p.hooks["on_history_entry"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_history_entry", pushEntry(p.L, e)); err != nil {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
+96
View File
@@ -0,0 +1,96 @@
package plugins
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type pluginFileEntry struct {
Enable bool `yaml:"enable"`
Config interface{} `yaml:"config,omitempty"`
}
type pluginsFileData struct {
Plugins map[string]pluginFileEntry `yaml:"plugins"`
}
type PluginsFile struct {
path string
data pluginsFileData
}
func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
path := filepath.Join(filepath.Dir(dbPath), "plugins.yaml")
pf := &PluginsFile{
path: path,
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return pf, nil
}
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(raw, &pf.data); err != nil {
return nil, err
}
if pf.data.Plugins == nil {
pf.data.Plugins = make(map[string]pluginFileEntry)
}
return pf, nil
}
func (pf *PluginsFile) save() error {
raw, err := yaml.Marshal(&pf.data)
if err != nil {
return err
}
return os.WriteFile(pf.path, raw, 0o600)
}
func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool) {
e, ok := pf.data.Plugins[id]
if !ok {
return false, "", false
}
if e.Config == nil {
return e.Enable, "", true
}
raw, err := yaml.Marshal(e.Config)
if err != nil {
return e.Enable, "", true
}
return e.Enable, string(raw), true
}
func (pf *PluginsFile) register(id string, defaultEnabled bool) {
if _, ok := pf.data.Plugins[id]; !ok {
pf.data.Plugins[id] = pluginFileEntry{Enable: defaultEnabled}
_ = pf.save()
}
}
func (pf *PluginsFile) setEnabled(id string, enabled bool) error {
e := pf.data.Plugins[id]
e.Enable = enabled
pf.data.Plugins[id] = e
return pf.save()
}
func (pf *PluginsFile) setConfig(id string, configText string) error {
e := pf.data.Plugins[id]
if configText == "" {
e.Config = nil
} else {
var parsed interface{}
if err := yaml.Unmarshal([]byte(configText), &parsed); err != nil {
return err
}
e.Config = parsed
}
pf.data.Plugins[id] = e
return pf.save()
}
+3
View File
@@ -11,6 +11,7 @@ type HookConfig struct {
} }
type Plugin struct { type Plugin struct {
ID string
Name string Name string
Description string Description string
FilePath string FilePath string
@@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
} }
type Info struct { type Info struct {
ID string
Name string Name string
Description string Description string
FilePath string FilePath string
@@ -57,6 +59,7 @@ func (p *Plugin) Info() Info {
hooks[k] = v hooks[k] = v
} }
return Info{ return Info{
ID: p.ID,
Name: p.Name, Name: p.Name,
Description: p.Description, Description: p.Description,
FilePath: p.FilePath, FilePath: p.FilePath,
+47 -3
View File
@@ -1,10 +1,14 @@
package proxy package proxy
import ( import (
"crypto/subtle"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"strings"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/config"
@@ -43,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
switch a.plugins.RunSyncOnRequest(f) { switch a.plugins.RunSyncOnRequest(f) {
case intercept.Drop: case intercept.Drop:
f.Response = dropResponse() f.Response = dropResponse()
go a.plugins.RunAsyncOnRequest(f)
return return
case intercept.Forward: case intercept.Forward:
go a.plugins.RunAsyncOnRequest(f) go a.plugins.RunAsyncOnRequest(f)
@@ -63,7 +66,15 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
func (a *interceptAddon) Response(f *goproxy.Flow) { func (a *interceptAddon) Response(f *goproxy.Flow) {
if f.Response != nil { if f.Response != nil {
if len(f.Response.Body) == 0 && f.Response.BodyReader != nil { if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
body, _ := io.ReadAll(f.Response.BodyReader) limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
body, err := io.ReadAll(io.LimitReader(f.Response.BodyReader, limit))
if err != nil {
log.Printf("proxy: reading response body: %v", err)
}
if int64(len(body)) == limit {
log.Printf("proxy: response body truncated at %dMB for %s", config.Global.App.MaxBodySizeMB, f.Request.URL.Host)
body = append(body, []byte(fmt.Sprintf("\n\n[body truncated at %dMB]", config.Global.App.MaxBodySizeMB))...)
}
f.Response.Body = body f.Response.Body = body
f.Response.BodyReader = nil f.Response.BodyReader = nil
} }
@@ -106,7 +117,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
opts := &goproxy.Options{ opts := &goproxy.Options{
Addr: addr, Addr: addr,
StreamLargeBodies: 1024 * 1024 * 5, StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
CaRootPath: caPath, CaRootPath: caPath,
Upstream: cfg.UpstreamProxy, Upstream: cfg.UpstreamProxy,
} }
@@ -116,10 +127,43 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
return err return err
} }
if cfg.ProxyAuth != "" {
parts := strings.SplitN(cfg.ProxyAuth, ":", 2)
if len(parts) == 2 {
wantUser, wantPass := parts[0], parts[1]
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(wantUser))
passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(wantPass))
if !ok || userOK&passOK != 1 {
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
return false, fmt.Errorf("invalid credentials")
}
return true, nil
})
}
}
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr}) p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
return p.Start() return p.Start()
} }
func parseBasicProxyAuth(header string) (user, pass string, ok bool) {
const prefix = "Basic "
if !strings.HasPrefix(header, prefix) {
return "", "", false
}
decoded, err := base64.StdEncoding.DecodeString(header[len(prefix):])
if err != nil {
return "", "", false
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
func dropResponse() *goproxy.Response { func dropResponse() *goproxy.Response {
return &goproxy.Response{ return &goproxy.Response{
StatusCode: 502, StatusCode: 502,
+19 -2
View File
@@ -15,6 +15,25 @@ func NewViewport() viewport.Model {
return vp return vp
} }
func ViewportView(vp *viewport.Model) string {
v := vp.View()
if vp.AtBottom() {
return v
}
lines := strings.Split(v, "\n")
if len(lines) == 0 {
return v
}
arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow)
inner := vp.Width() - 2*arrowW
if inner < 0 {
inner = 0
}
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
return strings.Join(lines, "\n")
}
func NewPaginator() paginator.Model { func NewPaginator() paginator.Model {
p := paginator.New() p := paginator.New()
p.Type = paginator.Dots p.Type = paginator.Dots
@@ -46,7 +65,6 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
return ta return ta
} }
// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
func SeverityStyle(sev string) lipgloss.Style { func SeverityStyle(sev string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true) base := lipgloss.NewStyle().Bold(true)
switch sev { switch sev {
@@ -63,7 +81,6 @@ func SeverityStyle(sev string) lipgloss.Style {
} }
} }
// StatusStyle returns a bold lipgloss style coloured by HTTP status code.
func StatusStyle(code, width int) lipgloss.Style { func StatusStyle(code, width int) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(width) base := lipgloss.NewStyle().Bold(true).Width(width)
switch { switch {
+1 -1
View File
@@ -15,10 +15,10 @@ func Paint(c color.Color, s string) string {
return lipgloss.NewStyle().Foreground(c).Render(s) return lipgloss.NewStyle().Foreground(c).Render(s)
} }
// HighlightHTTP highlights a full raw HTTP message (headers + body).
func HighlightHTTP(raw string) string { func HighlightHTTP(raw string) string {
raw = strings.ReplaceAll(raw, "\r\n", "\n") raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n") raw = strings.ReplaceAll(raw, "\r", "\n")
raw = strings.ReplaceAll(raw, "\t", " ")
idx := strings.Index(raw, "\n\n") idx := strings.Index(raw, "\n\n")
if idx == -1 { if idx == -1 {
return highlightHeaders(raw) return highlightHeaders(raw)
+18 -6
View File
@@ -28,6 +28,12 @@ type Styles struct {
PagerDotActive string PagerDotActive string
PagerDotInactive string PagerDotInactive string
methodGet lipgloss.Style
methodPost lipgloss.Style
methodPutPatch lipgloss.Style
methodDelete lipgloss.Style
methodDefault lipgloss.Style
} }
var S *Styles var S *Styles
@@ -46,6 +52,7 @@ func Init(cfg *config.Config) {
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
S = &Styles{ S = &Styles{
Primary: primary, Primary: primary,
Success: success, Success: success,
@@ -74,6 +81,12 @@ func Init(cfg *config.Config) {
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
methodGet: methodBase.Foreground(success),
methodPost: methodBase.Foreground(warning),
methodPutPatch: methodBase.Foreground(primary),
methodDelete: methodBase.Foreground(errCol),
methodDefault: methodBase.Foreground(text),
} }
} }
@@ -90,17 +103,16 @@ func NewHelp() help.Model {
} }
func (s *Styles) Method(method string) lipgloss.Style { func (s *Styles) Method(method string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(7)
switch method { switch method {
case "GET": case "GET":
return base.Foreground(s.Success) return s.methodGet
case "POST": case "POST":
return base.Foreground(s.Warning) return s.methodPost
case "PUT", "PATCH": case "PUT", "PATCH":
return base.Foreground(s.Primary) return s.methodPutPatch
case "DELETE": case "DELETE":
return base.Foreground(s.Error) return s.methodDelete
default: default:
return base.Foreground(s.Text) return s.methodDefault
} }
} }
+13 -4
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -31,10 +32,9 @@ const tickInterval = 2 * time.Second
type tickMsg struct{} type tickMsg struct{}
func tickCmd() tea.Cmd { func tickCmd() tea.Cmd {
return func() tea.Msg { return tea.Tick(tickInterval, func(time.Time) tea.Msg {
time.Sleep(tickInterval)
return tickMsg{} return tickMsg{}
} })
} }
var sidebarEntries = pageRegistry var sidebarEntries = pageRegistry
@@ -94,14 +94,23 @@ func New(broker *intercept.Broker, name, path string) Model {
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
} }
if d, err := db.Open(path); err == nil { d, err := db.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "db: %v\n", err)
os.Exit(1)
}
m.database = d m.database = d
broker.SetDB(d) broker.SetDB(d)
m.history.SetDB(d) m.history.SetDB(d)
m.replay.SetDB(d) m.replay.SetDB(d)
m.findingsPage.SetDB(d) m.findingsPage.SetDB(d)
mgr.SetDB(d) mgr.SetDB(d)
pf, err := plugins.OpenPluginsFile(path)
if err != nil {
log.Printf("plugins file: %v", err)
} }
mgr.SetPluginsFile(pf)
pluginsDir := config.ExpandPath(cfg.App.PluginsDir) pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil { if err := mgr.LoadFromDir(pluginsDir); err != nil {
+5
View File
@@ -37,6 +37,8 @@ type pageEntry struct {
isEditing func(m *Model) bool isEditing func(m *Model) bool
// resize propagates a new (w, h) to the page model. // resize propagates a new (w, h) to the page model.
resize func(m *Model, w, h int) resize func(m *Model, w, h int)
// hasUpdate reports whether the page has unseen updates.
hasUpdate func(m *Model) bool
} }
var pageRegistry = []pageEntry{ var pageRegistry = []pageEntry{
@@ -52,6 +54,7 @@ var pageRegistry = []pageEntry{
}, },
isEditing: func(m *Model) bool { return m.intercept.IsEditing() }, isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) }, resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
hasUpdate: func(m *Model) bool { return m.intercept.HasUnread() },
}, },
{ {
id: pageHistory, id: pageHistory,
@@ -115,6 +118,7 @@ var pageRegistry = []pageEntry{
return cmd return cmd
}, },
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) }, resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
hasUpdate: func(m *Model) bool { return m.findingsPage.HasUnread() },
}, },
{ {
id: pageDocs, id: pageDocs,
@@ -126,6 +130,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) },
}, },
} }
+20 -1
View File
@@ -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)
@@ -60,11 +61,15 @@ func (m *Model) renderSidebar() string {
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1) lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
var items strings.Builder var items strings.Builder
badgeUnread := lipgloss.NewStyle().Foreground(s.Warning).Bold(true)
for i, entry := range sidebarEntries { for i, entry := range sidebarEntries {
selected := entry.id == m.page selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal badgeStyle, textStyle := badgeNormal, textNormal
if selected { if selected {
badgeStyle, textStyle = badgeSelected, textSelected badgeStyle, textStyle = badgeSelected, textSelected
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
badgeStyle = badgeUnread
} }
icon := "" icon := ""
if entry.icon != nil { if entry.icon != nil {
@@ -75,14 +80,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)
} }
+63 -24
View File
@@ -37,6 +37,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case intercept.RequestArrivedMsg: case intercept.RequestArrivedMsg:
updated, cmd := m.intercept.Update(msg) updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model) m.intercept = updated.(interceptUI.Model)
if m.page == pageIntercept {
m.intercept.ClearUnread()
}
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker)) return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
case intercept.ResponseArrivedMsg: case intercept.ResponseArrivedMsg:
updated, cmd := m.intercept.Update(msg) updated, cmd := m.intercept.Update(msg)
@@ -104,6 +107,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case proxyPkg.ErrMsg: case proxyPkg.ErrMsg:
if msg.Err != nil { if msg.Err != nil {
log.Printf("proxy error: %v", msg.Err) log.Printf("proxy error: %v", msg.Err)
return m, tea.Batch(
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Proxy Error",
Body: msg.Err.Error(),
Kind: notificationsUI.KindError,
}
},
tea.Quit,
)
} }
return m, nil return m, nil
@@ -119,6 +132,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case findingsUI.FindingsLoadedMsg: case findingsUI.FindingsLoadedMsg:
updated, cmd := m.findingsPage.Update(msg) updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model) m.findingsPage = updated.(findingsUI.Model)
if m.page == pageFindings {
m.findingsPage.ClearUnread()
}
return m, cmd return m, cmd
case replayUI.SendToReplayMsg: case replayUI.SendToReplayMsg:
@@ -176,44 +192,63 @@ 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.CopyAs): case key.Matches(msg, keys.Keys.Global.CopyAs):
if m.page == pageDiff {
if raw := m.diff.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: "https",
})
}
} else if m.page == pageIntercept {
if raw := m.intercept.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: m.intercept.CurrentScheme(),
})
}
}
return m, nil
case key.Matches(msg, keys.Keys.Global.Copy):
var raw, scheme string var raw, scheme string
var responseFocused bool
switch m.page { switch m.page {
case pageIntercept: case pageIntercept:
raw = m.intercept.CurrentRaw() raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme() scheme = m.intercept.CurrentScheme()
case pageDiff: responseFocused = m.intercept.IsResponseFocused()
raw = m.diff.CurrentRaw()
scheme = "https"
case pageHistory: case pageHistory:
raw = m.history.CurrentRaw() raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme() scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay: case pageReplay:
raw = m.replay.CurrentRaw() raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme() scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
}
if raw != "" && !responseFocused {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
}
return m, nil
case key.Matches(msg, keys.Keys.Global.Copy):
if m.page == pageFindings {
if md := m.findingsPage.CurrentMarkdown(); md != "" {
return m, tea.Batch(
tea.SetClipboard(md),
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Copied",
Body: "Finding copied to clipboard",
Kind: notificationsUI.KindSuccess,
}
},
)
}
return m, nil
}
var raw, scheme string
var responseFocused bool
switch m.page {
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
responseFocused = m.intercept.IsResponseFocused()
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
} }
if raw != "" { if raw != "" {
m.copy.SetSize(m.width, m.height) m.copy.SetSize(m.width, m.height)
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme}) m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme, ShowURL: !responseFocused})
} }
return m, nil return m, nil
@@ -229,8 +264,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.history.RefreshCmd() return m, m.history.RefreshCmd()
} }
if p == pageFindings { if p == pageFindings {
m.findingsPage.ClearUnread()
return m, findingsUI.RefreshCmd(m.database) return m, findingsUI.RefreshCmd(m.database)
} }
if p == pageIntercept {
m.intercept.ClearUnread()
}
} }
} }
} }
+28 -13
View File
@@ -1,9 +1,6 @@
package copy package copy
import ( import (
"encoding/base64"
"fmt"
"os"
"strings" "strings"
"charm.land/bubbles/v2/list" "charm.land/bubbles/v2/list"
@@ -12,16 +9,15 @@ import (
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
) )
const popupInnerW = 40 const (
popupW = 55
func writeClipboard(text string) { popupH = 20
encoded := base64.StdEncoding.EncodeToString([]byte(text)) )
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
}
type OpenMsg struct { type OpenMsg struct {
RawRequest string RawRequest string
Scheme string Scheme string
ShowURL bool
} }
type copyItem struct { type copyItem struct {
@@ -66,7 +62,7 @@ func New() Model {
BorderForeground(s.Primary). BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(1) Foreground(s.MutedFg).PaddingLeft(1)
l := list.New(allItems, delegate, popupInnerW, 8) l := list.New(allItems, delegate, popupW, 8)
l.SetShowTitle(false) l.SetShowTitle(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetShowHelp(false) l.SetShowHelp(false)
@@ -87,19 +83,38 @@ func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme m.scheme = msg.Scheme
m.open = true m.open = true
items := allItems
if !msg.ShowURL {
filtered := make([]list.Item, 0, len(allItems))
for _, it := range allItems {
if it.(copyItem).id != "url" {
filtered = append(filtered, it)
}
}
items = filtered
}
m.list.SetItems(items)
m.list.ResetFilter() m.list.ResetFilter()
m.list.Select(0) m.list.Select(0)
m.list.SetSize(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 := 12 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
} }
+13 -3
View File
@@ -4,16 +4,26 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
) )
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if kp, ok := msg.(tea.KeyPressMsg); ok { if kp, ok := msg.(tea.KeyPressMsg); ok {
switch { switch {
case kp.String() == "enter": case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(copyItem); ok {
writeClipboard(m.extract(item.id))
}
m.open = false m.open = false
if item, ok := m.list.SelectedItem().(copyItem); ok {
return m, tea.Batch(
tea.SetClipboard(m.extract(item.id)),
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Copied",
Body: "Request copied to clipboard",
Kind: notificationsUI.KindSuccess,
}
},
)
}
return m, nil return m, nil
case key.Matches(kp, keys.Keys.Global.Escape): case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() { if m.list.SettingFilter() {
+1 -1
View File
@@ -22,7 +22,7 @@ func (m *Model) View(background string) string {
BorderForeground(s.Primary) BorderForeground(s.Primary)
popupH := m.popupHeight() popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy", inner, popupInnerW+2, popupH) popup := style.RenderWithTitle(border, "Copy", inner, m.popupInnerWidth()+2, popupH)
return copyasUI.OverlayCenter(background, popup, m.width, m.height) return copyasUI.OverlayCenter(background, popup, m.width, m.height)
} }
+136 -34
View File
@@ -1,8 +1,12 @@
package copyas package copyas
import ( import (
"encoding/json"
"fmt" "fmt"
"net/url"
"strings" "strings"
"github.com/anotherhadi/spilltea/internal/util"
) )
type header struct{ key, value string } type header struct{ key, value string }
@@ -12,46 +16,22 @@ type parsedRequest struct {
path string path string
host string host string
scheme string scheme string
headers []header headers []header // garder header{key, value} pour compat locale
body string body string
} }
func parseRaw(raw, scheme string) parsedRequest { func parseRaw(raw, scheme string) parsedRequest {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") r := util.ParseRawRequest(raw)
pr := parsedRequest{scheme: scheme} pr := parsedRequest{
if len(lines) == 0 { method: r.Method,
return pr path: r.Path,
host: r.Host,
scheme: scheme,
} }
for _, h := range r.Headers {
parts := strings.SplitN(lines[0], " ", 3) pr.headers = append(pr.headers, header{h.Key, h.Value})
if len(parts) >= 1 {
pr.method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
pr.path = strings.TrimSpace(parts[1])
}
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
pr.headers = append(pr.headers, header{k, v})
if strings.EqualFold(k, "host") {
pr.host = v
}
}
i++
}
if i < len(lines) {
pr.body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
} }
pr.body = r.Body
return pr return pr
} }
@@ -78,10 +58,31 @@ func formatAs(id, raw, scheme string) string {
return toFFUF(pr) return toFFUF(pr)
case "markdown": case "markdown":
return toMarkdown(pr) return toMarkdown(pr)
case "har":
return toHAR(pr)
case "httpie":
return toHTTPie(pr)
} }
return raw return raw
} }
func toHTTPie(pr parsedRequest) string {
var sb strings.Builder
method := strings.ToUpper(pr.method)
fmt.Fprintf(&sb, "http %s '%s'", method, pr.fullURL())
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " \\\n '%s:%s'", h.key, h.value)
}
if pr.body != "" {
// Pass body via stdin hint
fmt.Fprintf(&sb, " \\\n <<< %q", pr.body)
}
return sb.String()
}
func toMarkdown(pr parsedRequest) string { func toMarkdown(pr parsedRequest) string {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL()) fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
@@ -200,3 +201,104 @@ func toFFUF(pr parsedRequest) string {
} }
return sb.String() return sb.String()
} }
func toHAR(pr parsedRequest) string {
type harNameValue struct {
Name string `json:"name"`
Value string `json:"value"`
}
type harPostData struct {
MimeType string `json:"mimeType"`
Text string `json:"text"`
}
type harRequest struct {
Method string `json:"method"`
URL string `json:"url"`
HTTPVersion string `json:"httpVersion"`
Headers []harNameValue `json:"headers"`
QueryString []harNameValue `json:"queryString"`
Cookies []harNameValue `json:"cookies"`
HeadersSize int `json:"headersSize"`
BodySize int `json:"bodySize"`
PostData *harPostData `json:"postData,omitempty"`
}
type harEntry struct {
StartedDateTime string `json:"startedDateTime"`
Time int `json:"time"`
Request harRequest `json:"request"`
Cache struct{} `json:"cache"`
Timings struct {
Send int `json:"send"`
Wait int `json:"wait"`
Receive int `json:"receive"`
} `json:"timings"`
}
type harLog struct {
Version string `json:"version"`
Creator struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"creator"`
Entries []harEntry `json:"entries"`
}
type harRoot struct {
Log harLog `json:"log"`
}
headers := make([]harNameValue, 0, len(pr.headers))
for _, h := range pr.headers {
headers = append(headers, harNameValue{h.key, h.value})
}
var qs []harNameValue
if idx := strings.Index(pr.path, "?"); idx != -1 {
vals, err := url.ParseQuery(pr.path[idx+1:])
if err == nil {
for k, vs := range vals {
for _, v := range vs {
qs = append(qs, harNameValue{k, v})
}
}
}
}
if qs == nil {
qs = []harNameValue{}
}
req := harRequest{
Method: pr.method,
URL: pr.fullURL(),
HTTPVersion: "HTTP/1.1",
Headers: headers,
QueryString: qs,
Cookies: []harNameValue{},
HeadersSize: -1,
BodySize: len(pr.body),
}
if pr.body != "" {
mimeType := "application/octet-stream"
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-type") {
mimeType = h.value
break
}
}
req.PostData = &harPostData{MimeType: mimeType, Text: pr.body}
}
root := harRoot{Log: harLog{
Version: "1.2",
Entries: []harEntry{{
StartedDateTime: "1970-01-01T00:00:00.000Z",
Time: -1,
Request: req,
}},
}}
root.Log.Creator.Name = "spilltea"
b, err := json.MarshalIndent(root, "", " ")
if err != nil {
return ""
}
return string(b)
}
+18 -16
View File
@@ -1,24 +1,16 @@
package copyas package copyas
import ( import (
"encoding/base64"
"fmt"
"os"
"charm.land/bubbles/v2/list" "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
) )
const popupInnerW = 46 const (
popupW = 61
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard. popupH = 20
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…). )
func writeClipboard(text string) {
encoded := base64.StdEncoding.EncodeToString([]byte(text))
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
}
type OpenMsg struct { type OpenMsg struct {
RawRequest string RawRequest string
@@ -42,6 +34,8 @@ var allFormats = []list.Item{
formatItem{"go", "Go", "net/http package"}, formatItem{"go", "Go", "net/http package"},
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"}, formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
formatItem{"markdown", "Markdown", "formatted for documentation"}, formatItem{"markdown", "Markdown", "formatted for documentation"},
formatItem{"har", "HAR", "HTTP Archive (JSON)"},
formatItem{"httpie", "HTTPie", "HTTPie command line client"},
} }
type Model struct { type Model struct {
@@ -69,7 +63,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)
@@ -92,17 +86,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
} }
+13 -3
View File
@@ -4,16 +4,26 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
) )
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if kp, ok := msg.(tea.KeyPressMsg); ok { if kp, ok := msg.(tea.KeyPressMsg); ok {
switch { switch {
case kp.String() == "enter": case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(formatItem); ok {
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
}
m.open = false m.open = false
if item, ok := m.list.SelectedItem().(formatItem); ok {
return m, tea.Batch(
tea.SetClipboard(formatAs(item.id, m.rawRequest, m.scheme)),
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Copied",
Body: "Request copied to clipboard",
Kind: notificationsUI.KindSuccess,
}
},
)
}
return m, nil return m, nil
case key.Matches(kp, keys.Keys.Global.Escape): case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() { if m.list.SettingFilter() {
+1 -1
View File
@@ -24,7 +24,7 @@ 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)
} }
+162 -14
View File
@@ -10,8 +10,159 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
// isWordChar reports whether c belongs to a "word" token (letter, digit, underscore).
func isWordChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}
// tokenize splits s into runs of word characters and individual non-word bytes.
func tokenize(s string) []string {
var out []string
i := 0
for i < len(s) {
if isWordChar(s[i]) {
j := i
for j < len(s) && isWordChar(s[j]) {
j++
}
out = append(out, s[i:j])
i = j
} else {
out = append(out, s[i:i+1])
i++
}
}
return out
}
// wordDiff computes a token-level diff between leftLine and rightLine and
// returns the two rendered strings with changed tokens highlighted.
func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) {
lToks := tokenize(leftLine)
rToks := tokenize(rightLine)
n, m := len(lToks), len(rToks)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := 1; i <= n; i++ {
for j := 1; j <= m; j++ {
if lToks[i-1] == rToks[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] >= dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
type segment struct {
kind int // 0=same, 1=left-only, 2=right-only
tok string
}
segs := make([]segment, 0, n+m)
i, j := n, m
for i > 0 || j > 0 {
switch {
case i > 0 && j > 0 && lToks[i-1] == rToks[j-1]:
segs = append(segs, segment{0, lToks[i-1]})
i--
j--
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
segs = append(segs, segment{2, rToks[j-1]})
j--
default:
segs = append(segs, segment{1, lToks[i-1]})
i--
}
}
for lo, hi := 0, len(segs)-1; lo < hi; lo, hi = lo+1, hi-1 {
segs[lo], segs[hi] = segs[hi], segs[lo]
}
s := style.S
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true)
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true)
dim := lipgloss.NewStyle().Foreground(s.Subtle)
var lb, rb strings.Builder
for _, seg := range segs {
switch seg.kind {
case 0:
lb.WriteString(dim.Render(seg.tok))
rb.WriteString(dim.Render(seg.tok))
case 1:
lb.WriteString(boldErr.Render(seg.tok))
case 2:
rb.WriteString(boldOk.Render(seg.tok))
}
}
return lb.String(), rb.String()
}
// pairAndHighlight collapses adjacent removed/added blocks onto the same rows
// (eliminating the interleaved padding lines) and applies word-level diff
// highlighting to each paired line. Unpaired excess removals/additions keep
// their original single-sided padding row.
func pairAndHighlight(left, right []diffLine) ([]diffLine, []diffLine) {
newLeft := make([]diffLine, 0, len(left))
newRight := make([]diffLine, 0, len(right))
i := 0
for i < len(left) {
if left[i].kind != lineRemoved {
newLeft = append(newLeft, left[i])
newRight = append(newRight, right[i])
i++
continue
}
rStart := i
for i < len(left) && left[i].kind == lineRemoved {
i++
}
rEnd := i
aStart := i
for i < len(left) && left[i].kind == lineAdded {
i++
}
aEnd := i
nRemoved := rEnd - rStart
nAdded := aEnd - aStart
pairs := nRemoved
if nAdded < pairs {
pairs = nAdded
}
for k := 0; k < pairs; k++ {
lLine := left[rStart+k]
rLine := right[aStart+k]
lLine.text, rLine.text = wordDiff(lLine.plainText, rLine.plainText)
newLeft = append(newLeft, lLine)
newRight = append(newRight, rLine)
}
for k := pairs; k < nRemoved; k++ {
newLeft = append(newLeft, left[rStart+k])
newRight = append(newRight, diffLine{kind: lineRemoved})
}
for k := pairs; k < nAdded; k++ {
newLeft = append(newLeft, diffLine{kind: lineAdded})
newRight = append(newRight, right[aStart+k])
}
}
return newLeft, newRight
}
type slot struct { type slot struct {
label string label string
raw string raw string
@@ -38,7 +189,8 @@ const (
) )
type diffLine struct { type diffLine struct {
text string text string // displayed text (highlighted, possibly word-diff decorated)
plainText string // plain text for word-diff pairing (empty for padding lines)
kind lineKind kind lineKind
} }
@@ -126,6 +278,7 @@ func (m *Model) computeDiff() {
leftHL := hlLines(leftNorm) leftHL := hlLines(leftNorm)
rightHL := hlLines(rightNorm) rightHL := hlLines(rightNorm)
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL) m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
m.leftLines, m.rightLines = pairAndHighlight(m.leftLines, m.rightLines)
} }
func normRaw(s string) string { func normRaw(s string) string {
@@ -149,7 +302,7 @@ func (m *Model) refreshViewports() {
placeholder := lipgloss.Place( placeholder := lipgloss.Place(
m.leftViewport.Width(), m.leftViewport.Height(), m.leftViewport.Width(), m.leftViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
s.Faint.Render(" <(^_^)>\nsend two entries here to compare"), s.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")),
) )
m.leftViewport.SetContent(placeholder) m.leftViewport.SetContent(placeholder)
m.rightViewport.SetContent("") m.rightViewport.SetContent("")
@@ -161,7 +314,7 @@ func (m *Model) refreshViewports() {
placeholder := lipgloss.Place( placeholder := lipgloss.Place(
m.rightViewport.Width(), m.rightViewport.Height(), m.rightViewport.Width(), m.rightViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (・3・)\nwaiting for second entry…"), s.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")),
) )
m.rightViewport.SetContent(placeholder) m.rightViewport.SetContent(placeholder)
return return
@@ -227,10 +380,10 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
j-- j--
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]): case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
left = append(left, diffLine{kind: lineAdded}) left = append(left, diffLine{kind: lineAdded})
right = append(right, diffLine{text: hlB(j - 1), kind: lineAdded}) right = append(right, diffLine{text: hlB(j - 1), plainText: b[j-1], kind: lineAdded})
j-- j--
default: default:
left = append(left, diffLine{text: hlA(i - 1), kind: lineRemoved}) left = append(left, diffLine{text: hlA(i - 1), plainText: a[i-1], kind: lineRemoved})
right = append(right, diffLine{kind: lineRemoved}) right = append(right, diffLine{kind: lineRemoved})
i-- i--
} }
@@ -243,14 +396,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 +404,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}
all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
} }
+9 -2
View File
@@ -7,6 +7,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -38,6 +39,12 @@ func (m *Model) renderPanels(panelH int) string {
if m.right.label != "" { if m.right.label != "" {
rightTitle = icons.I.Diff + "Second: " + m.right.label rightTitle = icons.I.Diff + "Second: " + m.right.label
} }
if maxW := leftW - 4; maxW > 0 {
leftTitle = ansi.Truncate(leftTitle, maxW, "…")
}
if maxW := rightW - 4; maxW > 0 {
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
}
leftBorder := s.Panel leftBorder := s.Panel
rightBorder := s.Panel rightBorder := s.Panel
@@ -51,8 +58,8 @@ func (m *Model) renderPanels(panelH int) string {
rightBorder = s.PanelFocused rightBorder = s.PanelFocused
} }
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH) left := style.RenderWithTitle(leftBorder, leftTitle, style.ViewportView(&m.leftViewport), leftW, panelH)
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH) right := style.RenderWithTitle(rightBorder, rightTitle, style.ViewportView(&m.rightViewport), rightW, panelH)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right) return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
} }
+253 -1
View File
@@ -1,36 +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"),
}, "\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)
}
+44 -25
View File
@@ -4,47 +4,66 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 { util.HandleMouseWheel(msg, &e.viewport)
case tea.MouseWheelUp:
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case tea.MouseWheelDown:
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
}
case tea.KeyPressMsg: case tea.KeyPressMsg:
if e.searching {
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):
e.viewport.SetYOffset(e.viewport.YOffset() + 1) e.viewport.SetYOffset(e.viewport.YOffset() + 1)
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := e.viewport.Height() / 2 util.ScrollViewport(&e.viewport, -1)
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := e.viewport.Height() / 2 util.ScrollViewport(&e.viewport, 1)
if step < 1 { case key.Matches(msg, g.Help):
step = 1 e.help.ShowAll = !e.help.ShowAll
} e.SetSize(e.width, e.height)
e.viewport.SetYOffset(e.viewport.YOffset() + step)
} }
} }
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()
}
+26 -3
View File
@@ -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()
} }
+53 -10
View File
@@ -15,18 +15,24 @@ import (
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
type Model struct { type Model struct {
database *db.DB database *db.DB
findings []db.Finding findings []db.Finding
cursor int cursor int
hasUnread bool
knownCount int
listViewport viewport.Model listViewport viewport.Model
bodyViewport viewport.Model bodyViewport viewport.Model
pager paginator.Model pager paginator.Model
help help.Model help help.Model
renderer *glamour.TermRenderer
rendererWidth int
width int width int
height int height int
} }
@@ -42,6 +48,17 @@ func New() Model {
func (m Model) Init() tea.Cmd { return nil } func (m Model) Init() tea.Cmd { return nil }
func (m Model) HasUnread() bool { return m.hasUnread }
func (m *Model) ClearUnread() { m.hasUnread = false; m.knownCount = len(m.findings) }
func (m *Model) CurrentMarkdown() string {
if len(m.findings) == 0 {
return ""
}
f := m.findings[m.cursor]
return "# " + f.Title + "\n\n" + f.Description
}
func (m *Model) SetDB(d *db.DB) { func (m *Model) SetDB(d *db.DB) {
m.database = d m.database = d
} }
@@ -76,12 +93,17 @@ func (m *Model) recalcSizes() {
m.bodyViewport.SetWidth(inner) m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH) m.bodyViewport.SetHeight(bodyVH)
if m.rendererWidth != inner {
m.renderer = nil
m.rendererWidth = 0
}
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
} }
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.
@@ -104,19 +126,29 @@ type FindingsLoadedMsg struct {
} }
func (m *Model) refreshBody() { func (m *Model) refreshBody() {
m.refreshBodyScroll(true)
}
func (m *Model) refreshBodyKeepScroll() {
m.refreshBodyScroll(false)
}
func (m *Model) refreshBodyScroll(reset bool) {
if len(m.findings) == 0 { if len(m.findings) == 0 {
m.bodyViewport.SetContent("") m.bodyViewport.SetContent("")
return return
} }
f := m.findings[m.cursor] f := m.findings[m.cursor]
rendered := renderMarkdown(f.Description, m.bodyViewport.Width()) rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered) m.bodyViewport.SetContent(rendered)
if reset {
m.bodyViewport.GotoTop() m.bodyViewport.GotoTop()
} }
}
func renderMarkdown(src string, width int) string { func (m *Model) renderMarkdownCached(src string, width int) string {
if src == "" { if src == "" {
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description") return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
} }
tmpl, err := template.New("").Parse(src) tmpl, err := template.New("").Parse(src)
if err != nil { if err != nil {
@@ -129,28 +161,39 @@ func renderMarkdown(src string, width int) string {
if width < 10 { if width < 10 {
width = 80 width = 80
} }
// Rebuild renderer if width changed or not yet built.
if m.renderer == nil || m.rendererWidth != width {
r, err := glamour.NewTermRenderer( r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)), glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width), glamour.WithWordWrap(width),
) )
if err != nil { if err == nil {
m.renderer = r
m.rendererWidth = width
}
}
if m.renderer == nil {
return buf.String() return buf.String()
} }
out, err := r.Render(buf.String()) out, err := m.renderer.Render(buf.String())
if err != nil { if err != nil {
return buf.String() return buf.String()
} }
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.Copy, 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, g.Copy}
all := append(keys.Keys.Findings.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
} }
+51 -16
View File
@@ -6,6 +6,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -15,22 +16,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("findings load error: %v", msg.Err) log.Printf("findings load error: %v", msg.Err)
return m, nil return m, nil
} }
var prevID int64
if len(m.findings) > 0 && m.cursor < len(m.findings) {
prevID = m.findings[m.cursor].ID
}
m.findings = msg.Findings m.findings = msg.Findings
if len(m.findings) > m.knownCount {
m.hasUnread = true
}
if m.cursor >= len(m.findings) { if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1) m.cursor = max(0, len(m.findings)-1)
} }
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()
var newID int64
if len(m.findings) > 0 && m.cursor < len(m.findings) {
newID = m.findings[m.cursor].ID
}
if newID != prevID {
m.refreshBody() m.refreshBody()
} else {
m.refreshBodyKeepScroll()
}
return m, nil return m, nil
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
switch msg.Button { util.HandleMouseWheel(msg, &m.bodyViewport)
case tea.MouseWheelUp:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
return m, nil return m, nil
case tea.KeyPressMsg: case tea.KeyPressMsg:
@@ -65,17 +81,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, RefreshCmd(m.database) return m, RefreshCmd(m.database)
} }
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, -1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, 1)
if step < 1 { case key.Matches(msg, g.GotoTop):
step = 1 m.cursor = 0
} m.pager.Page = 0
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step) m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.GotoBottom):
m.cursor = util.CursorGotoBottom(len(m.findings))
m.pager.Page = util.CursorGotoBottom(m.pager.TotalPages)
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.PrevPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, false)
m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.NextPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, true)
m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
} }
} }
return m, nil return m, nil
+7 -10
View File
@@ -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),
@@ -42,7 +45,7 @@ func (m *Model) renderBodyPanel(h int) string {
if len(m.findings) > 0 { if len(m.findings) > 0 {
title = m.findings[m.cursor].Title title = m.findings[m.cursor].Title
} }
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h) return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
} }
func (m *Model) renderList() string { func (m *Model) renderList() string {
@@ -51,17 +54,11 @@ func (m *Model) renderList() string {
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"), s.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
) )
} }
start, end := m.pager.GetSliceBounds(len(m.findings)) start, end := util.PageBounds(m.pager, len(m.findings))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, f := range m.findings[start:end] { for i, f := range m.findings[start:end] {
+19 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
type panel int type panel int
@@ -59,10 +60,22 @@ func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "" return ""
} }
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw return m.entries[m.cursor].RequestRaw
} }
func (m Model) CurrentScheme() string { return "https" } func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https"
}
return util.InferScheme(m.entries[m.cursor].Host)
}
// RefreshCmd returns the appropriate load command given the current search state. // RefreshCmd returns the appropriate load command given the current search state.
// The app model should call this instead of LoadEntriesCmd directly so that // The app model should call this instead of LoadEntriesCmd directly so that
@@ -145,14 +158,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
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery} g := keys.Keys.Global
all = append(all, keys.Keys.Global.Bindings()...) 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.Flag, h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
all = append(all, pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
} }
+69 -30
View File
@@ -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"
@@ -35,18 +36,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") { if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
return m, nil return m, nil
} }
prevCursor := m.cursor // Remember the selected entry's ID so we can re-anchor after the list is
// reloaded (new entries are prepended; a pure index-based cursor would
// silently jump to a different entry).
var selectedID int64
if m.cursor >= 0 && m.cursor < len(m.entries) {
selectedID = m.entries[m.cursor].ID
}
m.entries = msg.Entries m.entries = msg.Entries
entryChanged := true
if selectedID != 0 {
for i, e := range m.entries {
if e.ID == selectedID {
m.cursor = i
entryChanged = false
break
}
}
}
if m.cursor >= len(m.entries) { if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1 m.cursor = len(m.entries) - 1
entryChanged = true
} }
if m.cursor < 0 { if m.cursor < 0 {
m.cursor = 0 m.cursor = 0
entryChanged = true
} }
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
if m.cursor != prevCursor { if entryChanged {
m.bodyViewport.SetYOffset(0) m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
} }
@@ -74,24 +93,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
switch msg.Button { util.HandleMouseWheel(msg, &m.bodyViewport)
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollLeft(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollRight(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.bodyViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.bodyViewport.ScrollRight(6)
}
case tea.KeyPressMsg: case tea.KeyPressMsg:
h := keys.Keys.History h := keys.Keys.History
@@ -229,6 +231,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
case key.Matches(msg, h.Flag):
if len(m.entries) > 0 && m.database != nil {
m.database.ToggleFlag(m.entries[m.cursor].ID)
return m, m.RefreshCmd()
}
case key.Matches(msg, h.DeleteEntry): case key.Matches(msg, h.DeleteEntry):
if len(m.entries) > 0 { if len(m.entries) > 0 {
id := m.entries[m.cursor].ID id := m.entries[m.cursor].ID
@@ -251,18 +259,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.clearSearch() return m, m.clearSearch()
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, -1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, 1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, g.Left): case key.Matches(msg, g.Left):
m.bodyViewport.ScrollLeft(6) m.bodyViewport.ScrollLeft(6)
@@ -270,6 +270,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Right): case key.Matches(msg, g.Right):
m.bodyViewport.ScrollRight(6) m.bodyViewport.ScrollRight(6)
case key.Matches(msg, g.GotoTop):
m.cursor = 0
m.pager.Page = 0
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.GotoBottom):
m.cursor = util.CursorGotoBottom(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.PrevPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.NextPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, keys.Keys.Global.Help): case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
@@ -281,9 +310,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 +333,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(util.CenterLines("(˘・_・˘)", "no response stored"))))
return
}
m.bodyViewport.SetContent(style.HighlightHTTP(raw)) m.bodyViewport.SetContent(style.HighlightHTTP(raw))
} }
+27 -12
View File
@@ -9,6 +9,7 @@ import (
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -28,7 +29,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),
@@ -42,7 +46,7 @@ func (m *Model) renderBodyPanel(h int) string {
if m.focusedPanel == panelResponse { if m.focusedPanel == panelResponse {
title = icons.I.Response + "Response" title = icons.I.Response + "Response"
} }
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h) return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
@@ -81,9 +85,9 @@ func (m *Model) renderList() string {
) )
} }
if len(m.entries) == 0 { if len(m.entries) == 0 {
msg := " (⌐■_■)\nno history yet" msg := util.CenterLines("(⌐■_■)", "no history yet")
if m.searchKind != searchKindOff { if m.searchKind != searchKindOff {
msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results" msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
} }
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
@@ -92,13 +96,7 @@ func (m *Model) renderList() string {
) )
} }
start, end := m.pager.GetSliceBounds(len(m.entries)) start, end := util.PageBounds(m.pager, len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, e := range m.entries[start:end] { for i, e := range m.entries[start:end] {
@@ -109,7 +107,7 @@ func (m *Model) renderList() string {
w := m.listViewport.Width() w := m.listViewport.Width()
statusStr := fmt.Sprintf("%3d", e.StatusCode) statusStr := fmt.Sprintf("%3d", e.StatusCode)
const fixedW = 2 + 7 + 1 + 3 + 1 + 10 + 1 const fixedW = 2 + 2 + 7 + 1 + 3 + 1 + 10 + 1
hostPathW := w - fixedW hostPathW := w - fixedW
if hostPathW < 0 { if hostPathW < 0 {
hostPathW = 0 hostPathW = 0
@@ -117,12 +115,21 @@ func (m *Model) renderList() string {
ts := e.Timestamp.Format("15:04:05") ts := e.Timestamp.Format("15:04:05")
statusSt := style.StatusStyle(e.StatusCode, 3) statusSt := style.StatusStyle(e.StatusCode, 3)
flagSt := lipgloss.NewStyle().Foreground(s.Primary)
var line string var line string
if selected { if selected {
bg := lipgloss.NewStyle().Background(selBg) bg := lipgloss.NewStyle().Background(selBg)
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"), bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
bg.Foreground(s.Primary).Width(2).Render(flagStr),
s.Method(e.Method).Background(selBg).Render(e.Method), s.Method(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""), bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr), statusSt.Background(selBg).Render(statusStr),
@@ -132,8 +139,16 @@ func (m *Model) renderList() string {
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path), bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
) )
} else { } else {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
flagSt.Width(2).Render(flagStr),
s.Method(e.Method).Render(e.Method), s.Method(e.Method).Render(e.Method),
" ", " ",
statusSt.Render(statusStr), statusSt.Render(statusStr),
+6 -6
View File
@@ -142,6 +142,11 @@ type Project struct {
ModTime time.Time ModTime time.Time
} }
// ProjectSelectedMsg is emitted when the user picks a project from the home screen.
type ProjectSelectedMsg struct {
Project *Project
}
type inputMode int type inputMode int
const ( const (
@@ -161,16 +166,11 @@ type Model struct {
list list.Model list list.Model
projectDir string projectDir string
nameInput textinput.Model nameInput textinput.Model
selected *Project
width int width int
height int height int
teapotFrame int teapotFrame int
} }
// Selected returns the project chosen by the user, or nil if the program was
// quit without making a selection.
func (m Model) Selected() *Project { return m.selected }
func New(projectDir string) Model { func New(projectDir string) Model {
projects := loadProjects(projectDir) projects := loadProjects(projectDir)
@@ -332,7 +332,7 @@ func (m Model) renderHelpLine() string {
} }
parts = append(parts, binding(k.Open)) parts = append(parts, binding(k.Open))
parts = append(parts, binding(k.Delete)) parts = append(parts, binding(k.Delete))
parts = append(parts, item("q", "quit")) parts = append(parts, item(keys.Keys.Global.Quit.Help().Key, "quit"))
} }
return strings.Join(parts, sep) return strings.Join(parts, sep)
+8 -8
View File
@@ -76,11 +76,11 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
initProjectFiles(dir) initProjectFiles(dir)
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")} p := &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
return m, tea.Quit return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
default: default:
m.selected = &Project{Name: item.name, Path: item.path} p := &Project{Name: item.name, Path: item.path}
return m, tea.Quit return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
} }
} }
@@ -117,8 +117,8 @@ func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
initProjectFiles(dir) initProjectFiles(dir)
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")} p := &Project{Name: name, Path: filepath.Join(dir, "data.db")}
return m, tea.Quit return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg) m.nameInput, cmd = m.nameInput.Update(msg)
@@ -138,14 +138,14 @@ func sanitizeName(s string) string {
} }
func IsValidProjectName(s string) bool { func IsValidProjectName(s string) bool {
if s == "tmp" { if s == "tmp" || s == "temp" || s == "temporary" {
return true return true
} }
return s != "" && s == sanitizeName(s) return s != "" && s == sanitizeName(s)
} }
func OpenProject(projectDir, name string) (*Project, error) { func OpenProject(projectDir, name string) (*Project, error) {
if name == "tmp" { if name == "tmp" || name == "temp" || name == "temporary" {
dir := tempDir() dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err return nil, err
+26 -91
View File
@@ -4,114 +4,39 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"sort"
"strconv" "strconv"
"strings" "strings"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func formatRawRequest(req *intercept.PendingRequest) string {
r := req.Flow.Request
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func formatRawResponse(resp *intercept.PendingResponse) string {
r := resp.Flow.Response
if r == nil {
return "(no response)"
}
var sb strings.Builder
proto := resp.Flow.Request.Proto
if proto == "" {
proto = "HTTP/1.1"
}
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func parseRawRequest(content string, req *intercept.PendingRequest) { func parseRawRequest(content string, req *intercept.PendingRequest) {
parsed := util.ParseRawRequest(content)
r := req.Flow.Request r := req.Flow.Request
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") if parsed.Method != "" {
if len(lines) == 0 { r.Method = parsed.Method
return
} }
if parsed.Path != "" {
parts := strings.SplitN(lines[0], " ", 3) if u, err := url.ParseRequestURI(parsed.Path); err == nil {
if len(parts) >= 1 {
r.Method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
if u, err := url.ParseRequestURI(strings.TrimSpace(parts[1])); err == nil {
r.URL.Path = u.Path r.URL.Path = u.Path
r.URL.RawQuery = u.RawQuery r.URL.RawQuery = u.RawQuery
} }
} }
if len(parts) >= 3 { if parsed.Proto != "" {
r.Proto = strings.TrimSpace(parts[2]) r.Proto = parsed.Proto
} }
r.Header = make(http.Header) r.Header = make(http.Header)
i := 1 for _, h := range parsed.Headers {
for i < len(lines) { r.Header.Set(h.Key, h.Value)
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
} }
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 { if parsed.Body != "" {
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])) r.Body = []byte(parsed.Body)
}
i++
}
if i < len(lines) {
body := strings.Join(lines[i:], "\n")
body = strings.TrimRight(body, "\n")
if body != "" {
r.Body = []byte(body)
} else { } else {
r.Body = nil r.Body = nil
} }
} }
}
func parseRawResponse(content string, resp *intercept.PendingResponse) { func parseRawResponse(content string, resp *intercept.PendingResponse) {
r := resp.Flow.Response r := resp.Flow.Response
@@ -295,17 +220,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())
} }
@@ -333,7 +268,7 @@ func (m *Model) loadIntoTextarea() {
if edited, ok := m.pendingResponseEdits[resp]; ok { if edited, ok := m.pendingResponseEdits[resp]; ok {
m.textarea.SetValue(edited) m.textarea.SetValue(edited)
} else { } else {
m.textarea.SetValue(formatRawResponse(resp)) m.textarea.SetValue(intercept.FormatRawResponse(resp.Flow))
} }
} else { } else {
if len(m.queue) == 0 { if len(m.queue) == 0 {
@@ -343,7 +278,7 @@ func (m *Model) loadIntoTextarea() {
if edited, ok := m.pendingEdits[req]; ok { if edited, ok := m.pendingEdits[req]; ok {
m.textarea.SetValue(edited) m.textarea.SetValue(edited)
} else { } else {
m.textarea.SetValue(formatRawRequest(req)) m.textarea.SetValue(intercept.FormatRawRequest(req.Flow))
} }
} }
} }
@@ -360,7 +295,7 @@ func (m *Model) refreshBody() {
if edited, ok := m.pendingResponseEdits[resp]; ok { if edited, ok := m.pendingResponseEdits[resp]; ok {
raw = edited raw = edited
} else { } else {
raw = formatRawResponse(resp) raw = intercept.FormatRawResponse(resp.Flow)
} }
} else { } else {
if len(m.queue) == 0 { if len(m.queue) == 0 {
@@ -371,7 +306,7 @@ func (m *Model) refreshBody() {
if edited, ok := m.pendingEdits[req]; ok { if edited, ok := m.pendingEdits[req]; ok {
raw = edited raw = edited
} else { } else {
raw = formatRawRequest(req) raw = intercept.FormatRawRequest(req.Flow)
} }
} }
m.bodyViewport.SetContent(style.HighlightHTTP(raw)) m.bodyViewport.SetContent(style.HighlightHTTP(raw))
+4 -1
View File
@@ -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)
} }
+10 -2
View File
@@ -30,6 +30,7 @@ type Model struct {
editing bool editing bool
interceptEnabled bool interceptEnabled bool
hasUnread bool
pendingEdits map[*intercept.PendingRequest]string pendingEdits map[*intercept.PendingRequest]string
pendingResponseEdits map[*intercept.PendingResponse]string pendingResponseEdits map[*intercept.PendingResponse]string
@@ -76,8 +77,15 @@ func New(broker *intercept.Broker) Model {
func (m Model) Init() tea.Cmd { return nil } func (m Model) Init() tea.Cmd { return nil }
func (m Model) HasUnread() bool { return m.hasUnread }
func (m *Model) ClearUnread() { m.hasUnread = false }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.captureResponse && m.focusedPanel == panelResponses
}
func (m Model) CurrentScheme() string { func (m Model) CurrentScheme() string {
if len(m.queue) == 0 { if len(m.queue) == 0 {
return "https" return "https"
@@ -98,7 +106,7 @@ func (m Model) CurrentRaw() string {
if edited, ok := m.pendingResponseEdits[resp]; ok { if edited, ok := m.pendingResponseEdits[resp]; ok {
return edited return edited
} }
return formatRawResponse(resp) return intercept.FormatRawResponse(resp.Flow)
} }
if len(m.queue) == 0 { if len(m.queue) == 0 {
return "" return ""
@@ -107,7 +115,7 @@ func (m Model) CurrentRaw() string {
if edited, ok := m.pendingEdits[req]; ok { if edited, ok := m.pendingEdits[req]; ok {
return edited return edited
} }
return formatRawRequest(req) return intercept.FormatRawRequest(req.Flow)
} }
func (m *Model) SetSize(w, h int) { func (m *Model) SetSize(w, h int) {
+48 -35
View File
@@ -31,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
wasEmpty := len(m.queue) == 0 wasEmpty := len(m.queue) == 0
m.queue = append(m.queue, msg.Req) m.queue = append(m.queue, msg.Req)
m.hasUnread = true
m.refreshListViewport() m.refreshListViewport()
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) { if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
m.refreshBody() m.refreshBody()
@@ -52,24 +53,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
if !m.editing { if !m.editing {
switch msg.Button { util.HandleMouseWheel(msg, &m.bodyViewport)
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollLeft(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollRight(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.bodyViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.bodyViewport.ScrollRight(6)
}
} }
case tea.KeyPressMsg: case tea.KeyPressMsg:
@@ -127,18 +111,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
} }
case key.Matches(msg, keys.Keys.Global.ScrollUp): case key.Matches(msg, keys.Keys.Global.ScrollUp):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, -1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, keys.Keys.Global.ScrollDown): case key.Matches(msg, keys.Keys.Global.ScrollDown):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, 1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, keys.Keys.Global.Left): case key.Matches(msg, keys.Keys.Global.Left):
m.bodyViewport.ScrollLeft(6) m.bodyViewport.ScrollLeft(6)
@@ -146,9 +122,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
case key.Matches(msg, keys.Keys.Global.Right): case key.Matches(msg, keys.Keys.Global.Right):
m.bodyViewport.ScrollRight(6) m.bodyViewport.ScrollRight(6)
case key.Matches(msg, keys.Keys.Global.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Keys.Intercept.UndoEdits): case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses { if onResponses {
if len(m.responseQueue) > 0 { if len(m.responseQueue) > 0 {
@@ -237,10 +210,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
case key.Matches(msg, keys.Keys.Intercept.EditExternal): case key.Matches(msg, keys.Keys.Intercept.EditExternal):
if !onResponses && len(m.queue) > 0 { if !onResponses && len(m.queue) > 0 {
return m, util.OpenExternalEditor(formatRawRequest(m.queue[m.cursor])) return m, util.OpenExternalEditor(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
} }
if onResponses && len(m.responseQueue) > 0 { if onResponses && len(m.responseQueue) > 0 {
return m, util.OpenExternalEditor(formatRawResponse(m.responseQueue[m.responseCursor])) return m, util.OpenExternalEditor(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
} }
case key.Matches(msg, keys.Keys.Global.SendToReplay): case key.Matches(msg, keys.Keys.Global.SendToReplay):
@@ -268,6 +241,46 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
return diffUI.SendToDiffMsg{Label: label, Raw: raw} return diffUI.SendToDiffMsg{Label: label, Raw: raw}
} }
} }
case key.Matches(msg, keys.Keys.Global.GotoTop):
if onResponses {
m.responseCursor = 0
} else {
m.cursor = 0
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoBottom):
if onResponses {
m.responseCursor = util.CursorGotoBottom(len(m.responseQueue))
} else {
m.cursor = util.CursorGotoBottom(len(m.queue))
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.PrevPage):
if onResponses {
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, false)
} else {
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, false)
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.NextPage):
if onResponses {
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, true)
} else {
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, true)
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
} }
return m, tea.Batch(*cmds...) return m, tea.Batch(*cmds...)
@@ -287,12 +300,12 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
if onResponses { if onResponses {
if len(m.responseQueue) > 0 { if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor]) delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.textarea.SetValue(formatRawResponse(m.responseQueue[m.responseCursor])) m.textarea.SetValue(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
} }
} else { } else {
if len(m.queue) > 0 { if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor]) delete(m.pendingEdits, m.queue[m.cursor])
m.textarea.SetValue(formatRawRequest(m.queue[m.cursor])) m.textarea.SetValue(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
} }
} }
+10 -18
View File
@@ -8,6 +8,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -45,7 +46,10 @@ 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),
@@ -83,7 +87,7 @@ func (m *Model) renderBodyPanel(h int) string {
if m.editing { if m.editing {
body = m.textarea.View() body = m.textarea.View()
} else { } else {
body = m.bodyViewport.View() body = style.ViewportView(&m.bodyViewport)
} }
border := s.Panel border := s.Panel
@@ -101,17 +105,11 @@ func (m *Model) renderStatusBar() string {
func (m *Model) renderList() string { func (m *Model) renderList() string {
if len(m.queue) == 0 { if len(m.queue) == 0 {
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (。◕‿‿◕。)\nwaiting for a request")) return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(。◕‿‿◕。)", "waiting for a request")))
} }
s := style.S s := style.S
start, end := m.pager.GetSliceBounds(len(m.queue)) start, end := util.PageBounds(m.pager, len(m.queue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, req := range m.queue[start:end] { for i, req := range m.queue[start:end] {
@@ -157,17 +155,11 @@ func (m *Model) renderList() string {
func (m *Model) renderResponseList() string { func (m *Model) renderResponseList() string {
if len(m.responseQueue) == 0 { if len(m.responseQueue) == 0 {
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (҂◡_◡)\nno response yet")) return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(҂◡_◡)", "no response yet")))
} }
s := style.S s := style.S
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue)) start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, resp := range m.responseQueue[start:end] { for i, resp := range m.responseQueue[start:end] {
+17 -3
View File
@@ -112,7 +112,6 @@ func (m *Model) recalcSizes() {
m.syncDetailViewport() m.syncDetailViewport()
} }
// Refresh reloads the plugin list from the manager.
func (m *Model) Refresh() { func (m *Model) Refresh() {
if m.manager == nil { if m.manager == nil {
return return
@@ -187,15 +186,21 @@ func (m *Model) syncDetailViewport() {
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())
} }
type pluginsKeyMap struct { type pluginsKeyMap struct {
editing bool editing bool
hasConfig bool hasConfig bool
width int
} }
func (k pluginsKeyMap) ShortHelp() []key.Binding { func (k pluginsKeyMap) ShortHelp() []key.Binding {
@@ -210,11 +215,20 @@ func (k pluginsKeyMap) ShortHelp() []key.Binding {
key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"), key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"),
) )
if k.hasConfig { if k.hasConfig {
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter, scrollHint} return []key.Binding{pk.Toggle, pk.EditConfig, scrollHint, g.Help}
} }
return []key.Binding{pk.Toggle, pk.Filter, scrollHint} 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)
}
+20 -34
View File
@@ -4,23 +4,10 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
) )
// PluginsChangedMsg is sent when the plugin list should be refreshed.
type PluginsChangedMsg struct{}
// RefreshCmd returns a command that triggers a list refresh.
func RefreshCmd() tea.Cmd {
return func() tea.Msg { return PluginsChangedMsg{} }
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case PluginsChangedMsg:
m.Refresh()
return m, nil
}
// Route non-key messages to textarea when editing so internal // Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly. // textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing { if m.editing {
@@ -34,12 +21,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
if !m.editing { if !m.editing {
switch msg.Button { util.HandleMouseWheel(msg, &m.detailViewport)
case tea.MouseWheelUp:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
}
} }
case tea.KeyPressMsg: case tea.KeyPressMsg:
@@ -77,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.Blur() m.textarea.Blur()
if info, ok := m.selected(); ok && m.manager != nil { if info, ok := m.selected(); ok && m.manager != nil {
val := m.textarea.Value() val := m.textarea.Value()
m.manager.SaveConfig(info.Name, val) m.manager.SaveConfig(info.ID, val)
// Update cached info. // Update cached info.
m.filtered[m.cursor].ConfigText = val m.filtered[m.cursor].ConfigText = val
for i := range m.items { for i := range m.items {
if m.items[i].Name == info.Name { if m.items[i].ID == info.ID {
m.items[i].ConfigText = val m.items[i].ConfigText = val
break break
} }
@@ -125,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, pk.Toggle): case key.Matches(msg, pk.Toggle):
if info, ok := m.selected(); ok && m.manager != nil { if info, ok := m.selected(); ok && m.manager != nil {
m.manager.TogglePlugin(info.Name) m.manager.TogglePlugin(info.ID)
m.filtered[m.cursor].Enabled = !info.Enabled m.filtered[m.cursor].Enabled = !info.Enabled
for i := range m.items { for i := range m.items {
if m.items[i].Name == info.Name { if m.items[i].ID == info.ID {
m.items[i].Enabled = !info.Enabled m.items[i].Enabled = !info.Enabled
break break
} }
@@ -142,19 +124,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.Focus() m.textarea.Focus()
} }
case key.Matches(msg, g.PrevPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, false)
m.recalcSizes()
m.syncTextarea()
m.detailViewport.GotoTop()
case key.Matches(msg, g.NextPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, true)
m.recalcSizes()
m.syncTextarea()
m.detailViewport.GotoTop()
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.detailViewport.Height() / 2 util.ScrollViewport(&m.detailViewport, -1)
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.detailViewport.Height() / 2 util.ScrollViewport(&m.detailViewport, 1)
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
+12 -14
View File
@@ -11,11 +11,12 @@ import (
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
if m.width == 0 || m.manager == nil { if m.width == 0 || m.manager == nil {
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (._.)~*.'\n no plugins loaded"))) return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded"))))
} }
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4) listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
@@ -34,7 +35,10 @@ func (m *Model) renderListPanel(w, h int) string {
if m.editing { if m.editing {
panelStyle = s.Panel panelStyle = s.Panel
} }
dots := s.Faint.Render(m.pager.View()) 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),
@@ -69,7 +73,7 @@ func (m *Model) renderDetailPanel(h int) string {
s.Faint.Render(filepath.Base(info.FilePath)), s.Faint.Render(filepath.Base(info.FilePath)),
) )
parts := []string{header, m.detailViewport.View()} parts := []string{header, style.ViewportView(&m.detailViewport)}
if m.hasConfig() { if m.hasConfig() {
var configLabel string var configLabel string
@@ -120,17 +124,17 @@ 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, hasConfig: m.hasConfig()}))) 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, hasConfig: m.hasConfig()})) 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 {
s := style.S s := style.S
if len(m.filtered) == 0 { if len(m.filtered) == 0 {
msg := " (ง •̀_•́)ง\nno plugins" msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
if m.filter != "" { if m.filter != "" {
msg = " = _ =\nno results" msg = util.CenterLines("= _ =", "no results")
} }
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
@@ -139,13 +143,7 @@ func (m *Model) renderList() string {
) )
} }
start, end := m.pager.GetSliceBounds(len(m.filtered)) start, end := util.PageBounds(m.pager, len(m.filtered))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, p := range m.filtered[start:end] { for i, p := range m.filtered[start:end] {
+21 -2
View File
@@ -34,10 +34,19 @@ type Entry struct {
Err error Err error
} }
type panel int
const (
panelList panel = iota
panelRequest
panelResponse
)
type Model struct { type Model struct {
entries []Entry entries []Entry
cursor int cursor int
editing bool editing bool
focusedPanel panel
database *db.DB database *db.DB
listViewport viewport.Model listViewport viewport.Model
@@ -68,10 +77,17 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentRaw() string { func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "" return ""
} }
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw return m.entries[m.cursor].RequestRaw
} }
@@ -183,10 +199,13 @@ type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding { func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
r := keys.Keys.Replay r := keys.Keys.Replay
return []key.Binding{g.Up, g.Down, r.Send, r.Edit, g.Help} return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help}
} }
func (m replayKeyMap) FullHelp() [][]key.Binding { func (m replayKeyMap) FullHelp() [][]key.Binding {
all := append(keys.Keys.Replay.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.Copy, g.CopyAs, g.SendToDiff}
all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
} }
+163 -89
View File
@@ -2,21 +2,27 @@ package replay
import ( import (
"bytes" "bytes"
"compress/gzip"
"compress/zlib"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/andybalholm/brotli"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
"github.com/anotherhadi/spilltea/internal/util" "github.com/anotherhadi/spilltea/internal/util"
"github.com/klauspost/compress/zstd"
) )
type sentMsg struct { type sentMsg struct {
@@ -92,14 +98,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6) m.responseViewport.ScrollLeft(6)
} else { } else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1) m.scrollFocusedViewportVertical(-1)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) { if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6) m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6) m.responseViewport.ScrollRight(6)
} else { } else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1) m.scrollFocusedViewportVertical(1)
} }
case tea.MouseWheelLeft: case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
@@ -125,18 +131,36 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
r := keys.Keys.Replay r := keys.Keys.Replay
switch { switch {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.focusedPanel == panelList {
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
} }
} else {
m.scrollFocusedViewportVertical(-1)
}
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
if m.focusedPanel == panelList {
if m.cursor < len(m.entries)-1 { if m.cursor < len(m.entries)-1 {
m.cursor++ m.cursor++
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
} }
} else {
m.scrollFocusedViewportVertical(1)
}
case key.Matches(msg, g.CycleFocus):
switch m.focusedPanel {
case panelList:
m.focusedPanel = panelRequest
case panelRequest:
m.focusedPanel = panelResponse
default:
m.focusedPanel = panelList
}
case key.Matches(msg, r.Send): case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending { if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
@@ -167,18 +191,14 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.responseViewport.Height() / 2 vp := m.focusedViewport()
if step < 1 { util.ScrollViewport(&vp, -1)
step = 1 m.setFocusedViewport(vp)
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.responseViewport.Height() / 2 vp := m.focusedViewport()
if step < 1 { util.ScrollViewport(&vp, 1)
step = 1 m.setFocusedViewport(vp)
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
case key.Matches(msg, g.Left): case key.Matches(msg, g.Left):
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
@@ -213,6 +233,45 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoTop):
m.cursor = 0
m.pager.Page = 0
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoBottom):
m.cursor = util.CursorGotoBottom(len(m.entries))
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.PrevPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.NextPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.SendToDiff):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
var raw, label string
if m.focusedPanel == panelResponse {
raw = e.ResponseRaw
label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode))
} else {
raw = e.RequestRaw
label = e.Method + " " + e.Host + e.Path
}
if raw != "" {
return m, func() tea.Msg {
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
}
}
}
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
@@ -240,11 +299,39 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// focusedViewport returns the viewport that should receive scroll events.
// When the list is focused, scroll targets the request panel.
func (m *Model) focusedViewport() viewport.Model {
if m.focusedPanel == panelResponse {
return m.responseViewport
}
return m.requestViewport
}
func (m *Model) setFocusedViewport(vp viewport.Model) {
if m.focusedPanel == panelResponse {
m.responseViewport = vp
} else {
m.requestViewport = vp
}
}
func (m *Model) scrollFocusedViewportVertical(delta int) {
vp := m.focusedViewport()
vp.SetYOffset(vp.YOffset() + delta)
m.setFocusedViewport(vp)
}
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
if len(m.entries) == 0 {
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())
} }
@@ -260,69 +347,46 @@ func (m *Model) refreshBody() {
m.requestViewport.SetXOffset(0) m.requestViewport.SetXOffset(0)
if e.Sending { if e.Sending {
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (ノ◕ヮ◕)ノ*:・゚\n sending..."))) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
} else if e.ResponseRaw != "" { } else if e.ResponseRaw != "" {
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw)) m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
} else { } else {
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" ( •_•)>⌐■\npress send to fire"))) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
} }
m.responseViewport.SetYOffset(0) m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0) m.responseViewport.SetXOffset(0)
} }
func doSend(entry Entry) (responseRaw string, statusCode int, err error) { func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n") parsed := util.ParseRawRequest(entry.RequestRaw)
if len(lines) == 0 { if parsed.Method == "" {
return "", 0, fmt.Errorf("empty request") return "", 0, fmt.Errorf("empty request")
} }
parts := strings.SplitN(lines[0], " ", 3) host := parsed.Host
if len(parts) < 2 { if host == "" {
return "", 0, fmt.Errorf("invalid request line") host = entry.Host
} }
method := strings.TrimSpace(parts[0])
path := strings.TrimSpace(parts[1])
headers := make(http.Header) headers := make(http.Header)
host := entry.Host for _, h := range parsed.Headers {
i := 1 if strings.EqualFold(h.Key, "host") {
for i < len(lines) { continue
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
} }
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 { headers.Add(h.Key, h.Value)
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
if strings.ToLower(k) == "host" {
host = v
} else {
headers.Add(k, v)
}
}
i++
}
var bodyBytes []byte
if i < len(lines) {
b := strings.Join(lines[i:], "\n")
b = strings.TrimRight(b, "\n")
bodyBytes = []byte(b)
} }
scheme := entry.Scheme scheme := entry.Scheme
if scheme == "" { if scheme == "" {
scheme = "https" scheme = "https"
} }
urlStr := scheme + "://" + host + path urlStr := scheme + "://" + host + parsed.Path
var bodyReader io.Reader var bodyReader io.Reader
if len(bodyBytes) > 0 { if parsed.Body != "" {
bodyReader = bytes.NewReader(bodyBytes) bodyReader = strings.NewReader(parsed.Body)
} }
req, err := http.NewRequest(method, urlStr, bodyReader) req, err := http.NewRequest(parsed.Method, urlStr, bodyReader)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
@@ -344,19 +408,21 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
if decoded, decErr := decodeBody(enc, respBody); decErr == nil {
respBody = decoded
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
}
}
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)) fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
sortedKeys := make([]string, 0, len(resp.Header)) for _, line := range util.SortedHeaderLines(resp.Header) {
for k := range resp.Header { sb.WriteString(line)
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
for _, k := range sortedKeys {
for _, v := range resp.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
} }
sb.WriteString("\n") sb.WriteString("\n")
sb.Write(respBody) sb.Write(respBody)
@@ -364,6 +430,35 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
return sb.String(), resp.StatusCode, nil return sb.String(), resp.StatusCode, nil
} }
func decodeBody(encoding string, body []byte) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "gzip":
r, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "br":
return io.ReadAll(brotli.NewReader(bytes.NewReader(body)))
case "deflate":
r, err := zlib.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "zstd":
r, err := zstd.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
return nil, fmt.Errorf("unsupported encoding: %s", encoding)
}
func entryToDB(e Entry) db.ReplayEntry { func entryToDB(e Entry) db.ReplayEntry {
errMsg := "" errMsg := ""
if e.Err != nil { if e.Err != nil {
@@ -385,7 +480,11 @@ func entryToDB(e Entry) db.ReplayEntry {
} }
func entryFromMsg(msg SendToReplayMsg) Entry { func entryFromMsg(msg SendToReplayMsg) Entry {
method, host, path := parseFirstLine(msg.RequestRaw, msg.Host) parsed := util.ParseRawRequest(msg.RequestRaw)
host := parsed.Host
if host == "" {
host = msg.Host
}
scheme := msg.Scheme scheme := msg.Scheme
if scheme == "" { if scheme == "" {
scheme = util.InferScheme(host) scheme = util.InferScheme(host)
@@ -393,34 +492,9 @@ func entryFromMsg(msg SendToReplayMsg) Entry {
return Entry{ return Entry{
Scheme: scheme, Scheme: scheme,
Host: host, Host: host,
Path: path, Path: parsed.Path,
Method: method, Method: parsed.Method,
OriginalRaw: msg.RequestRaw, OriginalRaw: msg.RequestRaw,
RequestRaw: msg.RequestRaw, RequestRaw: msg.RequestRaw,
} }
} }
func parseFirstLine(raw, fallbackHost string) (method, host, path string) {
host = fallbackHost
path = "/"
lines := strings.SplitN(raw, "\n", 2)
if len(lines) == 0 {
return
}
parts := strings.Fields(lines[0])
if len(parts) >= 1 {
method = parts[0]
}
if len(parts) >= 2 {
path = parts[1]
}
if len(lines) > 1 {
for _, line := range strings.Split(lines[1], "\n") {
if strings.HasPrefix(strings.ToLower(line), "host:") {
host = strings.TrimSpace(line[5:])
break
}
}
}
return
}
+19 -14
View File
@@ -8,6 +8,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -33,11 +34,14 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
panelStyle := s.PanelFocused panelStyle := s.Panel
if m.editing { if !m.editing && m.focusedPanel == panelList {
panelStyle = s.Panel panelStyle = s.PanelFocused
}
var dots string
if len(m.entries) > 0 {
dots = s.Faint.Render(m.pager.View())
} }
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
@@ -53,14 +57,21 @@ func (m *Model) renderRequestPanel(w, h int) string {
body = m.textarea.View() body = m.textarea.View()
border = s.PanelFocused border = s.PanelFocused
} else { } else {
body = m.requestViewport.View() body = style.ViewportView(&m.requestViewport)
if m.focusedPanel == panelRequest {
border = s.PanelFocused
}
} }
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h) return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
} }
func (m *Model) renderResponsePanel(w, h int) string { func (m *Model) renderResponsePanel(w, h int) string {
s := style.S s := style.S
return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h) border := s.Panel
if !m.editing && m.focusedPanel == panelResponse {
border = s.PanelFocused
}
return style.RenderWithTitle(border, icons.I.Response+"Response", style.ViewportView(&m.responseViewport), w, h)
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
@@ -72,18 +83,12 @@ func (m *Model) renderList() string {
return lipgloss.Place( return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(), m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
style.S.Faint.Render(" (╥﹏╥)\nsend a request from History or Intercept"), style.S.Faint.Render(util.CenterLines("(╥﹏╥)", "send a request from History or Intercept")),
) )
} }
s := style.S s := style.S
start, end := m.pager.GetSliceBounds(len(m.entries)) start, end := util.PageBounds(m.pager, len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, e := range m.entries[start:end] { for i, e := range m.entries[start:end] {
+30
View File
@@ -0,0 +1,30 @@
package util
// CursorMovePage moves cursor forward or backward by one page (perPage items),
// clamped to [0, total-1].
func CursorMovePage(cursor, total, perPage int, forward bool) int {
step := perPage
if step < 1 {
step = 1
}
if forward {
cursor += step
} else {
cursor -= step
}
if cursor < 0 || total <= 0 {
return 0
}
if cursor >= total {
return total - 1
}
return cursor
}
// CursorGotoBottom returns the last valid cursor index for a list of total items.
func CursorGotoBottom(total int) int {
if total <= 0 {
return 0
}
return total - 1
}
+10 -2
View File
@@ -1,10 +1,13 @@
package util package util
import ( import (
"log"
"os" "os"
"os/exec" "os/exec"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
) )
type EditorFinishedMsg struct { type EditorFinishedMsg struct {
@@ -13,7 +16,10 @@ type EditorFinishedMsg struct {
} }
func OpenExternalEditor(content string) tea.Cmd { func OpenExternalEditor(content string) tea.Cmd {
editor := os.Getenv("EDITOR") editor := config.Global.App.ExternalEditor
if editor == "" {
editor = os.Getenv("EDITOR")
}
if editor == "" { if editor == "" {
editor = "vi" editor = "vi"
} }
@@ -22,7 +28,9 @@ func OpenExternalEditor(content string) tea.Cmd {
return nil return nil
} }
tmpPath := f.Name() tmpPath := f.Name()
_, _ = f.WriteString(content) if _, err := f.WriteString(content); err != nil {
log.Printf("editor: writing temp file: %v", err)
}
f.Close() f.Close()
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg { return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
+86
View File
@@ -0,0 +1,86 @@
package util
import (
"fmt"
"net/http"
"sort"
"strings"
)
// RawRequest holds a parsed raw HTTP request string.
type RawRequest struct {
Method string
Path string
Proto string
Host string
Headers []RawHeader
Body string
}
// RawHeader is a single header key/value pair preserving insertion order.
type RawHeader struct {
Key string
Value string
}
// ParseRawRequest parses a raw HTTP request string (as produced by
// FormatRawRequest). The Host header, if present, is extracted into Host
// but also kept in Headers.
func ParseRawRequest(raw string) RawRequest {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
var r RawRequest
if len(lines) == 0 {
return r
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 1 {
r.Method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
r.Path = strings.TrimSpace(parts[1])
}
if len(parts) >= 3 {
r.Proto = strings.TrimSpace(parts[2])
}
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
r.Headers = append(r.Headers, RawHeader{k, v})
if strings.EqualFold(k, "host") {
r.Host = v
}
}
i++
}
if i < len(lines) {
r.Body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
}
return r
}
// SortedHeaderLines returns header lines sorted by key name, formatted as
// "Key: Value\n" strings. Useful for deterministic serialisation.
func SortedHeaderLines(h http.Header) []string {
keys := make([]string, 0, len(h))
for k := range h {
keys = append(keys, k)
}
sort.Strings(keys)
var out []string
for _, k := range keys {
for _, v := range h[k] {
out = append(out, fmt.Sprintf("%s: %s\n", k, v))
}
}
return out
}
+33 -1
View File
@@ -1,6 +1,11 @@
package util package util
import "strings" import (
"strings"
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2"
)
func Truncate(s string, max int) string { func Truncate(s string, max int) string {
if len(s) <= max { if len(s) <= max {
@@ -9,6 +14,33 @@ func Truncate(s string, max int) string {
return s[:max-1] + "…" return s[:max-1] + "…"
} }
// CenterLines centers each line horizontally relative to the longest one.
func CenterLines(lines ...string) string {
maxWidth := 0
for _, l := range lines {
if w := lipgloss.Width(l); w > maxWidth {
maxWidth = w
}
}
centered := make([]string, len(lines))
for i, l := range lines {
centered[i] = lipgloss.PlaceHorizontal(maxWidth, lipgloss.Center, l)
}
return strings.Join(centered, "\n")
}
// PageBounds returns clamped start/end indices for rendering a paginated list.
func PageBounds(p paginator.Model, total int) (start, end int) {
start, end = p.GetSliceBounds(total)
if start < 0 {
start = 0
}
if end < start {
end = start
}
return
}
// InferScheme returns "http" for port 80, "https" otherwise. // InferScheme returns "http" for port 80, "https" otherwise.
func InferScheme(host string) string { func InferScheme(host string) string {
if strings.HasSuffix(host, ":80") { if strings.HasSuffix(host, ":80") {
+39
View File
@@ -0,0 +1,39 @@
package util
import (
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
)
// ScrollViewport scrolls vp vertically by half its height.
// delta should be -1 for up, +1 for down.
func ScrollViewport(vp *viewport.Model, delta int) {
step := vp.Height() / 2
if step < 1 {
step = 1
}
vp.SetYOffset(vp.YOffset() + delta*step)
}
// HandleMouseWheel applies standard mouse wheel scrolling to vp.
// Vertical: one line at a time. Shift+vertical or horizontal: scroll 6 columns.
func HandleMouseWheel(msg tea.MouseWheelMsg, vp *viewport.Model) {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
vp.ScrollLeft(6)
} else {
vp.SetYOffset(vp.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
vp.ScrollRight(6)
} else {
vp.SetYOffset(vp.YOffset() + 1)
}
case tea.MouseWheelLeft:
vp.ScrollLeft(6)
case tea.MouseWheelRight:
vp.ScrollRight(6)
}
}
+242
View File
@@ -0,0 +1,242 @@
schema = 3
[mod]
[mod.'charm.land/bubbles/v2']
version = 'v2.1.0'
hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA='
[mod.'charm.land/bubbletea/v2']
version = 'v2.0.6'
hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434='
[mod.'charm.land/glamour/v2']
version = 'v2.0.0'
hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4='
[mod.'charm.land/lipgloss/v2']
version = 'v2.0.3'
hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk='
[mod.'github.com/alecthomas/chroma/v2']
version = 'v2.24.1'
hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng='
[mod.'github.com/andybalholm/brotli']
version = 'v1.0.4'
hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8='
[mod.'github.com/atotto/clipboard']
version = 'v0.1.4'
hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk='
[mod.'github.com/aymerick/douceur']
version = 'v0.2.0'
hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE='
[mod.'github.com/charmbracelet/colorprofile']
version = 'v0.4.3'
hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA='
[mod.'github.com/charmbracelet/ultraviolet']
version = 'v0.0.0-20260511121909-c840852527f3'
hash = 'sha256-oWEEaaetCmXvflD03MWy8qD105Kd62iI62+lxofE9Ns='
[mod.'github.com/charmbracelet/x/ansi']
version = 'v0.11.7'
hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ='
[mod.'github.com/charmbracelet/x/exp/slice']
version = 'v0.0.0-20260517005351-920740d613be'
hash = 'sha256-bMsbEzP1gHA2OJx4zMYZUI3UFhcTG+mcFm8rRY+Khh8='
[mod.'github.com/charmbracelet/x/term']
version = 'v0.2.2'
hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4='
[mod.'github.com/charmbracelet/x/termios']
version = 'v0.1.1'
hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds='
[mod.'github.com/charmbracelet/x/windows']
version = 'v0.2.2'
hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk='
[mod.'github.com/clipperhouse/displaywidth']
version = 'v0.11.0'
hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo='
[mod.'github.com/clipperhouse/uax29/v2']
version = 'v2.7.0'
hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE='
[mod.'github.com/dlclark/regexp2']
version = 'v1.12.0'
hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI='
[mod.'github.com/dustin/go-humanize']
version = 'v1.0.1'
hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc='
[mod.'github.com/fsnotify/fsnotify']
version = 'v1.10.1'
hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU='
[mod.'github.com/go-viper/mapstructure/v2']
version = 'v2.5.0'
hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U='
[mod.'github.com/golang/groupcache']
version = 'v0.0.0-20210331224755-41bb18bfe9da'
hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0='
[mod.'github.com/google/uuid']
version = 'v1.6.0'
hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw='
[mod.'github.com/gorilla/css']
version = 'v1.0.1'
hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A='
[mod.'github.com/gorilla/websocket']
version = 'v1.5.0'
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
[mod.'github.com/klauspost/compress']
version = 'v1.17.8'
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
[mod.'github.com/lqqyt2423/go-mitmproxy']
version = 'v1.8.11'
hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg='
[mod.'github.com/lucasb-eyer/go-colorful']
version = 'v1.4.0'
hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0='
[mod.'github.com/mattn/go-isatty']
version = 'v0.0.22'
hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4='
[mod.'github.com/mattn/go-runewidth']
version = 'v0.0.23'
hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA='
[mod.'github.com/microcosm-cc/bluemonday']
version = 'v1.0.27'
hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es='
[mod.'github.com/muesli/cancelreader']
version = 'v0.2.2'
hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ='
[mod.'github.com/ncruces/go-strftime']
version = 'v1.0.0'
hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ='
[mod.'github.com/pelletier/go-toml/v2']
version = 'v2.3.1'
hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg='
[mod.'github.com/remyoudompheng/bigfft']
version = 'v0.0.0-20230129092748-24d4a6f8daec'
hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM='
[mod.'github.com/rivo/uniseg']
version = 'v0.4.7'
hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo='
[mod.'github.com/sagikazarmark/locafero']
version = 'v0.12.0'
hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws='
[mod.'github.com/sahilm/fuzzy']
version = 'v0.1.1'
hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY='
[mod.'github.com/satori/go.uuid']
version = 'v1.2.0'
hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg='
[mod.'github.com/sirupsen/logrus']
version = 'v1.9.4'
hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg='
[mod.'github.com/spf13/afero']
version = 'v1.15.0'
hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8='
[mod.'github.com/spf13/cast']
version = 'v1.10.0'
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
[mod.'github.com/spf13/pflag']
version = 'v1.0.10'
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
[mod.'github.com/spf13/viper']
version = 'v1.21.0'
hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI='
[mod.'github.com/subosito/gotenv']
version = 'v1.6.0'
hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A='
[mod.'github.com/xo/terminfo']
version = 'v0.0.0-20220910002029-abceb7e1c41e'
hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU='
[mod.'github.com/yuin/goldmark']
version = 'v1.8.2'
hash = 'sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q='
[mod.'github.com/yuin/goldmark-emoji']
version = 'v1.0.6'
hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY='
[mod.'github.com/yuin/gopher-lua']
version = 'v1.1.2'
hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po='
[mod.'go.uber.org/atomic']
version = 'v1.11.0'
hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY='
[mod.'go.yaml.in/yaml/v3']
version = 'v3.0.4'
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
[mod.'golang.org/x/net']
version = 'v0.54.0'
hash = 'sha256-/EoIXzTQzK/yP/lxOyx0Z/bhns4FdPTIF4uyt4gIP80='
[mod.'golang.org/x/sync']
version = 'v0.20.0'
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
[mod.'golang.org/x/sys']
version = 'v0.44.0'
hash = 'sha256-JDlj+PKsG6I6kjv5JyOUNreY51u5An0oZ5OZMHZSk+A='
[mod.'golang.org/x/text']
version = 'v0.37.0'
hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E='
[mod.'gopkg.in/yaml.v3']
version = 'v3.0.1'
hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU='
[mod.'modernc.org/libc']
version = 'v1.72.3'
hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI='
[mod.'modernc.org/mathutil']
version = 'v1.7.1'
hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc='
[mod.'modernc.org/memory']
version = 'v1.11.0'
hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM='
[mod.'modernc.org/sqlite']
version = 'v1.50.1'
hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0='
+21
View File
@@ -0,0 +1,21 @@
{
pkgs,
buildGoApplication,
}: let
pname = "spilltea";
version = "0.0.6";
ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication {
inherit pname version ldflags;
src = ../.;
modules = ./gomod2nix.toml;
meta = with pkgs.lib; {
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
homepage = "https://github.com/anotherhadi/spilltea";
platforms = platforms.unix;
};
};
in {
"${pname}" = pkg;
default = pkg;
}
+62
View File
@@ -0,0 +1,62 @@
{
pkgs,
gitHooksLib,
gomod2nixPkgs,
}: let
hooks = gitHooksLib.run {
src = ../.;
hooks = {
gofmt.enable = true;
govet.enable = true;
gomod2nix = {
enable = true;
name = "gomod2nix";
entry = "gomod2nix --outdir ./nix";
language = "system";
files = "go\\.(mod|sum)$";
pass_filenames = false;
};
inject-exec-basics = {
enable = true;
name = "inject-exec-basics";
entry = "python3 .github/scripts/inject-exec.py docs/basics.md";
language = "system";
files = "(docs/basics\\.md|cmd/)";
pass_filenames = false;
};
inject-exec = {
enable = true;
name = "inject-exec";
entry = "python3 .github/scripts/inject-exec.py README.md";
language = "system";
files = "(README\\.md|docs/basics\\.md|cmd/)";
pass_filenames = false;
};
doctoc = {
enable = true;
name = "doctoc";
entry = "doctoc --notitle README.md";
language = "system";
files = "(README\\.md|docs/basics\\.md|cmd/)";
pass_filenames = false;
};
};
};
in
pkgs.mkShell {
packages = with pkgs;
[
go
python3
doctoc
trufflehog
gomod2nixPkgs.gomod2nix
]
++ hooks.enabledPackages;
shellHook = hooks.shellHook;
}
+10 -4
View File
@@ -3,23 +3,29 @@ Plugin = {
description = [[ description = [[
Inject custom headers into every intercepted request. Inject custom headers into every intercepted request.
**Config**: **Config** (YAML):
- one 'Header-Name: value' per line. ```yaml
headers:
- "X-My-Header: myvalue"
```
]], ]],
on_request = { sync = true }, on_request = { sync = true },
} }
local headers = {} local headers = {}
function on_config(config_text) function on_config()
headers = {} headers = {}
for line in config_text:gmatch("[^\n]+") do local cfg = get_config()
if cfg and cfg.headers then
for _, line in ipairs(cfg.headers) 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
end end
end
function on_request(req) function on_request(req)
for _, h in ipairs(headers) do for _, h in ipairs(headers) do
+20 -24
View File
@@ -3,52 +3,48 @@ Plugin = {
description = [[ description = [[
Checks that the proxy's outbound IP is in an allowed list on startup. Checks that the proxy's outbound IP is in an allowed list on startup.
**Config**: **Config** (YAML):
- one IP per line ```yaml
- prefix with `!` for a blacklist entry (blocked) ips:
- prefix with `#` to comment it out (ignored) - "1.2.3.4" # whitelist entry
- if no IPs are configured, the check is skipped - "!5.6.7.8" # blacklist entry (blocked)
```
- If no IPs are configured, the check is skipped.
]], ]],
on_start = { sync = false }, on_start = { sync = false },
disable_by_default = true,
} }
local whitelist = {} local whitelist = {}
local blacklist = {} local blacklist = {}
function on_config(config_text) function on_config()
whitelist = {} whitelist, blacklist = {}, {}
blacklist = {} local cfg = get_config()
if cfg and cfg.ips then
for line in config_text:gmatch("[^\n]+") do for _, entry in ipairs(cfg.ips) do
local trimmed = line:match("^%s*(.-)%s*$") local trimmed = entry:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then if trimmed ~= "" then
if trimmed:sub(1, 1) == "!" then if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$") local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then if ip ~= "" then table.insert(blacklist, ip) end
table.insert(blacklist, ip)
end
else else
table.insert(whitelist, trimmed) table.insert(whitelist, trimmed)
end end
end end
end end
end end
end
function on_start() function on_start()
if #whitelist == 0 and #blacklist == 0 then if #whitelist == 0 and #blacklist == 0 then
return return
end end
-- Fetch the current outbound IP via a public API. local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
local ok, result = pcall(function() result = result and result:match("^%s*(.-)%s*$") or nil
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
if not handle then return nil end
local ip = handle:read("*a")
handle:close()
return ip and ip:match("^%s*(.-)%s*$") or nil
end)
if not ok or not result or result == "" then if err or not result or result == "" then
log("could not determine outbound IP, skipping check") log("could not determine outbound IP, skipping check")
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning") notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
return return
+59 -34
View File
@@ -3,29 +3,35 @@ Plugin = {
description = [[ description = [[
Auto-forward requests and exclude them from history based on patterns. Auto-forward requests and exclude them from history based on patterns.
**Config**: **Config** (YAML):
- `pattern` - whitelist: only intercept matching requests ```yaml
- `!pattern` - blacklist: don't intercept matching requests and exclude from history patterns:
- lines starting with `#` are comments - "pattern" # whitelist: only intercept matching requests/responses and history
- "!pattern" # blacklist: skip matching requests/responses and history
- "r:pattern" # whitelist for requests/responses only
- "r:!pattern" # blacklist for requests/responses only
- "h:pattern" # whitelist for history only
- "h:!pattern" # blacklist for history only
```
Example (ignore static assets): Example (ignore static assets):
``` ```yaml
!%.css$ patterns:
!%.js$ - "!%.css$"
!%.png$ - "!%.js$"
- "!%.png$"
``` ```
Example (focus on mytarget.com, skip everything else): Example (focus on mytarget.com):
``` ```yaml
mytarget%.com/ patterns:
- "mytarget%.com/"
``` ```
Example (intercept mytarget.com except its static assets): Example (disable history):
``` ```yaml
mytarget%.com/ patterns:
!%.css$ - "h:^$"
!%.js$
!%.png$
``` ```
]], ]],
priority = 100, priority = 100,
@@ -34,31 +40,50 @@ mytarget%.com/
on_history_entry = { sync = true }, on_history_entry = { sync = true },
} }
local whitelist = {}
local blacklist = {} local blacklist = {}
local whitelist = {}
local blacklist_req = {}
local whitelist_req = {}
local blacklist_hist = {}
local whitelist_hist = {}
function on_config(config_text) function on_config()
whitelist = {} blacklist, whitelist = {}, {}
blacklist = {} blacklist_req, whitelist_req = {}, {}
for line in config_text:gmatch("[^\n]+") do blacklist_hist, whitelist_hist = {}, {}
local cfg = get_config()
if not cfg or not cfg.patterns then return end
for _, line in ipairs(cfg.patterns) do
local trimmed = line:match("^%s*(.-)%s*$") local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
if trimmed:sub(1, 1) == "!" then local scope = trimmed:match("^([rh]):")
table.insert(blacklist, trimmed:sub(2)) 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 else
table.insert(whitelist, trimmed) table.insert(is_bl and blacklist or whitelist, pattern)
end end
end end
end end
end end
local function should_skip(url) local function check_skip(url, bl_extra, wl_extra)
for _, pattern in ipairs(blacklist) do for _, p in ipairs(blacklist) do
if url:match(pattern) then return true end if url:match(p) then return true end
end end
if #whitelist > 0 then for _, p in ipairs(bl_extra) do
for _, pattern in ipairs(whitelist) do if url:match(p) then return true end
if url:match(pattern) then return false 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 end
return true return true
end end
@@ -66,13 +91,13 @@ local function should_skip(url)
end end
function on_request(req) function on_request(req)
if should_skip(req.url) then return "forward" end if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
end end
function on_response(req) function on_response(req)
if should_skip(req.url) then return "forward" end if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
end end
function on_history_entry(entry) function on_history_entry(entry)
if should_skip(entry.host .. entry.path) then return "skip" end if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then return "skip" end
end end
+166
View File
@@ -0,0 +1,166 @@
Plugin = {
name = "Secret Scan",
description = [[
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
secrets by matching common secret key names followed by a non-trivial value.
Uses `grep -E` (available on all Unix systems, no extra dependencies).
]],
on_request = { sync = false },
on_response = { sync = false },
disable_by_default = true,
}
local CONTENT_TYPES = {
"text/html",
"text/javascript",
"application/javascript",
"application/json",
}
-- Key name alternation (case-insensitive via grep -i)
-- Suffixes are required (no bare generic keyword alone).
local KEYS = {
"access(_key|_token)", "accessid_secret", "account(_key|_sid)",
"admin_pass(word)?", "admin_user",
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
"ansible_vault_password", "aos_key",
"api(_key|_secret|_token)",
"app_(id|key|secret)", "application(_key|_id|_secret)",
"auth(_token|_secret|orization)", "authkey", "authsecret",
"bearer_?token",
"bucket(_password|_key)",
"cert_?pass(word)?", "certificate_password",
"client(_id|_secret)",
"codecov_token", "consumer_(key|secret)",
"connection_?string", "credentials?", "crypt(_key|_secret)",
"db_(password|passwd|user(name)?)",
"deploy(_key|_password|_token)",
"docker_?pass(word)?", "dockerhub_?password",
"encryption_(key|password)",
"jwt_secret", "json_web_token",
"keycloak_secret", "kubernetes_token",
"ldap_(password|bindpw)", "login(_password|_token)",
"mail_?password", "mail_smtp_pass",
"mysql_password", "mongo_password",
"netlify_token", "npm(_token|_auth_token)",
"oauth(_token|_secret)",
"openai_(api_key|secret)",
"pass(word)?", "passwd",
"private(_key|_token)",
"rds_password",
"s3(_key|_secret|_access_key_id)",
"secret(_key|_token|_id)", "security_token",
"sendgrid_api_key",
"ses_(smtp|access|secret)",
"service(_account|_key|_token)",
"smtp_pass(word)?", "smtp_secret",
"sonar_token",
"ssh(_key|_private_key|_rsa)",
"supabase(_anon|_service)?_key",
"symfony_secret",
"telegram_bot_token",
"token",
"travis_token",
"vault(_token|_secret)",
"webhook(_secret|_token)",
"zapier_webhook_token",
}
-- Built once at load time.
-- Pattern breakdown:
-- KEY[a-z0-9._-]{0,20} key name + optional alphanumeric suffix (e.g. _ID in AWS_ACCESS_KEY_ID)
-- [^=:a-zA-Z0-9_]{0,3} optional non-identifier chars before separator (e.g. closing " in JSON "key":)
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
-- [[:space:]]*"? optional whitespace + opening quote
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:=][[:space:]]*"?[a-zA-Z0-9+/=_.-]{8,}'
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
local function is_relevant(ct)
if not ct or ct == "" then return false end
ct = ct:lower()
for _, t in ipairs(CONTENT_TYPES) do
if ct:find(t, 1, true) then return true end
end
return false
end
local function build_context(lines, linenum)
local lo = math.max(1, linenum - 6)
local hi = math.min(#lines, linenum + 6)
local before, after = {}, {}
for i = lo, linenum - 1 do
local l = lines[i] or ""
if #l > 120 then l = l:sub(1, 120) .. "..." end
table.insert(before, l)
end
for i = linenum + 1, hi do
local l = lines[i] or ""
if #l > 120 then l = l:sub(1, 120) .. "..." end
table.insert(after, l)
end
local matched_line = lines[linenum] or ""
if #matched_line > 200 then matched_line = matched_line:sub(1, 200) .. "..." end
local parts = {}
if #before > 0 then
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
end
table.insert(parts, "> **`" .. matched_line .. "`**")
if #after > 0 then
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
end
return table.concat(parts, "\n\n")
end
local function scan(label, ct, body, host, path)
if not is_relevant(ct) then return end
if not body or body == "" then return end
local out, err = shell_pipe(GREP_CMD, body)
if err and err ~= "" then
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
return
end
if not out or out == "" then return end
local lines = {}
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
for entry in out:gmatch("[^\n]+") do
local linenum_str, matched = entry:match("^(%d+):(.+)$")
if linenum_str then
local linenum = tonumber(linenum_str)
matched = matched:match("^%s*(.-)%s*$")
if matched ~= "" then
local display = matched
if #display > 200 then display = display:sub(1, 200) .. "..." end
local ctx = build_context(lines, linenum)
create_finding({
title = "Potential secret in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx,
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
severity = "high",
})
end
end
end
end
function on_request(req)
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
end
function on_response(req, res)
local ct = ""
if res.headers then
ct = res.headers["Content-Type"] or ""
end
scan("response", ct, res:get_body(), req.host, req.path)
end
+61
View File
@@ -0,0 +1,61 @@
Plugin = {
name = "TruffleHog",
description = [[
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
Requires `trufflehog` v3+ to be installed and available in PATH.
Each finding is stored on the **Findings** page with the matched detector output.
Findings are deduplicated per host+path+body content so repeated requests do not create duplicates.
]],
on_start = { sync = false },
on_request = { sync = false },
on_response = { sync = false },
disable_by_default = true,
}
function on_start()
local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
if not result or result:match("^%s*$") then
log("trufflehog is not installed or not in PATH")
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
return false
end
end
local function scan(label, content, host, path)
if not content or content == "" then return end
local out, err = shell_pipe("f=$(mktemp) && cat > \"$f\" && trufflehog filesystem --no-color \"$f\"; rc=$?; rm -f \"$f\"; exit $rc", content)
if err and err ~= "" then
log("trufflehog error on " .. label .. ": " .. err)
return
end
if not out or out == "" then return end
local blocks = {}
local current = nil
for line in out:gmatch("[^\n]+") do
if line:match("^Found ") then
if current then table.insert(blocks, current) end
current = line
elseif current then
current = current .. "\n" .. line
end
end
if current then table.insert(blocks, current) end
for _, block in ipairs(blocks) do
create_finding({
title = "Secret detected in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
severity = "high",
})
end
end
function on_request(req)
scan("request", req:get_body(), req.host, req.path)
end
function on_response(req, res)
scan("response", res:get_body(), req.host, req.path)
end
+3
View File
@@ -8,6 +8,9 @@ import (
"path/filepath" "path/filepath"
) )
//go:embed docs
var DocsFS embed.FS
//go:embed plugins/*.lua //go:embed plugins/*.lua
var PluginsFS embed.FS var PluginsFS embed.FS