mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 17:52:33 +02:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2225afd9ee | |||
| 6dc959de77 | |||
| 0017f37c33 | |||
| 924cb73afb | |||
| 746f1afd1b | |||
| 905013943d | |||
| c6bca887cb | |||
| dcf9cb4c8e | |||
| ae372d7283 | |||
| e20250f0a0 | |||
| 3463e51739 | |||
| 87fa9448d6 | |||
| 4240c4ceb9 | |||
| d79c9f91d1 | |||
| 33e2afe709 | |||
| 2c3e19258f | |||
| 69d5d0ffec | |||
| d47f51d2b5 | |||
| 598455f8d3 | |||
| 28b070dafc | |||
| 6f56e0b26a | |||
| eaa960e6ab | |||
| f874a70639 | |||
| 4643989ab6 | |||
| 7bbc00880a | |||
| 385b6e84e0 | |||
| 6a9935ec27 | |||
| b490c7a0ac | |||
| 1a1c0cff30 | |||
| 172a77e13b | |||
| 41c0e489cf | |||
| 79128bb865 | |||
| 48de2a8e10 | |||
| b4a45a23e5 | |||
| b5e2721aa1 | |||
| 0cfba17d3d | |||
| a147e8b972 | |||
| 03260e0947 | |||
| ed59923b7d | |||
| aa7b639f82 | |||
| 27e0c418e9 | |||
| 08757a5d1d | |||
| ffee0978e6 | |||
| 4f45a7c061 | |||
| 0fafa52c65 | |||
| 366bb682d2 | |||
| 9fe0c74150 | |||
| 3b6b58ac2b | |||
| 789a513469 | |||
| 615093bd8b | |||
| 7e1b7d3b5a | |||
| e3e89582c1 | |||
| 2705c2882d | |||
| 6ea692754a | |||
| 85c2806604 | |||
| d451965fa0 | |||
| d82a220e91 | |||
| 1ac5eb26e8 | |||
| 969febb14c | |||
| 6aa377acd8 | |||
| 2f4765bf37 | |||
| fac335a16e |
Binary file not shown.
|
After Width: | Height: | Size: 906 KiB |
@@ -0,0 +1,41 @@
|
||||
Output ./.github/assets/demo.gif
|
||||
Require spilltea
|
||||
|
||||
Set Shell "zsh"
|
||||
Set FontSize 32
|
||||
Set Width 1600
|
||||
Set Height 1900
|
||||
|
||||
Type "spilltea"
|
||||
Sleep 800ms
|
||||
Enter
|
||||
Sleep 3s
|
||||
|
||||
Down@800ms 2
|
||||
Up@800ms 1
|
||||
Enter@800ms 1
|
||||
|
||||
Wait+Screen /hadi.icu/
|
||||
Sleep 3s
|
||||
|
||||
Ctrl+Y
|
||||
Sleep 3s
|
||||
|
||||
Down@1.9 4
|
||||
Escape@1.3 1
|
||||
|
||||
Ctrl+R
|
||||
Sleep 3s
|
||||
Type "e"
|
||||
Sleep 1s
|
||||
Escape
|
||||
Sleep 1s
|
||||
Type "s"
|
||||
Sleep 6s
|
||||
|
||||
Type "1"
|
||||
Sleep 2s
|
||||
Type "f"
|
||||
Sleep 2s
|
||||
Type "2"
|
||||
Sleep 2s
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CURRENT_HASH=$(grep -oP '(?<=vendorHash = ")[^"]+' flake.nix)
|
||||
|
||||
go mod vendor
|
||||
|
||||
COMPUTED_HASH=$(nix hash path vendor/)
|
||||
|
||||
rm -rf vendor/
|
||||
|
||||
if [ "$CURRENT_HASH" = "$COMPUTED_HASH" ]; then
|
||||
echo "vendorHash is up to date"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Updating vendorHash in flake.nix..."
|
||||
python3 -c "
|
||||
import sys
|
||||
with open('flake.nix', 'r') as f:
|
||||
content = f.read()
|
||||
content = content.replace('$CURRENT_HASH', '$COMPUTED_HASH')
|
||||
with open('flake.nix', 'w') as f:
|
||||
f.write(content)
|
||||
"
|
||||
echo " Old: $CURRENT_HASH"
|
||||
echo " New: $COMPUTED_HASH"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PATTERN = re.compile(r"<!-- exec: (.+?) -->.*?<!-- endexec -->", re.DOTALL)
|
||||
|
||||
|
||||
def replace(match):
|
||||
cmd = match.group(1).strip()
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
output = result.stdout
|
||||
if result.returncode != 0:
|
||||
print(f"[inject-exec] command failed ({result.returncode}): {cmd}", file=sys.stderr)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
output = re.sub(r"<!-- exec: .+? -->\n?|<!-- endexec -->\n?", "", output)
|
||||
if output and not output.endswith("\n"):
|
||||
output += "\n"
|
||||
return f"<!-- exec: {cmd} -->\n{output}<!-- endexec -->"
|
||||
|
||||
|
||||
def process(path):
|
||||
content = Path(path).read_text()
|
||||
new_content = PATTERN.sub(replace, content)
|
||||
if new_content != content:
|
||||
Path(path).write_text(new_content)
|
||||
print(f"[inject-exec] updated {path}")
|
||||
|
||||
|
||||
for p in sys.argv[1:]:
|
||||
process(p)
|
||||
@@ -14,23 +14,92 @@
|
||||
[](LICENSE)
|
||||
[](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?
|
||||
|
||||
Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between your browser and the internet, letting you inspect, modify, and replay traffic without ever leaving your terminal.
|
||||
|
||||
It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, keyboard-driven tool that gets out of your way.
|
||||
|
||||
<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
|
||||
|
||||
- **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.
|
||||
- **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)
|
||||
- **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:
|
||||
- **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.
|
||||
- **Markdown Export**: Export any request and its response as a clean Markdown snippet, ready to drop into a report.
|
||||
|
||||
## Installation
|
||||
|
||||
<details>
|
||||
<summary>Go install</summary>
|
||||
|
||||
```sh
|
||||
go install github.com/anotherhadi/spilltea/cmd/spilltea@latest
|
||||
```
|
||||
|
||||
Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Nix (temporary run, no install)</summary>
|
||||
|
||||
```sh
|
||||
nix run github:anotherhadi/spilltea
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>NixOS (flake)</summary>
|
||||
|
||||
Add spilltea to your flake inputs:
|
||||
|
||||
```nix
|
||||
inputs.spilltea.url = "github:anotherhadi/spilltea";
|
||||
```
|
||||
|
||||
Then add the package to your system or home-manager packages:
|
||||
|
||||
```nix
|
||||
environment.systemPackages = [ inputs.spilltea.packages.${pkgs.system}.default ];
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<!-- exec: cat ./docs/basics.md -->
|
||||
## Project Management
|
||||
|
||||
Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
|
||||
@@ -41,11 +110,6 @@ On startup, you choose:
|
||||
- **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!
|
||||
|
||||
## 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
|
||||
|
||||
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
|
||||
@@ -53,16 +117,25 @@ Check the default configuration with all the options [here](./internal/config/de
|
||||
|
||||
## CLI Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| ----------------------- | ----- | ------------------------------------------------------------------------------ |
|
||||
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
|
||||
| `--plugin-dir` | | Path to plugins dir, overrides config (default: `~/.config/spilltea/plugins/`) |
|
||||
| `--host` | | Proxy host, overrides config |
|
||||
| `--port` | `-p` | Proxy port, overrides config |
|
||||
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session |
|
||||
| `--upstream-proxy` | | Upstream proxy URL, overrides config (e.g. `http://user:pass@host:8888`) |
|
||||
| `--version` | `-v` | Print version and exit |
|
||||
| `--add-default-plugins` | | Add the default plugins to your plugins dir and exit |
|
||||
```
|
||||
Usage: spilltea [flags]
|
||||
|
||||
--add-default-config copy the default config file to the config path and exit
|
||||
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||
-c, --config string path to config file
|
||||
--host string proxy host (overrides config)
|
||||
--plugins-dir string path to plugins dir (overrides config)
|
||||
-p, --port int proxy port (overrides config)
|
||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||
-v, --version print version
|
||||
```
|
||||
<!-- endexec -->
|
||||
|
||||
## Plugin System
|
||||
|
||||
Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code.
|
||||
For a full reference and examples, see the [plugin documentation](./docs/plugins.md) or [plugin examples](./plugins/).
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
+45
-30
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
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.
|
||||
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() {
|
||||
var (
|
||||
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
||||
@@ -31,7 +40,13 @@ func main() {
|
||||
flagVersion = flag.BoolP("version", "v", false, "print version")
|
||||
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
||||
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
||||
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()
|
||||
|
||||
if *flagVersion {
|
||||
@@ -40,7 +55,8 @@ func main() {
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
cfgPath = *flagConfig
|
||||
}
|
||||
@@ -61,12 +77,27 @@ func main() {
|
||||
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) {
|
||||
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
|
||||
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 != "" {
|
||||
cfgPath = *flagConfig
|
||||
}
|
||||
@@ -90,48 +121,32 @@ func main() {
|
||||
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)
|
||||
icons.Init(config.Global)
|
||||
keys.Init(config.Global)
|
||||
|
||||
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
||||
|
||||
// Resolve project: either from --project flag or by running the home UI.
|
||||
var project *homeUI.Project
|
||||
// If --project flag is set, skip the home screen entirely.
|
||||
if *flagProject != "" {
|
||||
p, err := homeUI.OpenProject(projectDir, *flagProject)
|
||||
project, err := homeUI.OpenProject(projectDir, *flagProject)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "project: %v\n", err)
|
||||
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()
|
||||
m := appUI.New(broker, project.Name, project.Path)
|
||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,5 +2,5 @@ package spilltea
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed .github/docs
|
||||
//go:embed docs
|
||||
var DocsFS embed.FS
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
## Project Management
|
||||
|
||||
Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
|
||||
|
||||
On startup, you choose:
|
||||
|
||||
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
|
||||
- **Existing project**: pick from a list of previous projects
|
||||
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
||||
|
||||
## Configuration
|
||||
|
||||
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
|
||||
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
|
||||
|
||||
## CLI Flags
|
||||
|
||||
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
||||
```
|
||||
Usage: spilltea [flags]
|
||||
|
||||
--add-default-config copy the default config file to the config path and exit
|
||||
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||
-c, --config string path to config file
|
||||
--host string proxy host (overrides config)
|
||||
--plugins-dir string path to plugins dir (overrides config)
|
||||
-p, --port int proxy port (overrides config)
|
||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||
-v, --version print version
|
||||
```
|
||||
<!-- endexec -->
|
||||
@@ -5,10 +5,13 @@
|
||||
- On Chrome:
|
||||
- Open your Chrome settings, search for "Certificates" and click on "Security".
|
||||
- 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}}`.
|
||||
- On Firefox:
|
||||
- Open your Firefox settings, search for "Certificates" and click on "View Certificates".
|
||||
- Select the "Authorities" tab and click on "Import".
|
||||
- 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".
|
||||
|
||||
> [!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.
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
WHERE expression (the `SELECT` is added automatically):
|
||||
**SQL mode**: press `:` to open it, then `Enter` to run. Type a WHERE expression: the full `SELECT … FROM entries WHERE` is added automatically.
|
||||
|
||||
```sql
|
||||
status_code = 404
|
||||
@@ -16,10 +14,8 @@ status_code = 404
|
||||
host LIKE '%.api.%' AND method = 'POST'
|
||||
```
|
||||
|
||||
Full SELECT query:
|
||||
|
||||
```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`.
|
||||
@@ -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.
|
||||
@@ -18,6 +18,7 @@ Plugin = {
|
||||
name = "My Plugin",
|
||||
description = "What this plugin does.",
|
||||
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).
|
||||
-- on_config and on_quit are always sync and do not need to be declared here.
|
||||
@@ -30,14 +31,14 @@ Plugin = {
|
||||
|
||||
### 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_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_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
|
||||
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
|
||||
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) |
|
||||
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
||||
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
||||
| `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
|
||||
|
||||
@@ -109,6 +110,16 @@ end
|
||||
|
||||
-- Quit the app (useful for startup checks that fail)
|
||||
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
|
||||
```
|
||||
|
||||
### Finding deduplication
|
||||
@@ -130,6 +141,27 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
|
||||
|
||||
### Return values for sync hooks
|
||||
|
||||
**`on_start`:**
|
||||
|
||||
| Return value | Effect |
|
||||
| ------------ | -------------------------------------------------------------------------------------------- |
|
||||
| `false` | The plugin is disabled immediately and the state is persisted (equivalent to toggling it off). |
|
||||
| anything else | Ignored. |
|
||||
|
||||
This is useful for prerequisite checks (binary not found, config invalid, etc.) so the plugin does not silently run in a broken state:
|
||||
|
||||
```lua
|
||||
function on_start()
|
||||
local h = io.popen("command -v mytool 2>/dev/null")
|
||||
local result = h and h:read("*a") or ""
|
||||
if h then h:close() end
|
||||
if result:match("^%s*$") then
|
||||
notif("MyPlugin", "mytool not found, plugin disabled", "error")
|
||||
return false
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**`on_request` and `on_response`:**
|
||||
|
||||
| Return value | Effect |
|
||||
@@ -141,7 +173,7 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
|
||||
**`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. |
|
||||
|
||||
@@ -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".
|
||||
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.
|
||||
|
||||
If `proxy_auth` is set in the config (`user:pass`), enter the same credentials in FoxyProxy under "Username" and "Password" in the proxy settings.
|
||||
@@ -14,7 +14,7 @@
|
||||
(system: f system (import nixpkgs {inherit system;}));
|
||||
|
||||
pname = "spilltea";
|
||||
version = "0.0.3";
|
||||
version = "0.0.5";
|
||||
|
||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||
in {
|
||||
@@ -25,7 +25,7 @@
|
||||
src = ./.;
|
||||
outputs = ["out"];
|
||||
|
||||
vendorHash = "sha256-v37RFS/T6KGZTO1tHmtUqBrRcCqNS3+ACBcsd7tl50c=";
|
||||
vendorHash = "sha256-1iPwFsyzdonak9EWMRnudwcCQZfI+Uvre38+puG4s0s=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||
@@ -37,5 +37,20 @@
|
||||
"${pname}" = pkg;
|
||||
default = pkg;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system: pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
go
|
||||
python3
|
||||
lefthook
|
||||
doctoc
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
lefthook install
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ require (
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
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/viper v1.21.0
|
||||
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
|
||||
modernc.org/sqlite v1.50.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -24,8 +24,8 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // 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/termios v0.1.1 // 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/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/klauspost/compress v1.17.8 // 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/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // 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/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/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/cast v1.10.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -24,14 +24,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/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/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow=
|
||||
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/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/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-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be h1:O22D2Od8gEsRGTDPKDTRzx2BGrvVcIAJlwBf+1sTeN0=
|
||||
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/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
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/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
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.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
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/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
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/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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
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/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
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.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
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/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
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.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
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/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
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/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
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=
|
||||
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=
|
||||
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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
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/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
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/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
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.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
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/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
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/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -24,6 +25,9 @@ type Config struct {
|
||||
ProjectDir string `mapstructure:"project_dir"`
|
||||
PluginsDir string `mapstructure:"plugins_dir"`
|
||||
UpstreamProxy string `mapstructure:"upstream_proxy"`
|
||||
ProxyAuth string `mapstructure:"proxy_auth"`
|
||||
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
||||
ExternalEditor string `mapstructure:"external_editor"`
|
||||
} `mapstructure:"app"`
|
||||
|
||||
TUI struct {
|
||||
@@ -45,6 +49,7 @@ type Config struct {
|
||||
|
||||
History struct {
|
||||
SkipDuplicates bool `mapstructure:"skip_duplicates"`
|
||||
KeepResponses bool `mapstructure:"keep_responses"`
|
||||
} `mapstructure:"history"`
|
||||
|
||||
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||
@@ -64,7 +69,7 @@ func Load(path string) error {
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigFile(path)
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -73,6 +78,16 @@ func Load(path string) error {
|
||||
return viper.Unmarshal(Global)
|
||||
}
|
||||
|
||||
func WriteDefaultConfig(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExpandPath(p string) string {
|
||||
if strings.HasPrefix(p, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
|
||||
@@ -5,6 +5,9 @@ app:
|
||||
project_dir: ~/.local/share/spilltea
|
||||
plugins_dir: ~/.config/spilltea/plugins
|
||||
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)
|
||||
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:
|
||||
default_intercept_enabled: true
|
||||
@@ -17,6 +20,7 @@ replay:
|
||||
|
||||
history:
|
||||
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:
|
||||
use_nerdfont_icons: false
|
||||
@@ -43,53 +47,58 @@ tui:
|
||||
keybindings:
|
||||
global:
|
||||
quit: "q,ctrl+c"
|
||||
help: "?"
|
||||
open_logs: "ctrl+g"
|
||||
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"
|
||||
down: "down,j"
|
||||
left: "left,h"
|
||||
right: "right,l"
|
||||
cycle_focus: "tab"
|
||||
copy_as: "ctrl+y"
|
||||
copy: "y"
|
||||
send_to_replay: "ctrl+r"
|
||||
goto_top: "g"
|
||||
goto_bottom: "G,end"
|
||||
scroll_up: "pgup"
|
||||
scroll_down: "pgdown"
|
||||
send_to_diff: "ctrl+d"
|
||||
prev_page: "["
|
||||
next_page: "]"
|
||||
|
||||
intercept:
|
||||
toggle_intercept: "i"
|
||||
capture_response: "r"
|
||||
forward: "f"
|
||||
forward_all: "F"
|
||||
drop: "d"
|
||||
drop_all: "D"
|
||||
toggle_intercept: "i"
|
||||
capture_response: "r"
|
||||
undo_edits: "ctrl+z"
|
||||
edit: "e,enter"
|
||||
edit_external: "E"
|
||||
undo_edits: "ctrl+z"
|
||||
|
||||
history:
|
||||
delete_entry: "x"
|
||||
delete_all: "X"
|
||||
sql_query: ":"
|
||||
filter: "/"
|
||||
flag: "m"
|
||||
|
||||
home:
|
||||
open: "enter,l"
|
||||
open: "l,enter"
|
||||
delete: "x"
|
||||
filter: "/"
|
||||
|
||||
replay:
|
||||
send: "enter,s"
|
||||
send: "s, enter"
|
||||
edit: "e"
|
||||
edit_external: "E"
|
||||
undo_edits: "R"
|
||||
undo_edits: "ctrl+z"
|
||||
delete_entry: "x"
|
||||
delete_all: "X"
|
||||
|
||||
diff:
|
||||
clear: "c"
|
||||
clear: "x"
|
||||
|
||||
findings:
|
||||
dismiss: "x"
|
||||
@@ -98,3 +107,9 @@ keybindings:
|
||||
toggle: "space"
|
||||
edit_config: "e,enter"
|
||||
filter: "/"
|
||||
|
||||
docs:
|
||||
search: "/"
|
||||
search_reset: "r"
|
||||
search_next: "n"
|
||||
search_prev: "N"
|
||||
|
||||
@@ -16,6 +16,10 @@ type GlobalKeys struct {
|
||||
ScrollUp string `mapstructure:"scroll_up"`
|
||||
ScrollDown string `mapstructure:"scroll_down"`
|
||||
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 {
|
||||
@@ -35,6 +39,7 @@ type HistoryKeys struct {
|
||||
DeleteAll string `mapstructure:"delete_all"`
|
||||
Filter string `mapstructure:"filter"`
|
||||
SqlQuery string `mapstructure:"sql_query"`
|
||||
Flag string `mapstructure:"flag"`
|
||||
}
|
||||
|
||||
type HomeKeys struct {
|
||||
@@ -66,6 +71,13 @@ type PluginsKeys struct {
|
||||
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 {
|
||||
Global GlobalKeys `mapstructure:"global"`
|
||||
Intercept InterceptKeys `mapstructure:"intercept"`
|
||||
@@ -75,4 +87,5 @@ type Keybindings struct {
|
||||
Diff DiffKeys `mapstructure:"diff"`
|
||||
Findings FindingsKeys `mapstructure:"findings"`
|
||||
Plugins PluginsKeys `mapstructure:"plugins"`
|
||||
Docs DocsKeys `mapstructure:"docs"`
|
||||
}
|
||||
|
||||
+18
-2
@@ -2,12 +2,15 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
path string
|
||||
dedupMu sync.Mutex
|
||||
}
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
@@ -15,7 +18,11 @@ func Open(path string) (*DB, error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
@@ -24,6 +31,9 @@ func Open(path string) (*DB, 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(`
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -33,7 +43,9 @@ func (d *DB) migrate() error {
|
||||
path TEXT NOT NULL,
|
||||
status_code INTEGER 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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -65,6 +77,10 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+58
-40
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -16,42 +17,48 @@ type Entry struct {
|
||||
StatusCode int
|
||||
RequestRaw 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
|
||||
// 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) {
|
||||
rows, err := d.conn.Query(
|
||||
`SELECT request_raw FROM entries WHERE method = ? AND host = ? AND path = ?`,
|
||||
method, host, path,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
hash := bodyHash(body)
|
||||
var exists int
|
||||
err := d.conn.QueryRow(
|
||||
`SELECT 1 FROM entries WHERE method = ? AND host = ? AND path = ? AND body_hash = ? LIMIT 1`,
|
||||
method, host, path, hash,
|
||||
).Scan(&exists)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
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()
|
||||
return err == nil, 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(
|
||||
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw, body_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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 {
|
||||
return e, err
|
||||
@@ -65,10 +72,12 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
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
|
||||
}
|
||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||
e.Flagged = flagged != 0
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
@@ -76,7 +85,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||
|
||||
func (d *DB) ListEntries() ([]Entry, error) {
|
||||
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`,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -89,7 +98,7 @@ func (d *DB) ListEntries() ([]Entry, error) {
|
||||
func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
||||
like := "%" + term + "%"
|
||||
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
|
||||
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
|
||||
ORDER BY id DESC`,
|
||||
@@ -102,17 +111,21 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
||||
return scanEntries(rows)
|
||||
}
|
||||
|
||||
// QueryEntries executes a user-supplied query against the entries table.
|
||||
// If the query does not start with SELECT, it is treated as a WHERE expression
|
||||
// and wrapped automatically (e.g. "status_code = 404" becomes a full SELECT).
|
||||
func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
|
||||
q := strings.TrimSpace(rawSQL)
|
||||
if !strings.HasPrefix(strings.ToUpper(q), "SELECT") {
|
||||
q = "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw FROM entries WHERE " + q
|
||||
} else if strings.ContainsAny(strings.ToUpper(q), "INSERTDELETEUPDATEDROP") {
|
||||
return nil, fmt.Errorf("only SELECT queries are allowed")
|
||||
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
|
||||
// It opens a dedicated read-only connection so that any DML or DDL in the
|
||||
// user-supplied expression is rejected by SQLite before it can execute.
|
||||
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
||||
roConn, err := sql.Open("sqlite", d.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := d.conn.Query(q)
|
||||
defer roConn.Close()
|
||||
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
|
||||
rows, err := roConn.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,6 +133,11 @@ func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
|
||||
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 {
|
||||
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
|
||||
return err
|
||||
|
||||
@@ -17,6 +17,8 @@ type Finding struct {
|
||||
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
|
||||
// not already exist. Returns true when the row was actually inserted.
|
||||
func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
||||
d.dedupMu.Lock()
|
||||
defer d.dedupMu.Unlock()
|
||||
res, err := d.conn.Exec(
|
||||
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||
|
||||
@@ -20,6 +20,7 @@ type Icons struct {
|
||||
New string
|
||||
Temp string
|
||||
Project string
|
||||
Flag string
|
||||
}
|
||||
|
||||
var I *Icons
|
||||
@@ -44,6 +45,7 @@ func Init(cfg *config.Config) {
|
||||
New: " ",
|
||||
Temp: " ",
|
||||
Project: " ",
|
||||
Flag: " ",
|
||||
}
|
||||
} else {
|
||||
I = &Icons{}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -75,9 +76,12 @@ func (b *Broker) SetCaptureResponse(v bool) {
|
||||
func (b *Broker) SetAutoForwardRegex(patterns []string) {
|
||||
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||
for _, p := range patterns {
|
||||
if r, err := regexp.Compile(p); err == nil {
|
||||
compiled = append(compiled, r)
|
||||
r, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
log.Printf("intercept: invalid auto_forward_regex %q: %v", p, err)
|
||||
continue
|
||||
}
|
||||
compiled = append(compiled, r)
|
||||
}
|
||||
b.autoFwdMu.Lock()
|
||||
b.autoFwdRegexes = compiled
|
||||
@@ -164,12 +168,7 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
if config.Global.History.SkipDuplicates {
|
||||
body := string(r.Body)
|
||||
if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup {
|
||||
return
|
||||
}
|
||||
}
|
||||
pending := db.Entry{
|
||||
Timestamp: time.Now(),
|
||||
Method: r.Method,
|
||||
@@ -177,20 +176,39 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
|
||||
Path: path,
|
||||
StatusCode: status,
|
||||
RequestRaw: FormatRawRequest(f),
|
||||
ResponseRaw: FormatRawResponse(f),
|
||||
ResponseRaw: func() string {
|
||||
if config.Global.History.KeepResponses {
|
||||
return FormatRawResponse(f)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
}
|
||||
if cb := b.onBeforeNewEntry; cb != nil {
|
||||
if !cb(pending) {
|
||||
return
|
||||
}
|
||||
}
|
||||
entry, err := d.InsertEntry(pending)
|
||||
if err == nil {
|
||||
var (
|
||||
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 {
|
||||
go cb(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) Decide(p *PendingRequest, d Decision) {
|
||||
p.decision <- d
|
||||
|
||||
@@ -3,9 +3,9 @@ package intercept
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
"github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
)
|
||||
|
||||
@@ -14,15 +14,8 @@ func FormatRawRequest(f *proxy.Flow) string {
|
||||
r := f.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)
|
||||
}
|
||||
for _, line := range util.SortedHeaderLines(r.Header) {
|
||||
sb.WriteString(line)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if len(r.Body) > 0 {
|
||||
@@ -43,15 +36,8 @@ func FormatRawResponse(f *proxy.Flow) string {
|
||||
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)
|
||||
}
|
||||
for _, line := range util.SortedHeaderLines(r.Header) {
|
||||
sb.WriteString(line)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if len(r.Body) > 0 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ type GlobalKeyMap struct {
|
||||
ScrollUp key.Binding
|
||||
ScrollDown key.Binding
|
||||
SendToDiff key.Binding
|
||||
GotoTop key.Binding
|
||||
GotoBottom key.Binding
|
||||
PrevPage key.Binding
|
||||
NextPage key.Binding
|
||||
}
|
||||
|
||||
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
||||
@@ -42,6 +46,10 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
||||
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
||||
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
||||
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.SendToReplay, g.SendToDiff,
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type HistoryKeyMap struct {
|
||||
DeleteAll key.Binding
|
||||
Filter key.Binding
|
||||
SqlQuery key.Binding
|
||||
Flag key.Binding
|
||||
}
|
||||
|
||||
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
||||
@@ -18,9 +19,10 @@ func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
||||
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
||||
Filter: binding(cfg.Filter, "filter"),
|
||||
SqlQuery: binding(cfg.SqlQuery, "sql query"),
|
||||
Flag: binding(cfg.Flag, "flag"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h HistoryKeyMap) Bindings() []key.Binding {
|
||||
return []key.Binding{h.DeleteEntry, h.DeleteAll}
|
||||
return []key.Binding{h.DeleteEntry, h.DeleteAll, h.Flag}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type KeyMap struct {
|
||||
Diff DiffKeyMap
|
||||
Findings FindingsKeyMap
|
||||
Plugins PluginsKeyMap
|
||||
Docs DocsKeyMap
|
||||
}
|
||||
|
||||
var Keys *KeyMap
|
||||
@@ -31,6 +32,7 @@ func Init(cfg *config.Config) {
|
||||
Diff: newDiffKeyMap(kb.Diff),
|
||||
Findings: newFindingsKeyMap(kb.Findings),
|
||||
Plugins: newPluginsKeyMap(kb.Plugins),
|
||||
Docs: newDocsKeyMap(kb.Docs),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-8
@@ -1,7 +1,10 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,7 +14,25 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
return L
|
||||
}
|
||||
@@ -153,6 +174,31 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
|
||||
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 luaTableString(t *lua.LTable, key string) string {
|
||||
@@ -246,22 +292,19 @@ func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
|
||||
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)
|
||||
if fn == lua.LNil {
|
||||
return "", nil
|
||||
return lua.LNil, nil
|
||||
}
|
||||
if err := p.L.CallByParam(lua.P{
|
||||
Fn: fn,
|
||||
NRet: 1,
|
||||
Protect: true,
|
||||
}, args...); err != nil {
|
||||
return "", err
|
||||
return lua.LNil, err
|
||||
}
|
||||
ret := p.L.Get(-1)
|
||||
p.L.Pop(1)
|
||||
if s, ok := ret.(lua.LString); ok {
|
||||
return string(s), nil
|
||||
}
|
||||
return "", nil
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
+108
-80
@@ -81,6 +81,9 @@ func (m *Manager) LoadFromDir(dir string) error {
|
||||
m.plugins = append(m.plugins, p)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -117,6 +120,10 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
||||
p.Priority = int(n)
|
||||
}
|
||||
|
||||
if pluginTable.RawGetString("disable_by_default") == lua.LTrue {
|
||||
p.Enabled = false
|
||||
}
|
||||
|
||||
// Hooks configurable via the Plugin table (sync field).
|
||||
configurableHooks := map[string]bool{
|
||||
"on_start": false, // async by default
|
||||
@@ -153,7 +160,6 @@ func (m *Manager) GetPlugins() []*Plugin {
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*Plugin, len(m.plugins))
|
||||
copy(out, m.plugins)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -178,6 +184,42 @@ func (m *Manager) TogglePlugin(name string) {
|
||||
if m.db != nil {
|
||||
_ = m.db.SavePluginState(name, enabled, configText)
|
||||
}
|
||||
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.db != nil {
|
||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -236,17 +278,31 @@ func (m *Manager) RunOnStart() {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||
if ret == lua.LFalse {
|
||||
p.Enabled = false
|
||||
if m.db != nil {
|
||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
||||
}
|
||||
}
|
||||
}
|
||||
if hc.Sync {
|
||||
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)
|
||||
} else {
|
||||
disableIfFalse(p, ret)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
} else {
|
||||
go func(p *Plugin) {
|
||||
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)
|
||||
} else {
|
||||
disableIfFalse(p, ret)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}(p)
|
||||
@@ -270,94 +326,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() {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
hc, ok := p.hooks["on_request"]
|
||||
hc, ok := p.hooks[hookName]
|
||||
if !ok || !hc.Sync {
|
||||
continue
|
||||
}
|
||||
p.mu.Lock()
|
||||
result, err := callHook(p, "on_request", pushRequest(p.L, f))
|
||||
result, err := callHook(p, hookName, argsFor(p)...)
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("plugin %s on_request: %v", p.Name, err)
|
||||
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
|
||||
continue
|
||||
}
|
||||
switch result {
|
||||
if s, ok := result.(lua.LString); ok {
|
||||
switch string(s) {
|
||||
case "drop":
|
||||
return intercept.Drop
|
||||
case "forward":
|
||||
return intercept.Forward
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
for _, p := range m.GetPlugins() {
|
||||
if !p.Enabled {
|
||||
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)
|
||||
}
|
||||
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||
return []lua.LValue{pushRequest(p.L, f)}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
||||
for _, p := range m.GetPlugins() {
|
||||
if !p.Enabled {
|
||||
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
|
||||
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
||||
for _, p := range m.GetPlugins() {
|
||||
if !p.Enabled {
|
||||
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)
|
||||
}
|
||||
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
||||
})
|
||||
}
|
||||
|
||||
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
|
||||
@@ -377,7 +418,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
|
||||
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
|
||||
continue
|
||||
}
|
||||
if result == "skip" {
|
||||
if s, ok := result.(lua.LString); ok && string(s) == "skip" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -385,20 +426,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
|
||||
}
|
||||
|
||||
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
|
||||
for _, p := range m.GetPlugins() {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
hc, ok := p.hooks["on_history_entry"]
|
||||
if !ok || hc.Sync {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
m.runAsyncForPlugins("on_history_entry", func(p *Plugin) []lua.LValue {
|
||||
return []lua.LValue{pushEntry(p.L, e)}
|
||||
})
|
||||
}
|
||||
|
||||
+44
-2
@@ -1,10 +1,13 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
@@ -63,7 +66,15 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
||||
func (a *interceptAddon) Response(f *goproxy.Flow) {
|
||||
if f.Response != 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.BodyReader = nil
|
||||
}
|
||||
@@ -106,7 +117,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
||||
|
||||
opts := &goproxy.Options{
|
||||
Addr: addr,
|
||||
StreamLargeBodies: 1024 * 1024 * 5,
|
||||
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
||||
CaRootPath: caPath,
|
||||
Upstream: cfg.UpstreamProxy,
|
||||
}
|
||||
@@ -116,10 +127,41 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
||||
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"))
|
||||
if !ok || user != wantUser || pass != wantPass {
|
||||
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
|
||||
return false, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
|
||||
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 {
|
||||
return &goproxy.Response{
|
||||
StatusCode: 502,
|
||||
|
||||
@@ -46,7 +46,6 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
|
||||
return ta
|
||||
}
|
||||
|
||||
// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
|
||||
func SeverityStyle(sev string) lipgloss.Style {
|
||||
base := lipgloss.NewStyle().Bold(true)
|
||||
switch sev {
|
||||
@@ -63,7 +62,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 {
|
||||
base := lipgloss.NewStyle().Bold(true).Width(width)
|
||||
switch {
|
||||
|
||||
@@ -15,7 +15,6 @@ func Paint(c color.Color, s string) string {
|
||||
return lipgloss.NewStyle().Foreground(c).Render(s)
|
||||
}
|
||||
|
||||
// HighlightHTTP highlights a full raw HTTP message (headers + body).
|
||||
func HighlightHTTP(raw string) string {
|
||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -31,10 +32,9 @@ const tickInterval = 2 * time.Second
|
||||
type tickMsg struct{}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
time.Sleep(tickInterval)
|
||||
return tea.Tick(tickInterval, func(time.Time) tea.Msg {
|
||||
return tickMsg{}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var sidebarEntries = pageRegistry
|
||||
@@ -94,14 +94,17 @@ func New(broker *intercept.Broker, name, path string) Model {
|
||||
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
|
||||
broker.SetDB(d)
|
||||
m.history.SetDB(d)
|
||||
m.replay.SetDB(d)
|
||||
m.findingsPage.SetDB(d)
|
||||
mgr.SetDB(d)
|
||||
}
|
||||
|
||||
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
||||
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
||||
|
||||
@@ -126,6 +126,7 @@ var pageRegistry = []pageEntry{
|
||||
m.docs = updated.(docsUI.Model)
|
||||
return cmd
|
||||
},
|
||||
isEditing: func(m *Model) bool { return m.docs.IsEditing() },
|
||||
resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ func (m *Model) renderSidebar() string {
|
||||
titleText = "SPLT"
|
||||
}
|
||||
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText)
|
||||
|
||||
divider := strings.Repeat("─", inner)
|
||||
|
||||
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
|
||||
@@ -75,14 +76,28 @@ func (m *Model) renderSidebar() string {
|
||||
label += string(entry.id)
|
||||
}
|
||||
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
|
||||
if m.sidebarState == sidebarCollapsed && icon == "" {
|
||||
line = " " + line
|
||||
}
|
||||
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,
|
||||
lipgloss.NewStyle().Width(inner).Foreground(s.Subtle).Padding(0, 1).Render(name),
|
||||
}
|
||||
parts = append(parts,
|
||||
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
|
||||
items.String(),
|
||||
)
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
|
||||
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
|
||||
}
|
||||
|
||||
+53
-24
@@ -104,6 +104,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case proxyPkg.ErrMsg:
|
||||
if msg.Err != nil {
|
||||
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
|
||||
|
||||
@@ -176,44 +186,63 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !m.activeIsEditing() {
|
||||
switch {
|
||||
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 responseFocused bool
|
||||
switch m.page {
|
||||
case pageIntercept:
|
||||
raw = m.intercept.CurrentRaw()
|
||||
scheme = m.intercept.CurrentScheme()
|
||||
case pageDiff:
|
||||
raw = m.diff.CurrentRaw()
|
||||
scheme = "https"
|
||||
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 != "" && !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 != "" {
|
||||
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
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package copy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/list"
|
||||
@@ -12,16 +9,16 @@ import (
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
const popupInnerW = 40
|
||||
const (
|
||||
popupW = 55
|
||||
popupH = 20
|
||||
)
|
||||
|
||||
func writeClipboard(text string) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(text))
|
||||
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
|
||||
}
|
||||
|
||||
type OpenMsg struct {
|
||||
RawRequest string
|
||||
Scheme string
|
||||
ShowURL bool
|
||||
}
|
||||
|
||||
type copyItem struct {
|
||||
@@ -66,7 +63,7 @@ func New() Model {
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.MutedFg).PaddingLeft(1)
|
||||
|
||||
l := list.New(allItems, delegate, popupInnerW, 8)
|
||||
l := list.New(allItems, delegate, popupW, 8)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowHelp(false)
|
||||
@@ -87,19 +84,38 @@ func (m *Model) Open(msg OpenMsg) {
|
||||
m.rawRequest = msg.RawRequest
|
||||
m.scheme = msg.Scheme
|
||||
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.Select(0)
|
||||
m.list.SetSize(popupInnerW, m.listHeight())
|
||||
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
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 {
|
||||
h := 12
|
||||
h := popupH
|
||||
if m.height > 0 && m.height-4 < h {
|
||||
h = m.height - 4
|
||||
}
|
||||
|
||||
@@ -4,16 +4,26 @@ import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"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) {
|
||||
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
||||
switch {
|
||||
case kp.String() == "enter":
|
||||
if item, ok := m.list.SelectedItem().(copyItem); ok {
|
||||
writeClipboard(m.extract(item.id))
|
||||
}
|
||||
m.open = false
|
||||
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
|
||||
case key.Matches(kp, keys.Keys.Global.Escape):
|
||||
if m.list.SettingFilter() {
|
||||
|
||||
@@ -22,7 +22,7 @@ func (m *Model) View(background string) string {
|
||||
BorderForeground(s.Primary)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package copyas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
type header struct{ key, value string }
|
||||
@@ -12,46 +16,22 @@ type parsedRequest struct {
|
||||
path string
|
||||
host string
|
||||
scheme string
|
||||
headers []header
|
||||
headers []header // garder header{key, value} pour compat locale
|
||||
body string
|
||||
}
|
||||
|
||||
func parseRaw(raw, scheme string) parsedRequest {
|
||||
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
||||
pr := parsedRequest{scheme: scheme}
|
||||
if len(lines) == 0 {
|
||||
return pr
|
||||
r := util.ParseRawRequest(raw)
|
||||
pr := parsedRequest{
|
||||
method: r.Method,
|
||||
path: r.Path,
|
||||
host: r.Host,
|
||||
scheme: scheme,
|
||||
}
|
||||
|
||||
parts := strings.SplitN(lines[0], " ", 3)
|
||||
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")
|
||||
for _, h := range r.Headers {
|
||||
pr.headers = append(pr.headers, header{h.Key, h.Value})
|
||||
}
|
||||
pr.body = r.Body
|
||||
return pr
|
||||
}
|
||||
|
||||
@@ -78,10 +58,31 @@ func formatAs(id, raw, scheme string) string {
|
||||
return toFFUF(pr)
|
||||
case "markdown":
|
||||
return toMarkdown(pr)
|
||||
case "har":
|
||||
return toHAR(pr)
|
||||
case "httpie":
|
||||
return toHTTPie(pr)
|
||||
}
|
||||
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 {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
|
||||
@@ -200,3 +201,104 @@ func toFFUF(pr parsedRequest) string {
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func toHAR(pr parsedRequest) string {
|
||||
type harNameValue struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type harPostData struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
type harRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
HTTPVersion string `json:"httpVersion"`
|
||||
Headers []harNameValue `json:"headers"`
|
||||
QueryString []harNameValue `json:"queryString"`
|
||||
Cookies []harNameValue `json:"cookies"`
|
||||
HeadersSize int `json:"headersSize"`
|
||||
BodySize int `json:"bodySize"`
|
||||
PostData *harPostData `json:"postData,omitempty"`
|
||||
}
|
||||
type harEntry struct {
|
||||
StartedDateTime string `json:"startedDateTime"`
|
||||
Time int `json:"time"`
|
||||
Request harRequest `json:"request"`
|
||||
Cache struct{} `json:"cache"`
|
||||
Timings struct {
|
||||
Send int `json:"send"`
|
||||
Wait int `json:"wait"`
|
||||
Receive int `json:"receive"`
|
||||
} `json:"timings"`
|
||||
}
|
||||
type harLog struct {
|
||||
Version string `json:"version"`
|
||||
Creator struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"creator"`
|
||||
Entries []harEntry `json:"entries"`
|
||||
}
|
||||
type harRoot struct {
|
||||
Log harLog `json:"log"`
|
||||
}
|
||||
|
||||
headers := make([]harNameValue, 0, len(pr.headers))
|
||||
for _, h := range pr.headers {
|
||||
headers = append(headers, harNameValue{h.key, h.value})
|
||||
}
|
||||
|
||||
var qs []harNameValue
|
||||
if idx := strings.Index(pr.path, "?"); idx != -1 {
|
||||
vals, err := url.ParseQuery(pr.path[idx+1:])
|
||||
if err == nil {
|
||||
for k, vs := range vals {
|
||||
for _, v := range vs {
|
||||
qs = append(qs, harNameValue{k, v})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if qs == nil {
|
||||
qs = []harNameValue{}
|
||||
}
|
||||
|
||||
req := harRequest{
|
||||
Method: pr.method,
|
||||
URL: pr.fullURL(),
|
||||
HTTPVersion: "HTTP/1.1",
|
||||
Headers: headers,
|
||||
QueryString: qs,
|
||||
Cookies: []harNameValue{},
|
||||
HeadersSize: -1,
|
||||
BodySize: len(pr.body),
|
||||
}
|
||||
if pr.body != "" {
|
||||
mimeType := "application/octet-stream"
|
||||
for _, h := range pr.headers {
|
||||
if strings.EqualFold(h.key, "content-type") {
|
||||
mimeType = h.value
|
||||
break
|
||||
}
|
||||
}
|
||||
req.PostData = &harPostData{MimeType: mimeType, Text: pr.body}
|
||||
}
|
||||
|
||||
root := harRoot{Log: harLog{
|
||||
Version: "1.2",
|
||||
Entries: []harEntry{{
|
||||
StartedDateTime: "1970-01-01T00:00:00.000Z",
|
||||
Time: -1,
|
||||
Request: req,
|
||||
}},
|
||||
}}
|
||||
root.Log.Creator.Name = "spilltea"
|
||||
|
||||
b, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
package copyas
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
const popupInnerW = 46
|
||||
const (
|
||||
popupW = 61
|
||||
popupH = 20
|
||||
)
|
||||
|
||||
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
|
||||
// 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 {
|
||||
RawRequest string
|
||||
@@ -42,6 +35,8 @@ var allFormats = []list.Item{
|
||||
formatItem{"go", "Go", "net/http package"},
|
||||
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
|
||||
formatItem{"markdown", "Markdown", "formatted for documentation"},
|
||||
formatItem{"har", "HAR", "HTTP Archive (JSON)"},
|
||||
formatItem{"httpie", "HTTPie", "HTTPie command line client"},
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
@@ -69,7 +64,7 @@ func New() Model {
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.MutedFg).PaddingLeft(1)
|
||||
|
||||
l := list.New(allFormats, delegate, popupInnerW, 8)
|
||||
l := list.New(allFormats, delegate, popupW, 8)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowHelp(false)
|
||||
@@ -92,17 +87,25 @@ func (m *Model) Open(msg OpenMsg) {
|
||||
m.open = true
|
||||
m.list.ResetFilter()
|
||||
m.list.Select(0)
|
||||
m.list.SetSize(popupInnerW, m.listHeight())
|
||||
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
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 {
|
||||
h := 14
|
||||
h := popupH
|
||||
if m.height > 0 && m.height-4 < h {
|
||||
h = m.height - 4
|
||||
}
|
||||
|
||||
@@ -4,16 +4,26 @@ import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"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) {
|
||||
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
||||
switch {
|
||||
case kp.String() == "enter":
|
||||
if item, ok := m.list.SelectedItem().(formatItem); ok {
|
||||
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
|
||||
}
|
||||
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
|
||||
case key.Matches(kp, keys.Keys.Global.Escape):
|
||||
if m.list.SettingFilter() {
|
||||
|
||||
@@ -24,7 +24,7 @@ func (m *Model) View(background string) string {
|
||||
BorderForeground(s.Primary)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
+162
-14
@@ -10,8 +10,159 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"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 {
|
||||
label string
|
||||
raw string
|
||||
@@ -38,7 +189,8 @@ const (
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -126,6 +278,7 @@ func (m *Model) computeDiff() {
|
||||
leftHL := hlLines(leftNorm)
|
||||
rightHL := hlLines(rightNorm)
|
||||
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
||||
m.leftLines, m.rightLines = pairAndHighlight(m.leftLines, m.rightLines)
|
||||
}
|
||||
|
||||
func normRaw(s string) string {
|
||||
@@ -149,7 +302,7 @@ func (m *Model) refreshViewports() {
|
||||
placeholder := lipgloss.Place(
|
||||
m.leftViewport.Width(), m.leftViewport.Height(),
|
||||
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.rightViewport.SetContent("")
|
||||
@@ -161,7 +314,7 @@ func (m *Model) refreshViewports() {
|
||||
placeholder := lipgloss.Place(
|
||||
m.rightViewport.Width(), m.rightViewport.Height(),
|
||||
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)
|
||||
return
|
||||
@@ -227,10 +380,10 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
|
||||
j--
|
||||
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||
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--
|
||||
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})
|
||||
i--
|
||||
}
|
||||
@@ -243,14 +396,6 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
|
||||
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 }
|
||||
|
||||
func (diffKeyMap) ShortHelp() []key.Binding {
|
||||
@@ -259,6 +404,9 @@ func (diffKeyMap) ShortHelp() []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)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
@@ -38,6 +39,12 @@ func (m *Model) renderPanels(panelH int) string {
|
||||
if 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
|
||||
rightBorder := s.Panel
|
||||
|
||||
+253
-1
@@ -1,36 +1,288 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
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"
|
||||
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 {
|
||||
b, _ := spilltea.DocsFS.ReadFile(".github/docs/" + name)
|
||||
b, _ := spilltea.DocsFS.ReadFile("docs/" + name)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var contentMarkdown = strings.Join([]string{
|
||||
readDoc("main.md"),
|
||||
readDoc("legal-disclaimer.md"),
|
||||
readDoc("basics.md"),
|
||||
readDoc("proxy.md"),
|
||||
readDoc("certificate.md"),
|
||||
readDoc("history.md"),
|
||||
}, "\n")
|
||||
|
||||
type matchEntry struct {
|
||||
line int
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
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 {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "/"
|
||||
s := ti.Styles()
|
||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
ti.SetStyles(s)
|
||||
|
||||
return Model{
|
||||
viewport: viewport.New(),
|
||||
help: style.NewHelp(),
|
||||
searchInput: ti,
|
||||
}
|
||||
}
|
||||
|
||||
func (e Model) Init() tea.Cmd {
|
||||
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
@@ -4,47 +4,66 @@ import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
g := keys.Keys.Global
|
||||
d := keys.Keys.Docs
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.MouseWheelMsg:
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
|
||||
case tea.MouseWheelDown:
|
||||
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
||||
}
|
||||
util.HandleMouseWheel(msg, &e.viewport)
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if e.searching {
|
||||
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):
|
||||
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
|
||||
case key.Matches(msg, g.Down):
|
||||
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
||||
case key.Matches(msg, g.ScrollUp):
|
||||
step := e.viewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
e.viewport.SetYOffset(e.viewport.YOffset() - step)
|
||||
util.ScrollViewport(&e.viewport, -1)
|
||||
case key.Matches(msg, g.ScrollDown):
|
||||
step := e.viewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
e.viewport.SetYOffset(e.viewport.YOffset() + step)
|
||||
util.ScrollViewport(&e.viewport, 1)
|
||||
case key.Matches(msg, g.Help):
|
||||
e.help.ShowAll = !e.help.ShowAll
|
||||
e.SetSize(e.width, e.height)
|
||||
}
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
frameW := windowStyle().GetHorizontalFrameSize()
|
||||
frameH := windowStyle().GetVerticalFrameSize()
|
||||
|
||||
m.viewport.SetWidth(w - frameW)
|
||||
m.viewport.SetHeight(h - frameH)
|
||||
m.renderMarkdown()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func windowStyle() lipgloss.Style {
|
||||
@@ -20,7 +22,23 @@ func windowStyle() lipgloss.Style {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -48,5 +66,10 @@ func (m *Model) renderMarkdown() {
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -27,6 +28,9 @@ type Model struct {
|
||||
pager paginator.Model
|
||||
help help.Model
|
||||
|
||||
renderer *glamour.TermRenderer
|
||||
rendererWidth int
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
@@ -42,6 +46,14 @@ func New() Model {
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
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) {
|
||||
m.database = d
|
||||
}
|
||||
@@ -76,12 +88,17 @@ func (m *Model) recalcSizes() {
|
||||
m.bodyViewport.SetWidth(inner)
|
||||
m.bodyViewport.SetHeight(bodyVH)
|
||||
|
||||
if m.rendererWidth != inner {
|
||||
m.renderer = nil
|
||||
m.rendererWidth = 0
|
||||
}
|
||||
|
||||
m.refreshListViewport()
|
||||
m.refreshBody()
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -104,19 +121,29 @@ type FindingsLoadedMsg struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
m.bodyViewport.SetContent("")
|
||||
return
|
||||
}
|
||||
f := m.findings[m.cursor]
|
||||
rendered := renderMarkdown(f.Description, m.bodyViewport.Width())
|
||||
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
|
||||
m.bodyViewport.SetContent(rendered)
|
||||
if reset {
|
||||
m.bodyViewport.GotoTop()
|
||||
}
|
||||
}
|
||||
|
||||
func renderMarkdown(src string, width int) string {
|
||||
func (m *Model) renderMarkdownCached(src string, width int) string {
|
||||
if src == "" {
|
||||
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description")
|
||||
return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
|
||||
}
|
||||
tmpl, err := template.New("").Parse(src)
|
||||
if err != nil {
|
||||
@@ -129,28 +156,39 @@ func renderMarkdown(src string, width int) string {
|
||||
if width < 10 {
|
||||
width = 80
|
||||
}
|
||||
// Rebuild renderer if width changed or not yet built.
|
||||
if m.renderer == nil || m.rendererWidth != width {
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
if err == nil {
|
||||
m.renderer = r
|
||||
m.rendererWidth = width
|
||||
}
|
||||
}
|
||||
if m.renderer == nil {
|
||||
return buf.String()
|
||||
}
|
||||
out, err := r.Render(buf.String())
|
||||
out, err := m.renderer.Render(buf.String())
|
||||
if err != nil {
|
||||
return buf.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type findingsKeyMap struct{}
|
||||
type findingsKeyMap struct{ width int }
|
||||
|
||||
func (findingsKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
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 {
|
||||
return [][]key.Binding{findingsKeyMap{}.ShortHelp()}
|
||||
func (m findingsKeyMap) FullHelp() [][]key.Binding {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -15,22 +16,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
log.Printf("findings load error: %v", msg.Err)
|
||||
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
|
||||
if m.cursor >= len(m.findings) {
|
||||
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.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()
|
||||
} else {
|
||||
m.refreshBodyKeepScroll()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
||||
case tea.MouseWheelDown:
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
||||
}
|
||||
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
@@ -65,17 +78,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, RefreshCmd(m.database)
|
||||
}
|
||||
case key.Matches(msg, g.ScrollUp):
|
||||
step := m.bodyViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
||||
util.ScrollViewport(&m.bodyViewport, -1)
|
||||
case key.Matches(msg, g.ScrollDown):
|
||||
step := m.bodyViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
||||
util.ScrollViewport(&m.bodyViewport, 1)
|
||||
case key.Matches(msg, g.GotoTop):
|
||||
m.cursor = 0
|
||||
m.pager.Page = 0
|
||||
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
|
||||
|
||||
@@ -28,7 +28,10 @@ func (m Model) View() tea.View {
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
||||
@@ -51,7 +54,7 @@ func (m *Model) renderList() string {
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"),
|
||||
s.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
type panel int
|
||||
@@ -59,10 +60,22 @@ func (m Model) CurrentRaw() string {
|
||||
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||
return ""
|
||||
}
|
||||
if m.focusedPanel == panelResponse {
|
||||
return m.entries[m.cursor].ResponseRaw
|
||||
}
|
||||
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.
|
||||
// The app model should call this instead of LoadEntriesCmd directly so that
|
||||
@@ -145,14 +158,16 @@ func (historyKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
g.Up, g.Down, g.CycleFocus,
|
||||
h.DeleteEntry, h.DeleteAll,
|
||||
h.Filter, h.SqlQuery,
|
||||
g.Help,
|
||||
}
|
||||
}
|
||||
|
||||
func (m historyKeyMap) FullHelp() [][]key.Binding {
|
||||
h := keys.Keys.History
|
||||
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
|
||||
all = append(all, 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 := []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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"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() != "") {
|
||||
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
|
||||
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) {
|
||||
m.cursor = len(m.entries) - 1
|
||||
entryChanged = true
|
||||
}
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
entryChanged = true
|
||||
}
|
||||
m.pager.SetTotalPages(len(m.entries))
|
||||
m.refreshListViewport()
|
||||
m.refreshBody()
|
||||
if m.cursor != prevCursor {
|
||||
if entryChanged {
|
||||
m.bodyViewport.SetYOffset(0)
|
||||
m.bodyViewport.SetXOffset(0)
|
||||
}
|
||||
@@ -74,24 +93,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.bodyViewport.SetXOffset(0)
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
switch msg.Button {
|
||||
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)
|
||||
}
|
||||
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
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):
|
||||
if len(m.entries) > 0 {
|
||||
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()
|
||||
|
||||
case key.Matches(msg, g.ScrollUp):
|
||||
step := m.bodyViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
||||
util.ScrollViewport(&m.bodyViewport, -1)
|
||||
|
||||
case key.Matches(msg, g.ScrollDown):
|
||||
step := m.bodyViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
||||
util.ScrollViewport(&m.bodyViewport, 1)
|
||||
|
||||
case key.Matches(msg, g.Left):
|
||||
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):
|
||||
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):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.recalcSizes()
|
||||
@@ -281,9 +310,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *Model) refreshListViewport() {
|
||||
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.SetTotalPages(len(m.entries))
|
||||
}
|
||||
}
|
||||
m.listViewport.SetContent(m.renderList())
|
||||
}
|
||||
|
||||
@@ -299,5 +333,10 @@ func (m *Model) refreshBody() {
|
||||
} else {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
@@ -28,7 +29,10 @@ func (m Model) View() tea.View {
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
||||
@@ -81,9 +85,9 @@ func (m *Model) renderList() string {
|
||||
)
|
||||
}
|
||||
if len(m.entries) == 0 {
|
||||
msg := " (⌐■_■)\nno history yet"
|
||||
msg := util.CenterLines("(⌐■_■)", "no history yet")
|
||||
if m.searchKind != searchKindOff {
|
||||
msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results"
|
||||
msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
@@ -109,7 +113,7 @@ func (m *Model) renderList() string {
|
||||
w := m.listViewport.Width()
|
||||
|
||||
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
|
||||
if hostPathW < 0 {
|
||||
hostPathW = 0
|
||||
@@ -117,12 +121,21 @@ func (m *Model) renderList() string {
|
||||
|
||||
ts := e.Timestamp.Format("15:04:05")
|
||||
statusSt := style.StatusStyle(e.StatusCode, 3)
|
||||
flagSt := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
|
||||
var line string
|
||||
if selected {
|
||||
bg := lipgloss.NewStyle().Background(selBg)
|
||||
flagStr := " "
|
||||
if e.Flagged {
|
||||
flagStr = icons.I.Flag + " "
|
||||
if icons.I.Flag == "" {
|
||||
flagStr = "★ "
|
||||
}
|
||||
}
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
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),
|
||||
bg.Width(1).Render(""),
|
||||
statusSt.Background(selBg).Render(statusStr),
|
||||
@@ -132,8 +145,16 @@ func (m *Model) renderList() string {
|
||||
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
||||
)
|
||||
} else {
|
||||
flagStr := " "
|
||||
if e.Flagged {
|
||||
flagStr = icons.I.Flag + " "
|
||||
if icons.I.Flag == "" {
|
||||
flagStr = "★ "
|
||||
}
|
||||
}
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
" ",
|
||||
flagSt.Width(2).Render(flagStr),
|
||||
s.Method(e.Method).Render(e.Method),
|
||||
" ",
|
||||
statusSt.Render(statusStr),
|
||||
|
||||
@@ -142,6 +142,11 @@ type Project struct {
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// ProjectSelectedMsg is emitted when the user picks a project from the home screen.
|
||||
type ProjectSelectedMsg struct {
|
||||
Project *Project
|
||||
}
|
||||
|
||||
type inputMode int
|
||||
|
||||
const (
|
||||
@@ -161,16 +166,11 @@ type Model struct {
|
||||
list list.Model
|
||||
projectDir string
|
||||
nameInput textinput.Model
|
||||
selected *Project
|
||||
width int
|
||||
height 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 {
|
||||
projects := loadProjects(projectDir)
|
||||
|
||||
@@ -332,7 +332,7 @@ func (m Model) renderHelpLine() string {
|
||||
}
|
||||
parts = append(parts, binding(k.Open))
|
||||
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)
|
||||
|
||||
@@ -76,11 +76,11 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
initProjectFiles(dir)
|
||||
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
|
||||
return m, tea.Quit
|
||||
p := &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
|
||||
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||
default:
|
||||
m.selected = &Project{Name: item.name, Path: item.path}
|
||||
return m, tea.Quit
|
||||
p := &Project{Name: item.name, Path: item.path}
|
||||
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
|
||||
}
|
||||
initProjectFiles(dir)
|
||||
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")}
|
||||
return m, tea.Quit
|
||||
p := &Project{Name: name, Path: filepath.Join(dir, "data.db")}
|
||||
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.nameInput, cmd = m.nameInput.Update(msg)
|
||||
|
||||
@@ -4,114 +4,39 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||
"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) {
|
||||
parsed := util.ParseRawRequest(content)
|
||||
r := req.Flow.Request
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
if parsed.Method != "" {
|
||||
r.Method = parsed.Method
|
||||
}
|
||||
|
||||
parts := strings.SplitN(lines[0], " ", 3)
|
||||
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 {
|
||||
if parsed.Path != "" {
|
||||
if u, err := url.ParseRequestURI(parsed.Path); err == nil {
|
||||
r.URL.Path = u.Path
|
||||
r.URL.RawQuery = u.RawQuery
|
||||
}
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
r.Proto = strings.TrimSpace(parts[2])
|
||||
if parsed.Proto != "" {
|
||||
r.Proto = parsed.Proto
|
||||
}
|
||||
|
||||
r.Header = make(http.Header)
|
||||
i := 1
|
||||
for i < len(lines) {
|
||||
line := strings.TrimRight(lines[i], "\r")
|
||||
if line == "" {
|
||||
i++
|
||||
break
|
||||
for _, h := range parsed.Headers {
|
||||
r.Header.Set(h.Key, h.Value)
|
||||
}
|
||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
||||
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if i < len(lines) {
|
||||
body := strings.Join(lines[i:], "\n")
|
||||
body = strings.TrimRight(body, "\n")
|
||||
if body != "" {
|
||||
r.Body = []byte(body)
|
||||
if parsed.Body != "" {
|
||||
r.Body = []byte(parsed.Body)
|
||||
} else {
|
||||
r.Body = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseRawResponse(content string, resp *intercept.PendingResponse) {
|
||||
r := resp.Flow.Response
|
||||
@@ -295,17 +220,27 @@ func (m *Model) recalcSizes() {
|
||||
|
||||
func (m *Model) refreshListViewport() {
|
||||
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.SetTotalPages(len(m.queue))
|
||||
}
|
||||
}
|
||||
m.listViewport.SetContent(m.renderList())
|
||||
}
|
||||
|
||||
func (m *Model) refreshResponseListViewport() {
|
||||
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.SetTotalPages(len(m.responseQueue))
|
||||
}
|
||||
}
|
||||
m.responseViewport.SetContent(m.renderResponseList())
|
||||
}
|
||||
|
||||
@@ -333,7 +268,7 @@ func (m *Model) loadIntoTextarea() {
|
||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||
m.textarea.SetValue(edited)
|
||||
} else {
|
||||
m.textarea.SetValue(formatRawResponse(resp))
|
||||
m.textarea.SetValue(intercept.FormatRawResponse(resp.Flow))
|
||||
}
|
||||
} else {
|
||||
if len(m.queue) == 0 {
|
||||
@@ -343,7 +278,7 @@ func (m *Model) loadIntoTextarea() {
|
||||
if edited, ok := m.pendingEdits[req]; ok {
|
||||
m.textarea.SetValue(edited)
|
||||
} 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 {
|
||||
raw = edited
|
||||
} else {
|
||||
raw = formatRawResponse(resp)
|
||||
raw = intercept.FormatRawResponse(resp.Flow)
|
||||
}
|
||||
} else {
|
||||
if len(m.queue) == 0 {
|
||||
@@ -371,7 +306,7 @@ func (m *Model) refreshBody() {
|
||||
if edited, ok := m.pendingEdits[req]; ok {
|
||||
raw = edited
|
||||
} else {
|
||||
raw = formatRawRequest(req)
|
||||
raw = intercept.FormatRawRequest(req.Flow)
|
||||
}
|
||||
}
|
||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||
|
||||
@@ -29,6 +29,9 @@ func (interceptKeyMap) ShortHelp() []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)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) IsEditing() bool { return m.editing }
|
||||
|
||||
func (m Model) IsResponseFocused() bool {
|
||||
return m.captureResponse && m.focusedPanel == panelResponses
|
||||
}
|
||||
|
||||
func (m Model) CurrentScheme() string {
|
||||
if len(m.queue) == 0 {
|
||||
return "https"
|
||||
@@ -98,7 +102,7 @@ func (m Model) CurrentRaw() string {
|
||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||
return edited
|
||||
}
|
||||
return formatRawResponse(resp)
|
||||
return intercept.FormatRawResponse(resp.Flow)
|
||||
}
|
||||
if len(m.queue) == 0 {
|
||||
return ""
|
||||
@@ -107,7 +111,7 @@ func (m Model) CurrentRaw() string {
|
||||
if edited, ok := m.pendingEdits[req]; ok {
|
||||
return edited
|
||||
}
|
||||
return formatRawRequest(req)
|
||||
return intercept.FormatRawRequest(req.Flow)
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
|
||||
@@ -52,24 +52,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
if !m.editing {
|
||||
switch msg.Button {
|
||||
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)
|
||||
}
|
||||
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||
}
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
@@ -127,18 +110,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Keys.Global.ScrollUp):
|
||||
step := m.bodyViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
||||
util.ScrollViewport(&m.bodyViewport, -1)
|
||||
|
||||
case key.Matches(msg, keys.Keys.Global.ScrollDown):
|
||||
step := m.bodyViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
||||
util.ScrollViewport(&m.bodyViewport, 1)
|
||||
|
||||
case key.Matches(msg, keys.Keys.Global.Left):
|
||||
m.bodyViewport.ScrollLeft(6)
|
||||
@@ -146,9 +121,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
||||
case key.Matches(msg, keys.Keys.Global.Right):
|
||||
m.bodyViewport.ScrollRight(6)
|
||||
|
||||
case key.Matches(msg, keys.Keys.Global.Quit):
|
||||
return m, tea.Quit
|
||||
|
||||
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
|
||||
if onResponses {
|
||||
if len(m.responseQueue) > 0 {
|
||||
@@ -237,10 +209,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
||||
|
||||
case key.Matches(msg, keys.Keys.Intercept.EditExternal):
|
||||
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 {
|
||||
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):
|
||||
@@ -268,6 +240,46 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
||||
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...)
|
||||
@@ -287,12 +299,12 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
|
||||
if onResponses {
|
||||
if len(m.responseQueue) > 0 {
|
||||
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 {
|
||||
if len(m.queue) > 0 {
|
||||
delete(m.pendingEdits, m.queue[m.cursor])
|
||||
m.textarea.SetValue(formatRawRequest(m.queue[m.cursor]))
|
||||
m.textarea.SetValue(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
@@ -45,7 +46,10 @@ func (m *Model) renderListPanel(w, h int) string {
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
||||
@@ -101,7 +105,7 @@ func (m *Model) renderStatusBar() string {
|
||||
|
||||
func (m *Model) renderList() string {
|
||||
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
|
||||
@@ -157,7 +161,7 @@ func (m *Model) renderList() string {
|
||||
|
||||
func (m *Model) renderResponseList() string {
|
||||
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
|
||||
|
||||
@@ -112,7 +112,6 @@ func (m *Model) recalcSizes() {
|
||||
m.syncDetailViewport()
|
||||
}
|
||||
|
||||
// Refresh reloads the plugin list from the manager.
|
||||
func (m *Model) Refresh() {
|
||||
if m.manager == nil {
|
||||
return
|
||||
@@ -187,15 +186,21 @@ func (m *Model) syncDetailViewport() {
|
||||
|
||||
func (m *Model) refreshListViewport() {
|
||||
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.SetTotalPages(len(m.filtered))
|
||||
}
|
||||
}
|
||||
m.listViewport.SetContent(m.renderList())
|
||||
}
|
||||
|
||||
type pluginsKeyMap struct {
|
||||
editing bool
|
||||
hasConfig bool
|
||||
width int
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
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 {
|
||||
g := keys.Keys.Global
|
||||
if k.editing {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,23 +4,10 @@ import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"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) {
|
||||
switch msg.(type) {
|
||||
case PluginsChangedMsg:
|
||||
m.Refresh()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Route non-key messages to textarea when editing so internal
|
||||
// textarea messages (e.g. clipboard paste) are handled correctly.
|
||||
if m.editing {
|
||||
@@ -34,12 +21,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.MouseWheelMsg:
|
||||
if !m.editing {
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
|
||||
case tea.MouseWheelDown:
|
||||
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
|
||||
}
|
||||
util.HandleMouseWheel(msg, &m.detailViewport)
|
||||
}
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
@@ -142,19 +124,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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):
|
||||
step := m.detailViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
|
||||
util.ScrollViewport(&m.detailViewport, -1)
|
||||
|
||||
case key.Matches(msg, g.ScrollDown):
|
||||
step := m.detailViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
|
||||
util.ScrollViewport(&m.detailViewport, 1)
|
||||
|
||||
case key.Matches(msg, g.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
|
||||
@@ -11,11 +11,12 @@ import (
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
if m.width == 0 || m.manager == nil {
|
||||
return tea.NewView(style.S.Faint.Render("\nno plugins loaded"))
|
||||
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded"))))
|
||||
}
|
||||
|
||||
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 {
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
||||
@@ -120,17 +124,17 @@ func (m *Model) renderStatusBar() string {
|
||||
escKey := keys.Keys.Global.Escape.Help().Key
|
||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
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 {
|
||||
s := style.S
|
||||
if len(m.filtered) == 0 {
|
||||
msg := " (ง •̀_•́)ง\nno plugins"
|
||||
msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
||||
if m.filter != "" {
|
||||
msg = " = _ =\nno results"
|
||||
msg = util.CenterLines("= _ =", "no results")
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
|
||||
@@ -34,10 +34,19 @@ type Entry struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
type panel int
|
||||
|
||||
const (
|
||||
panelList panel = iota
|
||||
panelRequest
|
||||
panelResponse
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
entries []Entry
|
||||
cursor int
|
||||
editing bool
|
||||
focusedPanel panel
|
||||
database *db.DB
|
||||
|
||||
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) IsResponseFocused() bool {
|
||||
return m.focusedPanel == panelResponse
|
||||
}
|
||||
|
||||
func (m Model) CurrentRaw() string {
|
||||
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||
return ""
|
||||
}
|
||||
if m.focusedPanel == panelResponse {
|
||||
return m.entries[m.cursor].ResponseRaw
|
||||
}
|
||||
return m.entries[m.cursor].RequestRaw
|
||||
}
|
||||
|
||||
@@ -183,10 +199,13 @@ type replayKeyMap struct{ width int }
|
||||
func (replayKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
+164
-89
@@ -2,20 +2,27 @@ package replay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/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/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
type sentMsg struct {
|
||||
@@ -91,14 +98,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.requestViewport.ScrollLeft(6)
|
||||
m.responseViewport.ScrollLeft(6)
|
||||
} else {
|
||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1)
|
||||
m.scrollFocusedViewportVertical(-1)
|
||||
}
|
||||
case tea.MouseWheelDown:
|
||||
if msg.Mod.Contains(tea.ModShift) {
|
||||
m.requestViewport.ScrollRight(6)
|
||||
m.responseViewport.ScrollRight(6)
|
||||
} else {
|
||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1)
|
||||
m.scrollFocusedViewportVertical(1)
|
||||
}
|
||||
case tea.MouseWheelLeft:
|
||||
m.requestViewport.ScrollLeft(6)
|
||||
@@ -124,18 +131,36 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
r := keys.Keys.Replay
|
||||
switch {
|
||||
case key.Matches(msg, g.Up):
|
||||
if m.focusedPanel == panelList {
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.refreshListViewport()
|
||||
m.refreshBody()
|
||||
}
|
||||
} else {
|
||||
m.scrollFocusedViewportVertical(-1)
|
||||
}
|
||||
|
||||
case key.Matches(msg, g.Down):
|
||||
if m.focusedPanel == panelList {
|
||||
if m.cursor < len(m.entries)-1 {
|
||||
m.cursor++
|
||||
m.refreshListViewport()
|
||||
m.refreshBody()
|
||||
}
|
||||
} else {
|
||||
m.scrollFocusedViewportVertical(1)
|
||||
}
|
||||
|
||||
case key.Matches(msg, g.CycleFocus):
|
||||
switch m.focusedPanel {
|
||||
case panelList:
|
||||
m.focusedPanel = panelRequest
|
||||
case panelRequest:
|
||||
m.focusedPanel = panelResponse
|
||||
default:
|
||||
m.focusedPanel = panelList
|
||||
}
|
||||
|
||||
case key.Matches(msg, r.Send):
|
||||
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
|
||||
@@ -166,18 +191,14 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case key.Matches(msg, g.ScrollUp):
|
||||
step := m.responseViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
|
||||
vp := m.focusedViewport()
|
||||
util.ScrollViewport(&vp, -1)
|
||||
m.setFocusedViewport(vp)
|
||||
|
||||
case key.Matches(msg, g.ScrollDown):
|
||||
step := m.responseViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
|
||||
vp := m.focusedViewport()
|
||||
util.ScrollViewport(&vp, 1)
|
||||
m.setFocusedViewport(vp)
|
||||
|
||||
case key.Matches(msg, g.Left):
|
||||
m.requestViewport.ScrollLeft(6)
|
||||
@@ -212,6 +233,45 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
m.refreshListViewport()
|
||||
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):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.recalcSizes()
|
||||
@@ -239,11 +299,39 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
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() {
|
||||
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.SetTotalPages(len(m.entries))
|
||||
}
|
||||
}
|
||||
m.listViewport.SetContent(m.renderList())
|
||||
}
|
||||
|
||||
@@ -259,69 +347,46 @@ func (m *Model) refreshBody() {
|
||||
m.requestViewport.SetXOffset(0)
|
||||
|
||||
if e.Sending {
|
||||
m.responseViewport.SetContent(style.HighlightHTTP("Sending..."))
|
||||
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
|
||||
} else if e.ResponseRaw != "" {
|
||||
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
||||
} else {
|
||||
m.responseViewport.SetContent("")
|
||||
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.SetXOffset(0)
|
||||
}
|
||||
|
||||
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
||||
lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n")
|
||||
if len(lines) == 0 {
|
||||
parsed := util.ParseRawRequest(entry.RequestRaw)
|
||||
if parsed.Method == "" {
|
||||
return "", 0, fmt.Errorf("empty request")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(lines[0], " ", 3)
|
||||
if len(parts) < 2 {
|
||||
return "", 0, fmt.Errorf("invalid request line")
|
||||
host := parsed.Host
|
||||
if host == "" {
|
||||
host = entry.Host
|
||||
}
|
||||
method := strings.TrimSpace(parts[0])
|
||||
path := strings.TrimSpace(parts[1])
|
||||
|
||||
headers := make(http.Header)
|
||||
host := entry.Host
|
||||
i := 1
|
||||
for i < len(lines) {
|
||||
line := strings.TrimRight(lines[i], "\r")
|
||||
if line == "" {
|
||||
i++
|
||||
break
|
||||
for _, h := range parsed.Headers {
|
||||
if strings.EqualFold(h.Key, "host") {
|
||||
continue
|
||||
}
|
||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
||||
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)
|
||||
headers.Add(h.Key, h.Value)
|
||||
}
|
||||
|
||||
scheme := entry.Scheme
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
urlStr := scheme + "://" + host + path
|
||||
urlStr := scheme + "://" + host + parsed.Path
|
||||
|
||||
var bodyReader io.Reader
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
if parsed.Body != "" {
|
||||
bodyReader = strings.NewReader(parsed.Body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, bodyReader)
|
||||
req, err := http.NewRequest(parsed.Method, urlStr, bodyReader)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
@@ -343,19 +408,21 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
||||
}
|
||||
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
|
||||
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||
sortedKeys := make([]string, 0, len(resp.Header))
|
||||
for k := range resp.Header {
|
||||
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)
|
||||
}
|
||||
for _, line := range util.SortedHeaderLines(resp.Header) {
|
||||
sb.WriteString(line)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.Write(respBody)
|
||||
@@ -363,6 +430,35 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
||||
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 {
|
||||
errMsg := ""
|
||||
if e.Err != nil {
|
||||
@@ -384,7 +480,11 @@ func entryToDB(e Entry) db.ReplayEntry {
|
||||
}
|
||||
|
||||
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
|
||||
if scheme == "" {
|
||||
scheme = util.InferScheme(host)
|
||||
@@ -392,34 +492,9 @@ func entryFromMsg(msg SendToReplayMsg) Entry {
|
||||
return Entry{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
Path: path,
|
||||
Method: method,
|
||||
Path: parsed.Path,
|
||||
Method: parsed.Method,
|
||||
OriginalRaw: 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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
@@ -33,11 +34,14 @@ func (m Model) View() tea.View {
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
s := style.S
|
||||
panelStyle := s.PanelFocused
|
||||
if m.editing {
|
||||
panelStyle = s.Panel
|
||||
panelStyle := s.Panel
|
||||
if !m.editing && m.focusedPanel == panelList {
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
||||
@@ -54,13 +58,20 @@ func (m *Model) renderRequestPanel(w, h int) string {
|
||||
border = s.PanelFocused
|
||||
} else {
|
||||
body = m.requestViewport.View()
|
||||
if m.focusedPanel == panelRequest {
|
||||
border = s.PanelFocused
|
||||
}
|
||||
}
|
||||
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
|
||||
}
|
||||
|
||||
func (m *Model) renderResponsePanel(w, h int) string {
|
||||
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", m.responseViewport.View(), w, h)
|
||||
}
|
||||
|
||||
func (m *Model) renderStatusBar() string {
|
||||
@@ -72,7 +83,7 @@ func (m *Model) renderList() string {
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
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")),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os/exec"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
)
|
||||
|
||||
type EditorFinishedMsg struct {
|
||||
@@ -13,7 +15,10 @@ type EditorFinishedMsg struct {
|
||||
}
|
||||
|
||||
func OpenExternalEditor(content string) tea.Cmd {
|
||||
editor := os.Getenv("EDITOR")
|
||||
editor := config.Global.App.ExternalEditor
|
||||
if editor == "" {
|
||||
editor = os.Getenv("EDITOR")
|
||||
}
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+20
-1
@@ -1,6 +1,10 @@
|
||||
package util
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func Truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
@@ -9,6 +13,21 @@ func Truncate(s string, max int) string {
|
||||
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")
|
||||
}
|
||||
|
||||
// InferScheme returns "http" for port 80, "https" otherwise.
|
||||
func InferScheme(host string) string {
|
||||
if strings.HasSuffix(host, ":80") {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// ScrollViewport scrolls vp vertically by half its height.
|
||||
// delta should be -1 for up, +1 for down.
|
||||
func ScrollViewport(vp *viewport.Model, delta int) {
|
||||
step := vp.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
vp.SetYOffset(vp.YOffset() + delta*step)
|
||||
}
|
||||
|
||||
// HandleMouseWheel applies standard mouse wheel scrolling to vp.
|
||||
// Vertical: one line at a time. Shift+vertical or horizontal: scroll 6 columns.
|
||||
func HandleMouseWheel(msg tea.MouseWheelMsg, vp *viewport.Model) {
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
if msg.Mod.Contains(tea.ModShift) {
|
||||
vp.ScrollLeft(6)
|
||||
} else {
|
||||
vp.SetYOffset(vp.YOffset() - 1)
|
||||
}
|
||||
case tea.MouseWheelDown:
|
||||
if msg.Mod.Contains(tea.ModShift) {
|
||||
vp.ScrollRight(6)
|
||||
} else {
|
||||
vp.SetYOffset(vp.YOffset() + 1)
|
||||
}
|
||||
case tea.MouseWheelLeft:
|
||||
vp.ScrollLeft(6)
|
||||
case tea.MouseWheelRight:
|
||||
vp.ScrollRight(6)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
pre-commit:
|
||||
piped: true
|
||||
commands:
|
||||
check-vendor-hash:
|
||||
glob: "{go.mod,go.sum}"
|
||||
run: .github/scripts/check-vendor-hash.sh
|
||||
stage_fixed: true
|
||||
inject-exec-basics:
|
||||
glob: "{docs/basics.md,cmd/**}"
|
||||
run: python3 .github/scripts/inject-exec.py docs/basics.md
|
||||
stage_fixed: true
|
||||
inject-exec:
|
||||
glob: "{README.md,docs/basics.md,cmd/**}"
|
||||
run: python3 .github/scripts/inject-exec.py README.md
|
||||
stage_fixed: true
|
||||
toc:
|
||||
glob: "{README.md,docs/basics.md,cmd/**}"
|
||||
run: doctoc --notitle README.md
|
||||
stage_fixed: true
|
||||
@@ -10,6 +10,7 @@ Checks that the proxy's outbound IP is in an allowed list on startup.
|
||||
- if no IPs are configured, the check is skipped
|
||||
]],
|
||||
on_start = { sync = false },
|
||||
disable_by_default = true,
|
||||
}
|
||||
|
||||
local whitelist = {}
|
||||
@@ -39,16 +40,10 @@ function on_start()
|
||||
return
|
||||
end
|
||||
|
||||
-- Fetch the current outbound IP via a public API.
|
||||
local ok, result = pcall(function()
|
||||
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
|
||||
if not handle then return nil end
|
||||
local ip = handle:read("*a")
|
||||
handle:close()
|
||||
return ip and ip:match("^%s*(.-)%s*$") or nil
|
||||
end)
|
||||
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
||||
result = result and result:match("^%s*(.-)%s*$") or nil
|
||||
|
||||
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")
|
||||
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
||||
return
|
||||
|
||||
+43
-17
@@ -4,8 +4,12 @@ Plugin = {
|
||||
Auto-forward requests and exclude them from history based on patterns.
|
||||
|
||||
**Config**:
|
||||
- `pattern` - whitelist: only intercept matching requests
|
||||
- `!pattern` - blacklist: don't intercept matching requests and exclude from history
|
||||
- `pattern` - whitelist: only intercept matching requests/responses and history entries
|
||||
- `!pattern` - blacklist: skip matching requests/responses and history entries
|
||||
- `r:pattern` - whitelist for requests/responses only (history unaffected)
|
||||
- `r:!pattern` - blacklist for requests/responses only
|
||||
- `h:pattern` - whitelist for history entries only (requests unaffected)
|
||||
- `h:!pattern` - blacklist for history entries only
|
||||
- lines starting with `#` are comments
|
||||
|
||||
Example (ignore static assets):
|
||||
@@ -26,6 +30,11 @@ mytarget%.com/
|
||||
!%.css$
|
||||
!%.js$
|
||||
!%.png$
|
||||
```
|
||||
|
||||
Example (disable history: whitelist never matches any real URL):
|
||||
```
|
||||
h:^$
|
||||
```
|
||||
]],
|
||||
priority = 100,
|
||||
@@ -34,31 +43,48 @@ mytarget%.com/
|
||||
on_history_entry = { sync = true },
|
||||
}
|
||||
|
||||
local whitelist = {}
|
||||
local blacklist = {}
|
||||
local whitelist = {}
|
||||
local blacklist_req = {}
|
||||
local whitelist_req = {}
|
||||
local blacklist_hist = {}
|
||||
local whitelist_hist = {}
|
||||
|
||||
function on_config(config_text)
|
||||
whitelist = {}
|
||||
blacklist = {}
|
||||
blacklist, whitelist = {}, {}
|
||||
blacklist_req, whitelist_req = {}, {}
|
||||
blacklist_hist, whitelist_hist = {}, {}
|
||||
for line in config_text:gmatch("[^\n]+") do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||
if trimmed:sub(1, 1) == "!" then
|
||||
table.insert(blacklist, trimmed:sub(2))
|
||||
local scope = trimmed:match("^([rh]):")
|
||||
local rest = scope and trimmed:sub(3) or trimmed
|
||||
local is_bl = rest:sub(1, 1) == "!"
|
||||
local pattern = is_bl and rest:sub(2) or rest
|
||||
if scope == "r" then
|
||||
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
||||
elseif scope == "h" then
|
||||
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
||||
else
|
||||
table.insert(whitelist, trimmed)
|
||||
table.insert(is_bl and blacklist or whitelist, pattern)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function should_skip(url)
|
||||
for _, pattern in ipairs(blacklist) do
|
||||
if url:match(pattern) then return true end
|
||||
local function check_skip(url, bl_extra, wl_extra)
|
||||
for _, p in ipairs(blacklist) do
|
||||
if url:match(p) then return true end
|
||||
end
|
||||
if #whitelist > 0 then
|
||||
for _, pattern in ipairs(whitelist) do
|
||||
if url:match(pattern) then return false end
|
||||
for _, p in ipairs(bl_extra) do
|
||||
if url:match(p) then return true end
|
||||
end
|
||||
local wl = {}
|
||||
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end
|
||||
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end
|
||||
if #wl > 0 then
|
||||
for _, p in ipairs(wl) do
|
||||
if url:match(p) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -66,13 +92,13 @@ local function should_skip(url)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
Plugin = {
|
||||
name = "Secret Scan",
|
||||
description = [[
|
||||
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
|
||||
secrets by matching common secret key names followed by a non-trivial value.
|
||||
|
||||
Uses `grep -E` (available on all Unix systems, no extra dependencies).
|
||||
]],
|
||||
on_request = { sync = false },
|
||||
on_response = { sync = false },
|
||||
disable_by_default = true,
|
||||
}
|
||||
|
||||
local CONTENT_TYPES = {
|
||||
"text/html",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
}
|
||||
|
||||
-- Key name alternation (case-insensitive via grep -i)
|
||||
-- Suffixes are required (no bare generic keyword alone).
|
||||
local KEYS = {
|
||||
"access(_key|_token)", "accessid_secret", "account(_key|_sid)",
|
||||
"admin_pass(word)?", "admin_user",
|
||||
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
|
||||
"ansible_vault_password", "aos_key",
|
||||
"api(_key|_secret|_token)",
|
||||
"app_(id|key|secret)", "application(_key|_id|_secret)",
|
||||
"auth(_token|_secret|orization)", "authkey", "authsecret",
|
||||
"bearer_?token",
|
||||
"bucket(_password|_key)",
|
||||
"cert_?pass(word)?", "certificate_password",
|
||||
"client(_id|_secret)",
|
||||
"codecov_token", "consumer_(key|secret)",
|
||||
"connection_?string", "credentials?", "crypt(_key|_secret)",
|
||||
"db_(password|passwd|user(name)?)",
|
||||
"deploy(_key|_password|_token)",
|
||||
"docker_?pass(word)?", "dockerhub_?password",
|
||||
"encryption_(key|password)",
|
||||
"jwt_secret", "json_web_token",
|
||||
"keycloak_secret", "kubernetes_token",
|
||||
"ldap_(password|bindpw)", "login(_password|_token)",
|
||||
"mail_?password", "mail_smtp_pass",
|
||||
"mysql_password", "mongo_password",
|
||||
"netlify_token", "npm(_token|_auth_token)",
|
||||
"oauth(_token|_secret)",
|
||||
"openai_(api_key|secret)",
|
||||
"pass(word)?", "passwd",
|
||||
"private(_key|_token)",
|
||||
"rds_password",
|
||||
"s3(_key|_secret|_access_key_id)",
|
||||
"secret(_key|_token|_id)", "security_token",
|
||||
"sendgrid_api_key",
|
||||
"ses_(smtp|access|secret)",
|
||||
"service(_account|_key|_token)",
|
||||
"smtp_pass(word)?", "smtp_secret",
|
||||
"sonar_token",
|
||||
"ssh(_key|_private_key|_rsa)",
|
||||
"supabase(_anon|_service)?_key",
|
||||
"symfony_secret",
|
||||
"telegram_bot_token",
|
||||
"token",
|
||||
"travis_token",
|
||||
"vault(_token|_secret)",
|
||||
"webhook(_secret|_token)",
|
||||
"zapier_webhook_token",
|
||||
}
|
||||
|
||||
-- Built once at load time.
|
||||
-- Pattern breakdown:
|
||||
-- KEY[a-z0-9._-]{0,20} key name + optional alphanumeric suffix (e.g. _ID in AWS_ACCESS_KEY_ID)
|
||||
-- [^=:a-zA-Z0-9_]{0,3} optional non-identifier chars before separator (e.g. closing " in JSON "key":)
|
||||
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
|
||||
-- [[:space:]]*"? optional whitespace + opening quote
|
||||
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
|
||||
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
|
||||
local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:=][[:space:]]*"?[a-zA-Z0-9+/=_.-]{8,}'
|
||||
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
|
||||
|
||||
local function is_relevant(ct)
|
||||
if not ct or ct == "" then return false end
|
||||
ct = ct:lower()
|
||||
for _, t in ipairs(CONTENT_TYPES) do
|
||||
if ct:find(t, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function build_context(lines, linenum)
|
||||
local lo = math.max(1, linenum - 6)
|
||||
local hi = math.min(#lines, linenum + 6)
|
||||
|
||||
local before, after = {}, {}
|
||||
for i = lo, linenum - 1 do
|
||||
local l = lines[i] or ""
|
||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
||||
table.insert(before, l)
|
||||
end
|
||||
for i = linenum + 1, hi do
|
||||
local l = lines[i] or ""
|
||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
||||
table.insert(after, l)
|
||||
end
|
||||
|
||||
local matched_line = lines[linenum] or ""
|
||||
if #matched_line > 200 then matched_line = matched_line:sub(1, 200) .. "..." end
|
||||
|
||||
local parts = {}
|
||||
if #before > 0 then
|
||||
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
||||
end
|
||||
table.insert(parts, "> **`" .. matched_line .. "`**")
|
||||
if #after > 0 then
|
||||
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
||||
end
|
||||
return table.concat(parts, "\n\n")
|
||||
end
|
||||
|
||||
local function scan(label, ct, body, host, path)
|
||||
if not is_relevant(ct) then return end
|
||||
if not body or body == "" then return end
|
||||
|
||||
local out, err = shell_pipe(GREP_CMD, body)
|
||||
if err and err ~= "" then
|
||||
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
||||
return
|
||||
end
|
||||
if not out or out == "" then return end
|
||||
|
||||
local lines = {}
|
||||
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
for entry in out:gmatch("[^\n]+") do
|
||||
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
||||
if linenum_str then
|
||||
local linenum = tonumber(linenum_str)
|
||||
matched = matched:match("^%s*(.-)%s*$")
|
||||
if matched ~= "" then
|
||||
local display = matched
|
||||
if #display > 200 then display = display:sub(1, 200) .. "..." end
|
||||
local ctx = build_context(lines, linenum)
|
||||
create_finding({
|
||||
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
||||
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx,
|
||||
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
||||
severity = "high",
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function on_request(req)
|
||||
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
|
||||
end
|
||||
|
||||
function on_response(req, res)
|
||||
local ct = ""
|
||||
if res.headers then
|
||||
ct = res.headers["Content-Type"] or ""
|
||||
end
|
||||
scan("response", ct, res:get_body(), req.host, req.path)
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
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 handle = io.popen("command -v trufflehog 2>/dev/null")
|
||||
local result = handle and handle:read("*a") or ""
|
||||
if handle then handle:close() end
|
||||
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
|
||||
Reference in New Issue
Block a user