mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-07-06 20:42:33 +02:00
Compare commits
26 Commits
2e61ffa125
..
v0.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
| a75b65644f | |||
| cc0176a73d | |||
| ec0fb61099 | |||
| ee3e6806c7 | |||
| 36eab35576 | |||
| 8211d87379 | |||
| ef1789af2f | |||
| 31bb2aa439 | |||
| b3caa9852c | |||
| 39248e2be6 | |||
| 9d0ef598a7 | |||
| 77b102791f | |||
| 2e0ad98a4a | |||
| b0afb7cb56 | |||
| acee3df1bc | |||
| 61a112a302 | |||
| 677223c9b0 | |||
| 098400ba77 | |||
| 4922f3704d | |||
| 1aa4b92bb3 | |||
| 437e8f883d | |||
| 5c74eda48b | |||
| 1190276bab | |||
| 6128dcc15e | |||
| 41d5366952 | |||
| 8683d17242 |
@@ -9,10 +9,10 @@
|
|||||||
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
> Think Burp Suite or Caido, but entirely in your terminal.
|
> Think Burp Suite or Caido, but entirely in your terminal.
|
||||||
|
|
||||||
[](go.mod)
|

|
||||||
[](https://github.com/anotherhadi/spilltea/releases)
|

|
||||||
[](LICENSE)
|

|
||||||
[](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
|
[](https://ko-fi.com/anotherhadi)
|
||||||
|
|
||||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Project Management](#project-management)
|
- [Project Management](#project-management)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
|
- [Per-project configuration](#per-project-configuration)
|
||||||
- [CLI Flags](#cli-flags)
|
- [CLI Flags](#cli-flags)
|
||||||
- [Plugin System](#plugin-system)
|
- [Plugin System](#plugin-system)
|
||||||
- [Vim / Neovim Integration](#vim--neovim-integration)
|
- [Vim / Neovim Integration](#vim--neovim-integration)
|
||||||
@@ -107,9 +108,9 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
|
|||||||
|
|
||||||
On startup, you choose:
|
On startup, you choose:
|
||||||
|
|
||||||
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
|
- **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
|
||||||
- **Existing project**: pick from a list of previous projects
|
- **Existing project**: pick from a list of previous projects
|
||||||
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
- **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -118,20 +119,38 @@ Check the default configuration with all the options [here](./internal/config/de
|
|||||||
|
|
||||||
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||||
|
|
||||||
|
### Per-project configuration
|
||||||
|
|
||||||
|
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
|
||||||
|
|
||||||
|
Only the keys present in that file are overridden; everything else falls back to the global config.
|
||||||
|
|
||||||
|
The priority order is:
|
||||||
|
|
||||||
|
1. Global config (`~/.config/spilltea/config.yaml`)
|
||||||
|
2. Project config (`<project-dir>/config.yaml`)
|
||||||
|
3. CLI flags (always win)
|
||||||
|
|
||||||
## CLI Flags
|
## CLI Flags
|
||||||
|
|
||||||
```
|
```
|
||||||
Usage: spilltea [flags]
|
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
spilltea [flags]
|
||||||
|
|
||||||
|
Flags:
|
||||||
--add-default-config copy the default config file to the config path and exit
|
--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
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
-c, --config string path to config file
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
--host string proxy host (overrides config)
|
--host string proxy host (overrides config)
|
||||||
--plugins-dir string path to plugins dir (overrides config)
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
-p, --port int proxy port (overrides config)
|
-p, --port int proxy port (overrides config)
|
||||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||||
|
--ssl-insecure skip TLS certificate verification (overrides config)
|
||||||
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
-v, --version print version
|
-v, --version version for spilltea
|
||||||
```
|
```
|
||||||
<!-- endexec -->
|
<!-- endexec -->
|
||||||
|
|
||||||
|
|||||||
+93
-65
@@ -15,7 +15,7 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
flag "github.com/spf13/pflag"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
||||||
@@ -32,95 +32,93 @@ func init() {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
flagConfig string
|
||||||
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
|
flagPluginsDir string
|
||||||
flagHost = flag.String("host", "", "proxy host (overrides config)")
|
flagHost string
|
||||||
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
|
flagPort int
|
||||||
flagUpstreamProxy = flag.String("upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
|
flagUpstreamProxy string
|
||||||
flagVersion = flag.BoolP("version", "v", false, "print version")
|
flagProject string
|
||||||
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
flagSslInsecure bool
|
||||||
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
flagAddDefaultPlugins bool
|
||||||
flagAddDefaultConfig = flag.Bool("add-default-config", false, "copy the default config file to the config path and exit")
|
flagAddDefaultConfig bool
|
||||||
)
|
)
|
||||||
flag.CommandLine.SetOutput(os.Stdout)
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Println("Usage: spilltea [flags]")
|
|
||||||
fmt.Println("")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *flagVersion {
|
rootCmd := &cobra.Command{
|
||||||
fmt.Println(version)
|
Use: "spilltea",
|
||||||
os.Exit(0)
|
Short: "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.",
|
||||||
}
|
Version: version,
|
||||||
|
SilenceUsage: true,
|
||||||
if *flagAddDefaultPlugins {
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagAddDefaultPlugins {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
if *flagConfig != "" {
|
if flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = flagConfig
|
||||||
}
|
}
|
||||||
if err := config.Load(cfgPath); err != nil {
|
if err := config.Load(cfgPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
return fmt.Errorf("config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
dir := config.ExpandPath(config.Global.App.PluginsDir)
|
dir := config.ExpandPath(config.Global.App.PluginsDir)
|
||||||
if *flagPluginsDir != "" {
|
if flagPluginsDir != "" {
|
||||||
dir = *flagPluginsDir
|
dir = flagPluginsDir
|
||||||
}
|
}
|
||||||
n, err := spilltea.InstallDefaultPlugins(dir)
|
n, err := spilltea.InstallDefaultPlugins(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err)
|
return fmt.Errorf("add-default-plugins: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
|
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
|
||||||
os.Exit(0)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flagAddDefaultConfig {
|
if flagAddDefaultConfig {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
if *flagConfig != "" {
|
if flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = flagConfig
|
||||||
}
|
}
|
||||||
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "add-default-config: %v\n", err)
|
return fmt.Errorf("add-default-config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
fmt.Printf("default config written to %s\n", cfgPath)
|
fmt.Printf("default config written to %s\n", cfgPath)
|
||||||
os.Exit(0)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) {
|
if flagProject != "" && !homeUI.IsValidProjectName(flagProject) {
|
||||||
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
|
return fmt.Errorf("project: invalid name %q (only lowercase letters, digits, - and _ are allowed)", flagProject)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
if *flagConfig != "" {
|
if flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = flagConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := config.Load(cfgPath); err != nil {
|
if err := config.Load(cfgPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
return fmt.Errorf("config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
config.Global.Version = version
|
config.Global.Version = version
|
||||||
|
|
||||||
if *flagPluginsDir != "" {
|
sslInsecureChanged := cmd.Flags().Changed("ssl-insecure")
|
||||||
config.Global.App.PluginsDir = *flagPluginsDir
|
applyCLI := func(c *config.Config) {
|
||||||
|
if flagPluginsDir != "" {
|
||||||
|
c.App.PluginsDir = flagPluginsDir
|
||||||
}
|
}
|
||||||
if *flagHost != "" {
|
if flagHost != "" {
|
||||||
config.Global.App.Host = *flagHost
|
c.App.Host = flagHost
|
||||||
}
|
}
|
||||||
if *flagPort != 0 {
|
if flagPort != 0 {
|
||||||
config.Global.App.Port = *flagPort
|
c.App.Port = flagPort
|
||||||
}
|
}
|
||||||
if *flagUpstreamProxy != "" {
|
if flagUpstreamProxy != "" {
|
||||||
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
c.App.UpstreamProxy = flagUpstreamProxy
|
||||||
}
|
}
|
||||||
|
if sslInsecureChanged {
|
||||||
|
c.App.SslInsecure = flagSslInsecure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyCLI(config.Global)
|
||||||
|
config.SetCLIOverrides(applyCLI)
|
||||||
|
|
||||||
style.Init()
|
style.Init()
|
||||||
icons.Init(config.Global)
|
icons.Init(config.Global)
|
||||||
@@ -128,26 +126,56 @@ func main() {
|
|||||||
|
|
||||||
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
||||||
|
|
||||||
// If --project flag is set, skip the home screen entirely.
|
if flagProject != "" {
|
||||||
if *flagProject != "" {
|
project, err := homeUI.OpenProject(projectDir, flagProject)
|
||||||
project, err := homeUI.OpenProject(projectDir, *flagProject)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "project: %v\n", err)
|
return fmt.Errorf("project: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
if err := config.MergeProjectConfig(filepath.Dir(project.Path)); err != nil {
|
||||||
|
return fmt.Errorf("project config: %w", err)
|
||||||
|
}
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
broker := intercept.NewBroker()
|
broker := intercept.NewBroker()
|
||||||
m := appUI.New(broker, project.Name, project.Path)
|
m := appUI.New(broker, project.Name, project.Path)
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
final, err := tea.NewProgram(m).Run()
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
if err != nil {
|
||||||
os.Exit(1)
|
return fmt.Errorf("tui: %w", err)
|
||||||
}
|
}
|
||||||
return
|
if app, ok := final.(appUI.Model); ok {
|
||||||
|
if ferr := app.FatalErr(); ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run home + app in a single program to avoid a blank flash on transition.
|
|
||||||
root := rootModel{home: homeUI.New(projectDir)}
|
root := rootModel{home: homeUI.New(projectDir)}
|
||||||
if _, err := tea.NewProgram(root).Run(); err != nil {
|
final, err := tea.NewProgram(root).Run()
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("tui: %w", err)
|
||||||
|
}
|
||||||
|
if r, ok := final.(rootModel); ok {
|
||||||
|
if ferr := r.FatalErr(); ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
|
||||||
|
rootCmd.Flags().StringVar(&flagPluginsDir, "plugins-dir", "", "path to plugins dir (overrides config)")
|
||||||
|
rootCmd.Flags().StringVar(&flagHost, "host", "", "proxy host (overrides config)")
|
||||||
|
rootCmd.Flags().IntVarP(&flagPort, "port", "p", 0, "proxy port (overrides config)")
|
||||||
|
rootCmd.Flags().StringVar(&flagUpstreamProxy, "upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
|
||||||
|
rootCmd.Flags().BoolVar(&flagSslInsecure, "ssl-insecure", false, "skip TLS certificate verification (overrides config)")
|
||||||
|
rootCmd.Flags().StringVarP(&flagProject, "project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultPlugins, "add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
)
|
)
|
||||||
@@ -34,6 +40,11 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
if m.state == rootStateHome {
|
if m.state == rootStateHome {
|
||||||
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
|
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
|
||||||
|
if err := config.MergeProjectConfig(filepath.Dir(sel.Project.Path)); err != nil {
|
||||||
|
log.Printf("project config: %v", err)
|
||||||
|
}
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
broker := intercept.NewBroker()
|
broker := intercept.NewBroker()
|
||||||
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
||||||
m.app = app
|
m.app = app
|
||||||
@@ -58,3 +69,12 @@ func (m rootModel) View() tea.View {
|
|||||||
}
|
}
|
||||||
return m.home.View()
|
return m.home.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m rootModel) FatalErr() error {
|
||||||
|
if m.state == rootStateApp {
|
||||||
|
if app, ok := m.app.(appUI.Model); ok {
|
||||||
|
return app.FatalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+22
-4
@@ -4,9 +4,9 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
|
|||||||
|
|
||||||
On startup, you choose:
|
On startup, you choose:
|
||||||
|
|
||||||
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
|
- **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
|
||||||
- **Existing project**: pick from a list of previous projects
|
- **Existing project**: pick from a list of previous projects
|
||||||
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
|
- **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -15,20 +15,38 @@ Check the default configuration with all the options [here](./internal/config/de
|
|||||||
|
|
||||||
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||||
|
|
||||||
|
### Per-project configuration
|
||||||
|
|
||||||
|
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
|
||||||
|
|
||||||
|
Only the keys present in that file are overridden; everything else falls back to the global config.
|
||||||
|
|
||||||
|
The priority order is:
|
||||||
|
|
||||||
|
1. Global config (`~/.config/spilltea/config.yaml`)
|
||||||
|
2. Project config (`<project-dir>/config.yaml`)
|
||||||
|
3. CLI flags (always win)
|
||||||
|
|
||||||
## CLI Flags
|
## CLI Flags
|
||||||
|
|
||||||
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
||||||
```
|
```
|
||||||
Usage: spilltea [flags]
|
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
spilltea [flags]
|
||||||
|
|
||||||
|
Flags:
|
||||||
--add-default-config copy the default config file to the config path and exit
|
--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
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
-c, --config string path to config file
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
--host string proxy host (overrides config)
|
--host string proxy host (overrides config)
|
||||||
--plugins-dir string path to plugins dir (overrides config)
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
-p, --port int proxy port (overrides config)
|
-p, --port int proxy port (overrides config)
|
||||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
-P, --project string project name to open directly, or "tmp" for a temporary session
|
||||||
|
--ssl-insecure skip TLS certificate verification (overrides config)
|
||||||
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
-v, --version print version
|
-v, --version version for spilltea
|
||||||
```
|
```
|
||||||
<!-- endexec -->
|
<!-- endexec -->
|
||||||
|
|||||||
@@ -11,5 +11,7 @@
|
|||||||
# Spilltea Documentation
|
# Spilltea Documentation
|
||||||
|
|
||||||
- **Version**: `{{.Cfg.Version}}`
|
- **Version**: `{{.Cfg.Version}}`
|
||||||
|
- **Proxy**: `{{.Cfg.App.Host}}:{{.Cfg.App.Port}}`
|
||||||
|
- **SSL Insecure**: `{{.Cfg.App.SslInsecure}}`
|
||||||
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
||||||
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
||||||
|
|||||||
+1
-1
@@ -155,7 +155,7 @@ If the user **dismisses** a finding it is permanently hidden and will never reap
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Plugin configuration is stored in a `plugins.yaml` file alongside the project database.
|
Plugin configuration is stored in a `plugins.yaml` file alongside the project database and can be edited through the "Proxy" TUI page.
|
||||||
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
|
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,12 @@
|
|||||||
## Configuring your browser's proxy settings
|
## Configuring your browser's proxy settings
|
||||||
|
|
||||||
We recommend installing **FoxyProxy** to manage your browser's proxies.
|
**If you use Nix**, you can launch a pre-configured browser (LibreWolf) with the proxy and certificate already set up:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run github:anotherhadi/spilltea#browser{{if or (ne .Cfg.App.Host "127.0.0.1") (ne .Cfg.App.Port 8080) (ne .Cfg.App.CertDir "~/.local/share/spilltea")}} --{{end}}{{if ne .Cfg.App.Host "127.0.0.1"}} --host {{.Cfg.App.Host}}{{end}}{{if ne .Cfg.App.Port 8080}} --port {{.Cfg.App.Port}}{{end}}{{if ne .Cfg.App.CertDir "~/.local/share/spilltea"}} --cert-dir {{.Cfg.App.CertDir}}{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, we recommend installing **FoxyProxy** to manage your browser's proxies.
|
||||||
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
|
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
|
||||||
|
|
||||||
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
|
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ require (
|
|||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
golang.org/x/net v0.54.0
|
golang.org/x/net v0.55.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.50.1
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
@@ -41,6 +41,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // indirect
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
@@ -55,6 +56,7 @@ require (
|
|||||||
github.com/satori/go.uuid v1.2.0 // indirect
|
github.com/satori/go.uuid v1.2.0 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
@@ -62,7 +64,7 @@ require (
|
|||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
|||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
@@ -72,6 +73,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||||
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -104,6 +107,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
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/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
@@ -116,6 +120,9 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
|||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
@@ -140,12 +147,12 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
|
|||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
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/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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
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 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Config struct {
|
|||||||
ProxyAuth string `mapstructure:"proxy_auth"`
|
ProxyAuth string `mapstructure:"proxy_auth"`
|
||||||
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
||||||
ExternalEditor string `mapstructure:"external_editor"`
|
ExternalEditor string `mapstructure:"external_editor"`
|
||||||
|
SslInsecure bool `mapstructure:"ssl_insecure"`
|
||||||
} `mapstructure:"app"`
|
} `mapstructure:"app"`
|
||||||
|
|
||||||
TUI struct {
|
TUI struct {
|
||||||
@@ -64,6 +65,43 @@ type GlobalPlugin struct {
|
|||||||
|
|
||||||
var Global *Config
|
var Global *Config
|
||||||
|
|
||||||
|
var projectCLIOverrides func(*Config)
|
||||||
|
|
||||||
|
// SetCLIOverrides stores a function that re-applies CLI flag overrides.
|
||||||
|
// It is called automatically after MergeProjectConfig so that CLI flags
|
||||||
|
// always win over both global and per-project config.
|
||||||
|
func SetCLIOverrides(fn func(*Config)) {
|
||||||
|
projectCLIOverrides = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeProjectConfig merges <projectDir>/config.yaml on top of the current
|
||||||
|
// Global config, then re-applies any registered CLI overrides.
|
||||||
|
// If no config.yaml exists in the project directory, this is a no-op.
|
||||||
|
func MergeProjectConfig(projectDir string) error {
|
||||||
|
projectConfigPath := filepath.Join(projectDir, "config.yaml")
|
||||||
|
if _, err := os.Stat(projectConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pv := viper.New()
|
||||||
|
pv.SetConfigFile(projectConfigPath)
|
||||||
|
if err := pv.ReadInConfig(); err != nil {
|
||||||
|
return fmt.Errorf("project config: %w", err)
|
||||||
|
}
|
||||||
|
if err := viper.MergeConfigMap(pv.AllSettings()); err != nil {
|
||||||
|
return fmt.Errorf("merge project config: %w", err)
|
||||||
|
}
|
||||||
|
if err := viper.Unmarshal(Global); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
|
||||||
|
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
|
||||||
|
if projectCLIOverrides != nil {
|
||||||
|
projectCLIOverrides(Global)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) error {
|
func Load(path string) error {
|
||||||
var defaults map[string]any
|
var defaults map[string]any
|
||||||
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
||||||
@@ -82,11 +120,16 @@ func Load(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Global = &Config{}
|
Global = &Config{}
|
||||||
return viper.Unmarshal(Global)
|
if err := viper.Unmarshal(Global); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
|
||||||
|
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteDefaultConfig(path string) error {
|
func WriteDefaultConfig(path string) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
return fmt.Errorf("create config dir: %w", err)
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
||||||
@@ -106,6 +149,17 @@ func ExpandPath(p string) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandEnvValue replaces a value starting with "$" with the corresponding
|
||||||
|
// environment variable, enabling secrets to be kept out of config files.
|
||||||
|
func expandEnvValue(s string) string {
|
||||||
|
if len(s) > 1 && s[0] == '$' {
|
||||||
|
if val := os.Getenv(s[1:]); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func flatten(prefix string, m map[string]any) map[string]any {
|
func flatten(prefix string, m map[string]any) map[string]any {
|
||||||
out := make(map[string]any)
|
out := make(map[string]any)
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ app:
|
|||||||
cert_dir: ~/.local/share/spilltea
|
cert_dir: ~/.local/share/spilltea
|
||||||
project_dir: ~/.local/share/spilltea
|
project_dir: ~/.local/share/spilltea
|
||||||
plugins_dir: ~/.config/spilltea/plugins
|
plugins_dir: ~/.config/spilltea/plugins
|
||||||
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
|
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or $ENV_VAR
|
||||||
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
|
proxy_auth: "" # require basic auth to use the spilltea proxy, format: user:pass or $ENV_VAR (empty = disabled).
|
||||||
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
|
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)
|
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
||||||
|
ssl_insecure: false # bypass TLS certificate verification (enable for self-signed cert targets)
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
default_intercept_enabled: true
|
default_intercept_enabled: true
|
||||||
default_capture_response: false
|
default_capture_response: false
|
||||||
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
|
queue_size: 128 # max pending intercepted requests/responses before the proxy blocks
|
||||||
auto_forward_regex:
|
auto_forward_regex:
|
||||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||||
|
|
||||||
@@ -31,12 +32,13 @@ tui:
|
|||||||
keybindings:
|
keybindings:
|
||||||
global:
|
global:
|
||||||
quit: "q,ctrl+c"
|
quit: "q,ctrl+c"
|
||||||
|
escape: "esc,ctrl+c"
|
||||||
help: "?"
|
help: "?"
|
||||||
open_logs: "ctrl+g"
|
open_logs: "ctrl+g"
|
||||||
toggle_sidebar: "ctrl+b"
|
toggle_sidebar: "ctrl+b"
|
||||||
cycle_focus: "tab"
|
cycle_focus: "tab"
|
||||||
send_to_replay: "ctrl+r"
|
send_to_replay: "ctrl+r"
|
||||||
send_to_diff: "ctrl+d"
|
send_to_diff: "ctrl+x"
|
||||||
copy_as: "ctrl+y"
|
copy_as: "ctrl+y"
|
||||||
copy: "y"
|
copy: "y"
|
||||||
up: "up,k"
|
up: "up,k"
|
||||||
@@ -45,10 +47,10 @@ keybindings:
|
|||||||
right: "right,l"
|
right: "right,l"
|
||||||
goto_top: "g"
|
goto_top: "g"
|
||||||
goto_bottom: "G,end"
|
goto_bottom: "G,end"
|
||||||
scroll_up: "pgup"
|
scroll_up: "pgup,ctrl+u"
|
||||||
scroll_down: "pgdown"
|
scroll_down: "pgdown,ctrl+d"
|
||||||
prev_page: "["
|
prev_page: "[,ctrl+i"
|
||||||
next_page: "]"
|
next_page: "],ctrl+o"
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
toggle_intercept: "i"
|
toggle_intercept: "i"
|
||||||
@@ -59,7 +61,7 @@ keybindings:
|
|||||||
drop_all: "D"
|
drop_all: "D"
|
||||||
edit: "e,enter"
|
edit: "e,enter"
|
||||||
edit_external: "E"
|
edit_external: "E"
|
||||||
undo_edits: "ctrl+z"
|
undo_edits: "u,ctrl+z"
|
||||||
|
|
||||||
history:
|
history:
|
||||||
delete_entry: "x"
|
delete_entry: "x"
|
||||||
@@ -77,15 +79,19 @@ keybindings:
|
|||||||
send: "s, enter"
|
send: "s, enter"
|
||||||
edit: "e"
|
edit: "e"
|
||||||
edit_external: "E"
|
edit_external: "E"
|
||||||
undo_edits: "ctrl+z"
|
undo_edits: "u,ctrl+z"
|
||||||
delete_entry: "x"
|
delete_entry: "x"
|
||||||
delete_all: "X"
|
delete_all: "X"
|
||||||
|
filter: "/"
|
||||||
|
flag: "m"
|
||||||
|
|
||||||
diff:
|
diff:
|
||||||
clear: "x"
|
clear: "x"
|
||||||
|
|
||||||
findings:
|
findings:
|
||||||
dismiss: "x"
|
dismiss: "x"
|
||||||
|
dismiss_all: "X"
|
||||||
|
flag: "m"
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
toggle: "space"
|
toggle: "space"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
type GlobalKeys struct {
|
type GlobalKeys struct {
|
||||||
Quit string `mapstructure:"quit"`
|
Quit string `mapstructure:"quit"`
|
||||||
|
Escape string `mapstructure:"escape"`
|
||||||
OpenLogs string `mapstructure:"open_logs"`
|
OpenLogs string `mapstructure:"open_logs"`
|
||||||
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
||||||
Help string `mapstructure:"help"`
|
Help string `mapstructure:"help"`
|
||||||
@@ -55,6 +56,8 @@ type ReplayKeys struct {
|
|||||||
UndoEdits string `mapstructure:"undo_edits"`
|
UndoEdits string `mapstructure:"undo_edits"`
|
||||||
Delete string `mapstructure:"delete_entry"`
|
Delete string `mapstructure:"delete_entry"`
|
||||||
DeleteAll string `mapstructure:"delete_all"`
|
DeleteAll string `mapstructure:"delete_all"`
|
||||||
|
Filter string `mapstructure:"filter"`
|
||||||
|
Flag string `mapstructure:"flag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiffKeys struct {
|
type DiffKeys struct {
|
||||||
@@ -63,6 +66,8 @@ type DiffKeys struct {
|
|||||||
|
|
||||||
type FindingsKeys struct {
|
type FindingsKeys struct {
|
||||||
Dismiss string `mapstructure:"dismiss"`
|
Dismiss string `mapstructure:"dismiss"`
|
||||||
|
DismissAll string `mapstructure:"dismiss_all"`
|
||||||
|
Flag string `mapstructure:"flag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginsKeys struct {
|
type PluginsKeys struct {
|
||||||
|
|||||||
+19
-6
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -25,17 +26,25 @@ func Open(path string) (*DB, error) {
|
|||||||
conn.SetMaxOpenConns(1)
|
conn.SetMaxOpenConns(1)
|
||||||
d := &DB{conn: conn, path: path}
|
d := &DB{conn: conn, path: path}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
roConn, err := sql.Open("sqlite", path)
|
roConn, err := sql.Open("sqlite", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
roConn.Close()
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
|
if cerr := roConn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close roConn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
d.roConn = roConn
|
d.roConn = roConn
|
||||||
@@ -70,7 +79,8 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
|||||||
request_raw TEXT NOT NULL,
|
request_raw TEXT NOT NULL,
|
||||||
response_raw TEXT NOT NULL,
|
response_raw TEXT NOT NULL,
|
||||||
status_code INTEGER NOT NULL,
|
status_code INTEGER NOT NULL,
|
||||||
error_msg TEXT NOT NULL
|
error_msg TEXT NOT NULL,
|
||||||
|
flagged INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS findings (
|
CREATE TABLE IF NOT EXISTS findings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -80,6 +90,7 @@ CREATE TABLE IF NOT EXISTS findings (
|
|||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
severity TEXT NOT NULL DEFAULT 'info',
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
dismissed INTEGER NOT NULL DEFAULT 0,
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
flagged INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
UNIQUE(plugin_name, dedup_key)
|
UNIQUE(plugin_name, dedup_key)
|
||||||
);
|
);
|
||||||
@@ -114,6 +125,8 @@ func CountEntriesAt(path string) int {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var n int
|
var n int
|
||||||
conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n)
|
if err := conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
|
||||||
|
log.Printf("db: count entries: %v", err)
|
||||||
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
|
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
var tsErr error
|
||||||
|
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
|
||||||
|
if tsErr != nil {
|
||||||
|
log.Printf("db: parse timestamp %q: %v", ts, tsErr)
|
||||||
|
}
|
||||||
e.Flagged = flagged != 0
|
e.Flagged = flagged != 0
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,7 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
|||||||
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||||
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||||
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
||||||
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
|
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where) // #nosec G202 -- intentional: user-supplied WHERE clause on a PRAGMA query_only=ON connection
|
||||||
rows, err := d.roConn.Query(q)
|
rows, err := d.roConn.Query(q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+25
-2
@@ -11,6 +11,7 @@ type Finding struct {
|
|||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
Severity string
|
Severity string
|
||||||
|
Flagged bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
|||||||
|
|
||||||
func (d *DB) LoadFindings() ([]Finding, error) {
|
func (d *DB) LoadFindings() ([]Finding, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
|
`SELECT id, plugin_name, dedup_key, title, description, severity, flagged, created_at
|
||||||
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
|
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,9 +46,11 @@ func (d *DB) LoadFindings() ([]Finding, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f Finding
|
var f Finding
|
||||||
var ts string
|
var ts string
|
||||||
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil {
|
var flagged int
|
||||||
|
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &flagged, &ts); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
f.Flagged = flagged != 0
|
||||||
for _, layout := range findingTimeFormats {
|
for _, layout := range findingTimeFormats {
|
||||||
if t, err := time.Parse(layout, ts); err == nil {
|
if t, err := time.Parse(layout, ts); err == nil {
|
||||||
f.CreatedAt = t.Local()
|
f.CreatedAt = t.Local()
|
||||||
@@ -63,3 +66,23 @@ func (d *DB) DismissFinding(id int64) error {
|
|||||||
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
|
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ToggleFindingFlag(id int64) error {
|
||||||
|
_, err := d.conn.Exec(`UPDATE findings SET flagged = NOT flagged WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissAllFindings dismisses unflagged findings first. If none are unflagged
|
||||||
|
// (only flagged ones remain), it dismisses everything.
|
||||||
|
func (d *DB) DismissAllFindings() error {
|
||||||
|
var count int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM findings WHERE dismissed = 0 AND flagged = 0`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE flagged = 0`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
+36
-8
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,16 +17,17 @@ type ReplayEntry struct {
|
|||||||
ResponseRaw string
|
ResponseRaw string
|
||||||
StatusCode int
|
StatusCode int
|
||||||
ErrorMsg string
|
ErrorMsg string
|
||||||
|
Flagged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
|
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
|
||||||
res, err := d.conn.Exec(
|
res, err := d.conn.Exec(
|
||||||
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg)
|
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
e.Timestamp.UTC().Format(time.RFC3339),
|
e.Timestamp.UTC().Format(time.RFC3339),
|
||||||
e.Scheme, e.Host, e.Path, e.Method,
|
e.Scheme, e.Host, e.Path, e.Method,
|
||||||
e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
|
e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
|
||||||
e.StatusCode, e.ErrorMsg,
|
e.StatusCode, e.ErrorMsg, e.Flagged,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -35,15 +37,15 @@ func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
|
|||||||
|
|
||||||
func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
|
func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
|
||||||
_, err := d.conn.Exec(
|
_, err := d.conn.Exec(
|
||||||
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`,
|
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=?, flagged=? WHERE id=?`,
|
||||||
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID,
|
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.Flagged, e.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg
|
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged
|
||||||
FROM replay_entries ORDER BY id ASC`,
|
FROM replay_entries ORDER BY id ASC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,16 +57,27 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e ReplayEntry
|
var e ReplayEntry
|
||||||
var ts string
|
var ts string
|
||||||
|
var flagged int
|
||||||
if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
|
if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
|
||||||
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
|
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg, &flagged); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
var tsErr error
|
||||||
|
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
|
||||||
|
if tsErr != nil {
|
||||||
|
log.Printf("db: parse replay timestamp %q: %v", ts, tsErr)
|
||||||
|
}
|
||||||
|
e.Flagged = flagged != 0
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ToggleReplayFlag(id int64) error {
|
||||||
|
_, err := d.conn.Exec(`UPDATE replay_entries SET flagged = NOT flagged WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) DeleteReplayEntry(id int64) error {
|
func (d *DB) DeleteReplayEntry(id int64) error {
|
||||||
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
|
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
@@ -74,3 +87,18 @@ func (d *DB) DeleteAllReplayEntries() error {
|
|||||||
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
|
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAllReplayEntriesExceptFlagged deletes all unflagged replay entries. If
|
||||||
|
// none are unflagged (only flagged ones remain), it deletes everything.
|
||||||
|
func (d *DB) DeleteAllReplayEntriesExceptFlagged() error {
|
||||||
|
var count int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM replay_entries WHERE flagged = 0`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE flagged = 0`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ func Init(cfg *config.Config) {
|
|||||||
Flag: " ",
|
Flag: " ",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
I = &Icons{}
|
I = &Icons{
|
||||||
|
Flag: "*",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ func FormatRawRequest(f *proxy.Flow) string {
|
|||||||
r := f.Request
|
r := f.Request
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
||||||
|
if r.URL.Host != "" {
|
||||||
|
fmt.Fprintf(&sb, "Host: %s\n", r.URL.Host)
|
||||||
|
}
|
||||||
for _, line := range util.SortedHeaderLines(r.Header) {
|
for _, line := range util.SortedHeaderLines(r.Header) {
|
||||||
sb.WriteString(line)
|
sb.WriteString(line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ import (
|
|||||||
|
|
||||||
type FindingsKeyMap struct {
|
type FindingsKeyMap struct {
|
||||||
Dismiss key.Binding
|
Dismiss key.Binding
|
||||||
|
DismissAll key.Binding
|
||||||
|
Flag key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
|
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
|
||||||
return FindingsKeyMap{
|
return FindingsKeyMap{
|
||||||
Dismiss: binding(cfg.Dismiss, "dismiss"),
|
Dismiss: binding(cfg.Dismiss, "dismiss"),
|
||||||
|
DismissAll: binding(cfg.DismissAll, "dismiss all"),
|
||||||
|
Flag: binding(cfg.Flag, "flag"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FindingsKeyMap) Bindings() []key.Binding {
|
func (f FindingsKeyMap) Bindings() []key.Binding {
|
||||||
return []key.Binding{f.Dismiss}
|
return []key.Binding{f.Flag, f.Dismiss, f.DismissAll}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
|||||||
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
||||||
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
||||||
Copy: binding(cfg.Copy, "copy..."),
|
Copy: binding(cfg.Copy, "copy..."),
|
||||||
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
|
Escape: binding(cfg.Escape, "cancel"),
|
||||||
SendToReplay: binding(cfg.SendToReplay, "send to replay"),
|
SendToReplay: binding(cfg.SendToReplay, "send to replay"),
|
||||||
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
||||||
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type ReplayKeyMap struct {
|
|||||||
UndoEdits key.Binding
|
UndoEdits key.Binding
|
||||||
Delete key.Binding
|
Delete key.Binding
|
||||||
DeleteAll key.Binding
|
DeleteAll key.Binding
|
||||||
|
Filter key.Binding
|
||||||
|
Flag key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
|
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
|
||||||
@@ -22,9 +24,11 @@ func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
|
|||||||
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
|
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
|
||||||
Delete: binding(cfg.Delete, "delete"),
|
Delete: binding(cfg.Delete, "delete"),
|
||||||
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
||||||
|
Filter: binding(cfg.Filter, "filter"),
|
||||||
|
Flag: binding(cfg.Flag, "flag"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r ReplayKeyMap) Bindings() []key.Binding {
|
func (r ReplayKeyMap) Bindings() []key.Binding {
|
||||||
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll}
|
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Flag, r.Delete, r.DeleteAll, r.Filter}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
|||||||
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||||
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
||||||
msg := L.CheckString(1)
|
msg := L.CheckString(1)
|
||||||
log.Printf("[plugin:%s] %s", p.Name, msg)
|
log.Printf("plugin %s: %s", p.Name, msg)
|
||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[plugin:%s] create_finding error: %v", p.Name, err)
|
log.Printf("plugin %s: create_finding error: %v", p.Name, err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
_ = inserted
|
_ = inserted
|
||||||
@@ -281,7 +281,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
c := exec.CommandContext(ctx, "sh", "-c", cmd)
|
c := exec.CommandContext(ctx, "sh", "-c", cmd) // #nosec G204 -- intentional: shell_pipe is a Lua plugin API for user-written scripts
|
||||||
c.Stdin = strings.NewReader(input)
|
c.Stdin = strings.NewReader(input)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
@@ -337,7 +337,7 @@ func luaTableString(t *lua.LTable, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
func pushRequest(L *lua.LState, p *Plugin, f *goproxy.Flow) *lua.LTable {
|
||||||
t := L.NewTable()
|
t := L.NewTable()
|
||||||
r := f.Request
|
r := f.Request
|
||||||
L.SetField(t, "method", lua.LString(r.Method))
|
L.SetField(t, "method", lua.LString(r.Method))
|
||||||
@@ -377,7 +377,7 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
|||||||
L.SetField(t, "set_url", L.NewFunction(func(L *lua.LState) int {
|
L.SetField(t, "set_url", L.NewFunction(func(L *lua.LState) int {
|
||||||
parsed, err := url.Parse(L.CheckString(2))
|
parsed, err := url.Parse(L.CheckString(2))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[plugin] set_url: %v", err)
|
log.Printf("plugin %s: set_url: %v", p.Name, err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
r.URL = parsed
|
r.URL = parsed
|
||||||
|
|||||||
@@ -395,25 +395,25 @@ func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lu
|
|||||||
|
|
||||||
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
||||||
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
||||||
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
||||||
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
||||||
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
|
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
|
|||||||
path: path,
|
path: path,
|
||||||
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
|
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path) // #nosec G304 -- path is filepath.Join(dbDir, "plugins.yaml"), hardcoded filename
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return pf, nil
|
return pf, nil
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,9 @@ func (pf *PluginsFile) register(id string, defaultEnabled bool, configText strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pf.data.Plugins[id] = entry
|
pf.data.Plugins[id] = entry
|
||||||
_ = pf.save()
|
if err := pf.save(); err != nil {
|
||||||
|
log.Printf("plugin %s: register save: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
||||||
CaRootPath: caPath,
|
CaRootPath: caPath,
|
||||||
Upstream: cfg.UpstreamProxy,
|
Upstream: cfg.UpstreamProxy,
|
||||||
|
SslInsecure: cfg.SslInsecure,
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := goproxy.NewProxy(opts)
|
p, err := goproxy.NewProxy(opts)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -37,11 +36,9 @@ func tickCmd() tea.Cmd {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sidebarEntries = pageRegistry
|
|
||||||
|
|
||||||
var pageShortcuts = func() map[string]page {
|
var pageShortcuts = func() map[string]page {
|
||||||
m := make(map[string]page, len(sidebarEntries))
|
m := make(map[string]page, len(pageRegistry))
|
||||||
for i, e := range sidebarEntries {
|
for i, e := range pageRegistry {
|
||||||
m[strconv.Itoa(i+1)] = e.id
|
m[strconv.Itoa(i+1)] = e.id
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
@@ -55,6 +52,8 @@ type Model struct {
|
|||||||
database *db.DB
|
database *db.DB
|
||||||
logFile *os.File
|
logFile *os.File
|
||||||
pluginManager *plugins.Manager
|
pluginManager *plugins.Manager
|
||||||
|
fatalErr error
|
||||||
|
logFileErr error
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -96,8 +95,8 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
|
|
||||||
d, err := db.Open(path)
|
d, err := db.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "db: %v\n", err)
|
m.fatalErr = err
|
||||||
os.Exit(1)
|
return m
|
||||||
}
|
}
|
||||||
m.database = d
|
m.database = d
|
||||||
broker.SetDB(d)
|
broker.SetDB(d)
|
||||||
@@ -130,14 +129,18 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
m.logFile = lf
|
m.logFile = lf
|
||||||
log.SetOutput(lf)
|
log.SetOutput(lf)
|
||||||
logrus.SetOutput(lf)
|
logrus.SetOutput(lf)
|
||||||
|
} else {
|
||||||
|
m.logFileErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) FatalErr() error { return m.fatalErr }
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
mgr := m.pluginManager
|
mgr := m.pluginManager
|
||||||
return tea.Batch(
|
cmds := []tea.Cmd{
|
||||||
intercept.WaitForRequest(m.broker),
|
intercept.WaitForRequest(m.broker),
|
||||||
intercept.WaitForResponse(m.broker),
|
intercept.WaitForResponse(m.broker),
|
||||||
tickCmd(),
|
tickCmd(),
|
||||||
@@ -146,5 +149,16 @@ func (m Model) Init() tea.Cmd {
|
|||||||
plugins.WaitForQuit(mgr),
|
plugins.WaitForQuit(mgr),
|
||||||
findingsUI.RefreshCmd(m.database),
|
findingsUI.RefreshCmd(m.database),
|
||||||
func() tea.Msg { mgr.RunOnStart(); return nil },
|
func() tea.Msg { mgr.RunOnStart(); return nil },
|
||||||
)
|
}
|
||||||
|
if m.logFileErr != nil {
|
||||||
|
err := m.logFileErr
|
||||||
|
cmds = append(cmds, func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Warning",
|
||||||
|
Body: "Could not open log file: " + err.Error(),
|
||||||
|
Kind: notificationsUI.KindWarning,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func (m *Model) renderSidebar() string {
|
|||||||
var items strings.Builder
|
var items strings.Builder
|
||||||
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
||||||
|
|
||||||
for i, entry := range sidebarEntries {
|
for i, entry := range pageRegistry {
|
||||||
selected := entry.id == m.page
|
selected := entry.id == m.page
|
||||||
badgeStyle, textStyle := badgeNormal, textNormal
|
badgeStyle, textStyle := badgeNormal, textNormal
|
||||||
if selected {
|
if selected {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ import (
|
|||||||
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
||||||
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
||||||
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -107,16 +107,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case proxyPkg.ErrMsg:
|
case proxyPkg.ErrMsg:
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
log.Printf("proxy error: %v", msg.Err)
|
log.Printf("proxy error: %v", msg.Err)
|
||||||
return m, tea.Batch(
|
m.fatalErr = msg.Err
|
||||||
func() tea.Msg {
|
return m, tea.Quit
|
||||||
return notificationsUI.NotificationMsg{
|
|
||||||
Title: "Proxy Error",
|
|
||||||
Body: msg.Err.Error(),
|
|
||||||
Kind: notificationsUI.KindError,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tea.Quit,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -170,23 +162,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
// ctrl+c always quits, even when a textarea is focused.
|
|
||||||
if msg.String() == "ctrl+c" {
|
|
||||||
m.pluginManager.RunOnQuit()
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
|
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
|
||||||
m.pluginManager.RunOnQuit()
|
m.pluginManager.RunOnQuit()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
|
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
|
||||||
editor := os.Getenv("EDITOR")
|
|
||||||
if editor == "" {
|
|
||||||
editor = "vi"
|
|
||||||
}
|
|
||||||
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
|
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
|
||||||
return m, tea.ExecProcess(exec.Command(editor, logPath), nil)
|
return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), logPath), nil) // #nosec G204 G702 -- editor from trusted config/$EDITOR, logPath is a fixed path
|
||||||
}
|
}
|
||||||
|
|
||||||
if !m.activeIsEditing() {
|
if !m.activeIsEditing() {
|
||||||
|
|||||||
+11
-9
@@ -6,25 +6,28 @@ import (
|
|||||||
ilovetui "github.com/anotherhadi/ilovetui"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (m Model) newView(content string) tea.View {
|
||||||
|
v := tea.NewView(content)
|
||||||
|
v.AltScreen = true
|
||||||
|
v.WindowTitle = "Spilltea: " + m.projectName
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
v := tea.NewView("")
|
return m.newView("")
|
||||||
v.AltScreen = true
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normal := m.renderNormal()
|
normal := m.renderNormal()
|
||||||
|
|
||||||
if m.copyAs.IsOpen() {
|
if m.copyAs.IsOpen() {
|
||||||
v := tea.NewView(m.copyAs.View(normal))
|
v := m.newView(m.copyAs.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.copy.IsOpen() {
|
if m.copy.IsOpen() {
|
||||||
v := tea.NewView(m.copy.View(normal))
|
v := m.newView(m.copy.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -34,8 +37,7 @@ func (m Model) View() tea.View {
|
|||||||
rendered = m.notifications.View(normal)
|
rendered = m.notifications.View(normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
v := tea.NewView(rendered)
|
v := m.newView(rendered)
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type parsedRequest struct {
|
|||||||
path string
|
path string
|
||||||
host string
|
host string
|
||||||
scheme string
|
scheme string
|
||||||
headers []header // garder header{key, value} pour compat locale
|
headers []header
|
||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.leftViewport.Width(), m.leftViewport.Height(),
|
m.leftViewport.Width(), m.leftViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.leftViewport.Width(), "<(^_^)>", "send two entries here to compare")),
|
||||||
)
|
)
|
||||||
m.leftViewport.SetContent(placeholder)
|
m.leftViewport.SetContent(placeholder)
|
||||||
m.rightViewport.SetContent("")
|
m.rightViewport.SetContent("")
|
||||||
@@ -312,7 +312,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.rightViewport.Width(), m.rightViewport.Height(),
|
m.rightViewport.Width(), m.rightViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.rightViewport.Width(), "(・3・)", "waiting for second entry…")),
|
||||||
)
|
)
|
||||||
m.rightViewport.SetContent(placeholder)
|
m.rightViewport.SetContent(placeholder)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ func readDoc(name string) string {
|
|||||||
|
|
||||||
var contentMarkdown = strings.Join([]string{
|
var contentMarkdown = strings.Join([]string{
|
||||||
readDoc("main.md"),
|
readDoc("main.md"),
|
||||||
readDoc("legal-disclaimer.md"),
|
|
||||||
readDoc("basics.md"),
|
|
||||||
readDoc("proxy.md"),
|
readDoc("proxy.md"),
|
||||||
readDoc("certificate.md"),
|
readDoc("certificate.md"),
|
||||||
|
readDoc("legal-disclaimer.md"),
|
||||||
|
readDoc("basics.md"),
|
||||||
readDoc("history.md"),
|
readDoc("history.md"),
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,9 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
if e.searching {
|
if e.searching {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, d.SearchReset):
|
case key.Matches(msg, g.Escape):
|
||||||
e.searching = false
|
e.searching = false
|
||||||
e.searchInput.Blur()
|
e.searchInput.Blur()
|
||||||
e.searchInput.SetValue("")
|
|
||||||
e.matches = nil
|
|
||||||
e.matchIndex = 0
|
|
||||||
e.SetSize(e.width, e.height)
|
e.SetSize(e.width, e.height)
|
||||||
case msg.String() == "enter":
|
case msg.String() == "enter":
|
||||||
e.searching = false
|
e.searching = false
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ type findingsKeyMap struct{ width int }
|
|||||||
func (findingsKeyMap) ShortHelp() []key.Binding {
|
func (findingsKeyMap) ShortHelp() []key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
f := keys.Keys.Findings
|
f := keys.Keys.Findings
|
||||||
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Copy, g.Help}
|
return []key.Binding{g.Up, g.Down, f.Flag, f.Dismiss, f.DismissAll, g.Copy, g.Help}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m findingsKeyMap) FullHelp() [][]key.Binding {
|
func (m findingsKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
|
case key.Matches(msg, f.Flag):
|
||||||
|
if len(m.findings) > 0 && m.database != nil {
|
||||||
|
if err := m.database.ToggleFindingFlag(m.findings[m.cursor].ID); err != nil {
|
||||||
|
log.Printf("findings: toggle flag: %v", err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, RefreshCmd(m.database)
|
||||||
|
}
|
||||||
case key.Matches(msg, f.Dismiss):
|
case key.Matches(msg, f.Dismiss):
|
||||||
if len(m.findings) > 0 && m.database != nil {
|
if len(m.findings) > 0 && m.database != nil {
|
||||||
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
|
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
|
||||||
@@ -80,6 +88,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, RefreshCmd(m.database)
|
return m, RefreshCmd(m.database)
|
||||||
}
|
}
|
||||||
|
case key.Matches(msg, f.DismissAll):
|
||||||
|
if m.database != nil {
|
||||||
|
if err := m.database.DismissAllFindings(); err != nil {
|
||||||
|
log.Printf("findings: dismiss all: %v", err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, RefreshCmd(m.database)
|
||||||
|
}
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
util.ScrollViewport(&m.bodyViewport, -1)
|
util.ScrollViewport(&m.bodyViewport, -1)
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(҂◡_◡) ᕤ", "no findings")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +66,10 @@ func (m *Model) renderList() string {
|
|||||||
sevStyle := style.SeverityStyle(f.Severity)
|
sevStyle := style.SeverityStyle(f.Severity)
|
||||||
sevLabel := sevStyle.Width(8).Render(f.Severity)
|
sevLabel := sevStyle.Width(8).Render(f.Severity)
|
||||||
ts := f.CreatedAt.Format("15:04:05")
|
ts := f.CreatedAt.Format("15:04:05")
|
||||||
|
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1
|
const fixedW = 2 + 2 + 8 + 1 + 8 + 1 + 10 + 1
|
||||||
titleW := w - fixedW
|
titleW := w - fixedW
|
||||||
if titleW < 0 {
|
if titleW < 0 {
|
||||||
titleW = 0
|
titleW = 0
|
||||||
@@ -79,8 +80,13 @@ func (m *Model) renderList() string {
|
|||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
||||||
|
flagStr := " "
|
||||||
|
if f.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
|
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
|
||||||
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
|
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
|
bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
|
||||||
@@ -90,8 +96,13 @@ func (m *Model) renderList() string {
|
|||||||
bg.Bold(true).Width(titleW).Render(f.Title),
|
bg.Bold(true).Width(titleW).Render(f.Title),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
flagStr := " "
|
||||||
|
if f.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
|
flagSt.Width(2).Render(flagStr),
|
||||||
sevLabel,
|
sevLabel,
|
||||||
" ",
|
" ",
|
||||||
pluginStr,
|
pluginStr,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package history
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
@@ -25,7 +26,10 @@ func LoadEntriesCmd(database *db.DB) tea.Cmd {
|
|||||||
if database == nil {
|
if database == nil {
|
||||||
return EntriesLoadedMsg{}
|
return EntriesLoadedMsg{}
|
||||||
}
|
}
|
||||||
entries, _ := database.ListEntries()
|
entries, err := database.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("history: load entries: %v", err)
|
||||||
|
}
|
||||||
return EntriesLoadedMsg{Entries: entries}
|
return EntriesLoadedMsg{Entries: entries}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,14 +76,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case SearchResultMsg:
|
case SearchResultMsg:
|
||||||
|
var selectedID int64
|
||||||
|
if m.cursor >= 0 && m.cursor < len(m.entries) {
|
||||||
|
selectedID = m.entries[m.cursor].ID
|
||||||
|
}
|
||||||
m.entries = msg.Entries
|
m.entries = msg.Entries
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
|
if selectedID != 0 {
|
||||||
|
for i, e := range m.entries {
|
||||||
|
if e.ID == selectedID {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
m.searchErr = ""
|
m.searchErr = ""
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
if selectedID == 0 {
|
||||||
m.bodyViewport.SetYOffset(0)
|
m.bodyViewport.SetYOffset(0)
|
||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
}
|
||||||
if m.searchKind == searchKindSQL {
|
if m.searchKind == searchKindSQL {
|
||||||
m.acceptSearch()
|
m.acceptSearch()
|
||||||
}
|
}
|
||||||
@@ -101,7 +119,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
|
|
||||||
if m.searchKind != searchKindOff && !m.searchAccepted {
|
if m.searchKind != searchKindOff && !m.searchAccepted {
|
||||||
// Actively typing: only search navigation + accept/cancel.
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, g.Escape):
|
case key.Matches(msg, g.Escape):
|
||||||
return m, m.clearSearch()
|
return m, m.clearSearch()
|
||||||
@@ -112,24 +129,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.acceptSearch()
|
m.acceptSearch()
|
||||||
|
|
||||||
case key.Matches(msg, g.Up):
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
m.refreshListViewport()
|
|
||||||
m.refreshBody()
|
|
||||||
m.bodyViewport.SetYOffset(0)
|
|
||||||
m.bodyViewport.SetXOffset(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, g.Down):
|
|
||||||
if m.cursor < len(m.entries)-1 {
|
|
||||||
m.cursor++
|
|
||||||
m.refreshListViewport()
|
|
||||||
m.refreshBody()
|
|
||||||
m.bodyViewport.SetYOffset(0)
|
|
||||||
m.bodyViewport.SetXOffset(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
@@ -234,7 +233,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, h.Flag):
|
case key.Matches(msg, h.Flag):
|
||||||
if len(m.entries) > 0 && m.database != nil {
|
if len(m.entries) > 0 && m.database != nil {
|
||||||
m.database.ToggleFlag(m.entries[m.cursor].ID)
|
if err := m.database.ToggleFlag(m.entries[m.cursor].ID); err != nil {
|
||||||
|
log.Printf("history: toggle flag: %v", err)
|
||||||
|
}
|
||||||
return m, m.RefreshCmd()
|
return m, m.RefreshCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +243,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
id := m.entries[m.cursor].ID
|
id := m.entries[m.cursor].ID
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
m.database.DeleteEntry(id)
|
if err := m.database.DeleteEntry(id); err != nil {
|
||||||
|
log.Printf("history: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, LoadEntriesCmd(m.database)
|
return m, LoadEntriesCmd(m.database)
|
||||||
}
|
}
|
||||||
@@ -261,10 +264,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if hasUnflagged && e.Flagged {
|
if hasUnflagged && e.Flagged {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.database.DeleteEntry(e.ID)
|
if err := m.database.DeleteEntry(e.ID); err != nil {
|
||||||
|
log.Printf("history: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.database.DeleteAllExceptFlagged()
|
if err := m.database.DeleteAllExceptFlagged(); err != nil {
|
||||||
|
log.Printf("history: delete all unflagged: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, m.clearSearch()
|
return m, m.clearSearch()
|
||||||
@@ -346,7 +353,7 @@ func (m *Model) refreshBody() {
|
|||||||
}
|
}
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
||||||
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored"))))
|
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(w, "(˘・_・˘)", "no response stored"))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if len(m.entries) == 0 {
|
if len(m.entries) == 0 {
|
||||||
msg := util.CenterLines("(⌐■_■)", "no history yet")
|
msg := util.EmptyState(m.listViewport.Width(), "(⌐■_■)", "no history yet")
|
||||||
if m.searchKind != searchKindOff {
|
if m.searchKind != searchKindOff {
|
||||||
msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
msg = util.EmptyState(m.listViewport.Width(), "ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
@@ -120,9 +120,6 @@ func (m *Model) renderList() string {
|
|||||||
flagStr := " "
|
flagStr := " "
|
||||||
if e.Flagged {
|
if e.Flagged {
|
||||||
flagStr = icons.I.Flag + " "
|
flagStr = icons.I.Flag + " "
|
||||||
if icons.I.Flag == "" {
|
|
||||||
flagStr = "★ "
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
@@ -139,9 +136,6 @@ func (m *Model) renderList() string {
|
|||||||
flagStr := " "
|
flagStr := " "
|
||||||
if e.Flagged {
|
if e.Flagged {
|
||||||
flagStr = icons.I.Flag + " "
|
flagStr = icons.I.Flag + " "
|
||||||
if icons.I.Flag == "" {
|
|
||||||
flagStr = "★ "
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
|
|||||||
@@ -318,13 +318,14 @@ func (m Model) renderHelpLine() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
if fs == list.Filtering {
|
if fs == list.Filtering {
|
||||||
parts = append(parts, item("enter", "apply filter"))
|
parts = append(parts, item("enter", "apply filter"))
|
||||||
parts = append(parts, item("esc", "cancel"))
|
parts = append(parts, item(escKey, "cancel"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, item("↑/↓", "navigate"))
|
parts = append(parts, item("↑/↓", "navigate"))
|
||||||
if fs == list.FilterApplied {
|
if fs == list.FilterApplied {
|
||||||
parts = append(parts, item("esc", "clear filter"))
|
parts = append(parts, item(escKey, "clear filter"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, binding(k.Filter))
|
parts = append(parts, binding(k.Filter))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package home
|
|||||||
import (
|
import (
|
||||||
crypto "crypto/rand"
|
crypto "crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -72,7 +73,8 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
|
|||||||
return m, m.nameInput.Focus()
|
return m, m.nameInput.Focus()
|
||||||
case kindTemp:
|
case kindTemp:
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
log.Printf("home: create temp dir: %v", err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -90,7 +92,9 @@ func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(item.path) // parent dir of data.db
|
dir := filepath.Dir(item.path) // parent dir of data.db
|
||||||
os.RemoveAll(dir)
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
log.Printf("home: remove project dir: %v", err)
|
||||||
|
}
|
||||||
idx := m.list.GlobalIndex()
|
idx := m.list.GlobalIndex()
|
||||||
m.list.RemoveItem(idx)
|
m.list.RemoveItem(idx)
|
||||||
if idx > 0 && idx >= len(m.list.Items()) {
|
if idx > 0 && idx >= len(m.list.Items()) {
|
||||||
@@ -113,7 +117,8 @@ func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = modeSelect
|
m.mode = modeSelect
|
||||||
m.nameInput.Blur()
|
m.nameInput.Blur()
|
||||||
dir := filepath.Join(m.projectDir, name)
|
dir := filepath.Join(m.projectDir, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
log.Printf("home: create project dir %q: %v", name, err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -147,14 +152,14 @@ func IsValidProjectName(s string) bool {
|
|||||||
func OpenProject(projectDir, name string) (*Project, error) {
|
func OpenProject(projectDir, name string) (*Project, error) {
|
||||||
if name == "tmp" || name == "temp" || name == "temporary" {
|
if name == "tmp" || name == "temp" || name == "temporary" {
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Join(projectDir, name)
|
dir := filepath.Join(projectDir, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -171,9 +176,11 @@ func initProjectFiles(dir string) {
|
|||||||
for _, name := range []string{"data.db", "logs.log"} {
|
for _, name := range []string{"data.db", "logs.log"} {
|
||||||
p := filepath.Join(dir, name)
|
p := filepath.Join(dir, name)
|
||||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
f, err := os.Create(p)
|
f, err := os.Create(p) // #nosec G304 -- p is filepath.Join(dir, hardcoded_name), no traversal possible
|
||||||
if err == nil {
|
if err == nil {
|
||||||
f.Close()
|
if cerr := f.Close(); cerr != nil {
|
||||||
|
log.Printf("home: close project file: %v", cerr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
ilovetui "github.com/anotherhadi/ilovetui"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ func (m Model) View() tea.View {
|
|||||||
v := tea.NewView(content)
|
v := tea.NewView(content)
|
||||||
v.AltScreen = true
|
v.AltScreen = true
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
|
v.WindowTitle = "Spilltea"
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +71,8 @@ func (m Model) renderNamingPanel() string {
|
|||||||
Width(panelW).
|
Width(panelW).
|
||||||
Render(label + "\n" + inputLine)
|
Render(label + "\n" + inputLine)
|
||||||
|
|
||||||
hint := ilovetui.S.Faint.Render("[enter] confirm [esc] cancel")
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
|
hint := ilovetui.S.Faint.Render("[enter] confirm [" + escKey + "] cancel")
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(center(iw, panel))
|
sb.WriteString(center(iw, panel))
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ func parseRawRequest(content string, req *intercept.PendingRequest) {
|
|||||||
if parsed.Proto != "" {
|
if parsed.Proto != "" {
|
||||||
r.Proto = parsed.Proto
|
r.Proto = parsed.Proto
|
||||||
}
|
}
|
||||||
|
if parsed.Host != "" {
|
||||||
|
r.URL.Host = parsed.Host
|
||||||
|
}
|
||||||
r.Header = make(http.Header)
|
r.Header = make(http.Header)
|
||||||
for _, h := range parsed.Headers {
|
for _, h := range parsed.Headers {
|
||||||
|
if strings.EqualFold(h.Key, "host") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
r.Header.Set(h.Key, h.Value)
|
r.Header.Set(h.Key, h.Value)
|
||||||
}
|
}
|
||||||
if parsed.Body != "" {
|
if parsed.Body != "" {
|
||||||
|
|||||||
@@ -2,24 +2,17 @@ package intercept
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
)
|
)
|
||||||
|
|
||||||
type interceptKeyMap struct{ width int }
|
type interceptKeyMap struct{ width int }
|
||||||
|
|
||||||
func iconBinding(b key.Binding, icon string) key.Binding {
|
|
||||||
h := b.Help()
|
|
||||||
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (interceptKeyMap) ShortHelp() []key.Binding {
|
func (interceptKeyMap) ShortHelp() []key.Binding {
|
||||||
ic := keys.Keys.Intercept
|
ic := keys.Keys.Intercept
|
||||||
i := icons.I
|
|
||||||
return []key.Binding{
|
return []key.Binding{
|
||||||
iconBinding(ic.Forward, i.Forward),
|
ic.Forward,
|
||||||
iconBinding(ic.Drop, i.Drop),
|
ic.Drop,
|
||||||
iconBinding(ic.Edit, i.Edit),
|
ic.Edit,
|
||||||
keys.Keys.Global.Help,
|
keys.Keys.Global.Help,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,8 +287,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
|
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
|
||||||
onResponses := m.captureResponse && m.focusedPanel == panelResponses
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, keys.Keys.Global.Escape):
|
case key.Matches(msg, keys.Keys.Global.Escape):
|
||||||
m.saveCurrentEdit()
|
m.saveCurrentEdit()
|
||||||
@@ -296,19 +294,6 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
m.refreshBodyViewport()
|
m.refreshBodyViewport()
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
|
|
||||||
if onResponses {
|
|
||||||
if len(m.responseQueue) > 0 {
|
|
||||||
delete(m.pendingResponseEdits, 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(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.textarea, cmd = m.textarea.Update(msg)
|
m.textarea, cmd = m.textarea.Update(msg)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (m *Model) renderStatusBar() string {
|
|||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(。◕‿‿◕。)", "waiting for a request")))
|
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(。◕‿‿◕。)", "waiting for a request")))
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := util.PageBounds(m.pager, len(m.queue))
|
start, end := util.PageBounds(m.pager, len(m.queue))
|
||||||
@@ -149,7 +149,7 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
func (m *Model) renderResponseList() string {
|
func (m *Model) renderResponseList() string {
|
||||||
if len(m.responseQueue) == 0 {
|
if len(m.responseQueue) == 0 {
|
||||||
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(҂◡_◡)", "no response yet")))
|
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(҂◡_◡)", "no response yet")))
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 || m.manager == nil {
|
if m.width == 0 || m.manager == nil {
|
||||||
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded"))))
|
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.width, "(._.)~*.'", "no plugins loaded"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
|
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
|
||||||
@@ -127,9 +127,9 @@ func (m *Model) renderStatusBar() string {
|
|||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
if len(m.filtered) == 0 {
|
if len(m.filtered) == 0 {
|
||||||
msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
msg := util.EmptyState(m.listViewport.Width(), "(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
||||||
if m.filter != "" {
|
if m.filter != "" {
|
||||||
msg = util.CenterLines("= _ =", "no results")
|
msg = util.EmptyState(m.listViewport.Width(), "= _ =", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package replay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/help"
|
"charm.land/bubbles/v2/help"
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
"charm.land/bubbles/v2/paginator"
|
"charm.land/bubbles/v2/paginator"
|
||||||
"charm.land/bubbles/v2/textarea"
|
"charm.land/bubbles/v2/textarea"
|
||||||
|
"charm.land/bubbles/v2/textinput"
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
ilovetui "github.com/anotherhadi/ilovetui"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
@@ -31,6 +33,7 @@ type Entry struct {
|
|||||||
ResponseRaw string // filled after send
|
ResponseRaw string // filled after send
|
||||||
StatusCode int // 0 = not sent yet
|
StatusCode int // 0 = not sent yet
|
||||||
Sending bool
|
Sending bool
|
||||||
|
Flagged bool
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +46,18 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
entries []Entry
|
allEntries []Entry
|
||||||
|
entries []Entry // filtered view; equals allEntries when no filter
|
||||||
|
entryIndices []int // maps entries[i] -> allEntries[i]; nil when no filter
|
||||||
cursor int
|
cursor int
|
||||||
editing bool
|
editing bool
|
||||||
focusedPanel panel
|
focusedPanel panel
|
||||||
database *db.DB
|
database *db.DB
|
||||||
|
|
||||||
|
filterInput textinput.Model
|
||||||
|
filterActive bool
|
||||||
|
filterAccepted bool
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
requestViewport viewport.Model
|
requestViewport viewport.Model
|
||||||
responseViewport viewport.Model
|
responseViewport viewport.Model
|
||||||
@@ -63,11 +72,14 @@ type Model struct {
|
|||||||
func New() Model {
|
func New() Model {
|
||||||
ta := ilovetui.NewTextarea(false)
|
ta := ilovetui.NewTextarea(false)
|
||||||
ta.Blur()
|
ta.Blur()
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Prompt = ""
|
||||||
return Model{
|
return Model{
|
||||||
listViewport: ilovetui.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
requestViewport: ilovetui.NewViewport(),
|
requestViewport: ilovetui.NewViewport(),
|
||||||
responseViewport: ilovetui.NewViewport(),
|
responseViewport: ilovetui.NewViewport(),
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
|
filterInput: ti,
|
||||||
pager: ilovetui.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: ilovetui.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
@@ -75,7 +87,7 @@ func New() Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
func (m Model) IsEditing() bool { return m.editing }
|
func (m Model) IsEditing() bool { return m.editing || (m.filterActive && !m.filterAccepted) }
|
||||||
|
|
||||||
func (m Model) IsResponseFocused() bool {
|
func (m Model) IsResponseFocused() bool {
|
||||||
return m.focusedPanel == panelResponse
|
return m.focusedPanel == panelResponse
|
||||||
@@ -111,8 +123,9 @@ func (m *Model) SetDB(d *db.DB) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, dbe := range entries {
|
for _, dbe := range entries {
|
||||||
m.entries = append(m.entries, entryFromDB(dbe))
|
m.allEntries = append(m.allEntries, entryFromDB(dbe))
|
||||||
}
|
}
|
||||||
|
m.entries = m.allEntries
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
m.cursor = len(m.entries) - 1
|
m.cursor = len(m.entries) - 1
|
||||||
@@ -136,6 +149,7 @@ func entryFromDB(dbe db.ReplayEntry) Entry {
|
|||||||
RequestRaw: dbe.RequestRaw,
|
RequestRaw: dbe.RequestRaw,
|
||||||
ResponseRaw: dbe.ResponseRaw,
|
ResponseRaw: dbe.ResponseRaw,
|
||||||
StatusCode: dbe.StatusCode,
|
StatusCode: dbe.StatusCode,
|
||||||
|
Flagged: dbe.Flagged,
|
||||||
Err: err,
|
Err: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +162,7 @@ func (m *Model) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m *Model) recalcSizes() {
|
func (m *Model) recalcSizes() {
|
||||||
m.help.SetWidth(m.width - 2)
|
m.help.SetWidth(m.width - 2)
|
||||||
|
m.filterInput.SetWidth(m.width - 4)
|
||||||
|
|
||||||
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
@@ -194,12 +209,77 @@ func (m *Model) bodyHalfWidths() (left, right int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) currentAllIdx() int {
|
||||||
|
if m.entryIndices != nil && m.cursor < len(m.entryIndices) {
|
||||||
|
return m.entryIndices[m.cursor]
|
||||||
|
}
|
||||||
|
if m.cursor < len(m.allEntries) {
|
||||||
|
return m.cursor
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesFilter(e Entry, term string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(e.Host), term) ||
|
||||||
|
strings.Contains(strings.ToLower(e.Path), term) ||
|
||||||
|
strings.Contains(strings.ToLower(strings.TrimSpace(e.Method)), term)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) applyFilter(preferAllIdx int) {
|
||||||
|
term := strings.ToLower(m.filterInput.Value())
|
||||||
|
if !m.filterActive || term == "" {
|
||||||
|
m.entries = m.allEntries
|
||||||
|
m.entryIndices = nil
|
||||||
|
if preferAllIdx >= 0 && preferAllIdx < len(m.allEntries) {
|
||||||
|
m.cursor = preferAllIdx
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.entries = nil
|
||||||
|
m.entryIndices = nil
|
||||||
|
newCursor := 0
|
||||||
|
found := false
|
||||||
|
for i, e := range m.allEntries {
|
||||||
|
if matchesFilter(e, term) {
|
||||||
|
if i == preferAllIdx && !found {
|
||||||
|
newCursor = len(m.entries)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
m.entries = append(m.entries, e)
|
||||||
|
m.entryIndices = append(m.entryIndices, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
m.cursor = newCursor
|
||||||
|
} else {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.entries) == 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
} else if m.cursor >= len(m.entries) {
|
||||||
|
m.cursor = len(m.entries) - 1
|
||||||
|
}
|
||||||
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) clearFilter() {
|
||||||
|
prevAllIdx := m.currentAllIdx()
|
||||||
|
m.filterActive = false
|
||||||
|
m.filterAccepted = false
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.recalcSizes()
|
||||||
|
m.applyFilter(prevAllIdx)
|
||||||
|
}
|
||||||
|
|
||||||
type replayKeyMap struct{ width int }
|
type replayKeyMap struct{ width int }
|
||||||
|
|
||||||
func (replayKeyMap) ShortHelp() []key.Binding {
|
func (replayKeyMap) ShortHelp() []key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
r := keys.Keys.Replay
|
r := keys.Keys.Replay
|
||||||
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help}
|
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, r.Flag, r.Filter, g.Help}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m replayKeyMap) FullHelp() [][]key.Binding {
|
func (m replayKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
|||||||
+144
-42
@@ -5,9 +5,14 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"compress/zlib"
|
"compress/zlib"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -62,15 +67,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
entry.DBID = id
|
entry.DBID = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.entries = append(m.entries, entry)
|
m.allEntries = append(m.allEntries, entry)
|
||||||
m.cursor = len(m.entries) - 1
|
m.applyFilter(len(m.allEntries) - 1)
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
|
||||||
m.refreshListViewport()
|
|
||||||
m.refreshBody()
|
|
||||||
|
|
||||||
case sentMsg:
|
case sentMsg:
|
||||||
if msg.index >= 0 && msg.index < len(m.entries) {
|
allIdx := msg.index
|
||||||
e := &m.entries[msg.index]
|
if allIdx >= 0 && allIdx < len(m.allEntries) {
|
||||||
|
e := m.allEntries[allIdx]
|
||||||
e.Sending = false
|
e.Sending = false
|
||||||
e.StatusCode = msg.statusCode
|
e.StatusCode = msg.statusCode
|
||||||
e.ResponseRaw = msg.responseRaw
|
e.ResponseRaw = msg.responseRaw
|
||||||
@@ -79,11 +82,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
e.ResponseRaw = "Error: " + msg.err.Error()
|
e.ResponseRaw = "Error: " + msg.err.Error()
|
||||||
}
|
}
|
||||||
if m.database != nil && e.DBID != 0 {
|
if m.database != nil && e.DBID != 0 {
|
||||||
m.database.UpdateReplayEntry(entryToDB(*e))
|
if err := m.database.UpdateReplayEntry(entryToDB(e)); err != nil {
|
||||||
|
log.Printf("replay: update entry: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.refreshListViewport()
|
m.allEntries[allIdx] = e
|
||||||
m.refreshBody()
|
}
|
||||||
|
prevAllIdx := m.currentAllIdx()
|
||||||
|
m.applyFilter(prevAllIdx)
|
||||||
|
|
||||||
case util.EditorFinishedMsg:
|
case util.EditorFinishedMsg:
|
||||||
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
|
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
|
||||||
@@ -96,24 +102,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.Button {
|
switch msg.Button {
|
||||||
case tea.MouseWheelUp:
|
case tea.MouseWheelUp:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.scrollFocusedViewportVertical(-1)
|
m.scrollFocusedViewportVertical(-1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelDown:
|
case tea.MouseWheelDown:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.scrollFocusedViewportVertical(1)
|
m.scrollFocusedViewportVertical(1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelLeft:
|
case tea.MouseWheelLeft:
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
case tea.MouseWheelRight:
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +132,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
r := keys.Keys.Replay
|
r := keys.Keys.Replay
|
||||||
|
|
||||||
|
if m.filterActive && !m.filterAccepted {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, g.Escape):
|
||||||
|
m.clearFilter()
|
||||||
|
case msg.String() == "enter":
|
||||||
|
m.filterAccepted = true
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.recalcSizes()
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||||
|
prevAllIdx := m.currentAllIdx()
|
||||||
|
m.applyFilter(prevAllIdx)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.filterActive && m.filterAccepted {
|
||||||
|
if key.Matches(msg, g.Escape) {
|
||||||
|
m.clearFilter()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, g.Up):
|
case key.Matches(msg, g.Up):
|
||||||
if m.focusedPanel == panelList {
|
if m.focusedPanel == panelList {
|
||||||
@@ -165,12 +193,13 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, r.Send):
|
case key.Matches(msg, r.Send):
|
||||||
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
|
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
|
||||||
m.entries[m.cursor].Sending = true
|
allIdx := m.currentAllIdx()
|
||||||
m.entries[m.cursor].ResponseRaw = ""
|
m.allEntries[allIdx].Sending = true
|
||||||
m.entries[m.cursor].Err = nil
|
m.allEntries[allIdx].ResponseRaw = ""
|
||||||
m.refreshListViewport()
|
m.allEntries[allIdx].Err = nil
|
||||||
m.refreshBody()
|
entry := m.allEntries[allIdx]
|
||||||
return m, sendCmd(m.entries[m.cursor], m.cursor)
|
m.applyFilter(allIdx)
|
||||||
|
return m, sendCmd(entry, allIdx)
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.Edit):
|
case key.Matches(msg, r.Edit):
|
||||||
@@ -212,30 +241,67 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.requestViewport.ScrollRight(6)
|
m.requestViewport.ScrollRight(6)
|
||||||
m.responseViewport.ScrollRight(6)
|
m.responseViewport.ScrollRight(6)
|
||||||
|
|
||||||
|
case key.Matches(msg, r.Flag):
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
allIdx := m.currentAllIdx()
|
||||||
|
if m.database != nil && m.allEntries[allIdx].DBID != 0 {
|
||||||
|
if err := m.database.ToggleReplayFlag(m.allEntries[allIdx].DBID); err != nil {
|
||||||
|
log.Printf("replay: toggle flag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.allEntries[allIdx].Flagged = !m.allEntries[allIdx].Flagged
|
||||||
|
m.applyFilter(allIdx)
|
||||||
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.Delete):
|
case key.Matches(msg, r.Delete):
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
e := m.entries[m.cursor]
|
allIdx := m.currentAllIdx()
|
||||||
|
e := m.allEntries[allIdx]
|
||||||
if m.database != nil && e.DBID != 0 {
|
if m.database != nil && e.DBID != 0 {
|
||||||
m.database.DeleteReplayEntry(e.DBID)
|
if err := m.database.DeleteReplayEntry(e.DBID); err != nil {
|
||||||
|
log.Printf("replay: delete entry: %v", err)
|
||||||
}
|
}
|
||||||
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
|
|
||||||
if m.cursor >= len(m.entries) && m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
}
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.allEntries = append(m.allEntries[:allIdx], m.allEntries[allIdx+1:]...)
|
||||||
m.refreshListViewport()
|
preferAllIdx := allIdx
|
||||||
m.refreshBody()
|
if preferAllIdx >= len(m.allEntries) {
|
||||||
|
preferAllIdx = len(m.allEntries) - 1
|
||||||
|
}
|
||||||
|
m.applyFilter(preferAllIdx)
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.DeleteAll):
|
case key.Matches(msg, r.DeleteAll):
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
m.database.DeleteAllReplayEntries()
|
if err := m.database.DeleteAllReplayEntriesExceptFlagged(); err != nil {
|
||||||
|
log.Printf("replay: delete all entries: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mirror the DB logic in memory: delete unflagged first; if none, delete all.
|
||||||
|
hasUnflagged := false
|
||||||
|
for _, e := range m.allEntries {
|
||||||
|
if !e.Flagged {
|
||||||
|
hasUnflagged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasUnflagged {
|
||||||
|
filtered := m.allEntries[:0]
|
||||||
|
for _, e := range m.allEntries {
|
||||||
|
if e.Flagged {
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.allEntries = filtered
|
||||||
|
} else {
|
||||||
|
m.allEntries = nil
|
||||||
}
|
}
|
||||||
m.entries = nil
|
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.pager.SetTotalPages(0)
|
m.filterActive = false
|
||||||
m.refreshListViewport()
|
m.filterAccepted = false
|
||||||
m.refreshBody()
|
m.filterInput.SetValue("")
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.recalcSizes()
|
||||||
|
m.applyFilter(-1)
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.GotoTop):
|
case key.Matches(msg, keys.Keys.Global.GotoTop):
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
@@ -276,6 +342,15 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, r.Filter):
|
||||||
|
if m.focusedPanel == panelList {
|
||||||
|
m.filterActive = true
|
||||||
|
m.filterAccepted = false
|
||||||
|
m.filterInput.Placeholder = "filter requests..."
|
||||||
|
m.filterInput.Focus()
|
||||||
|
m.recalcSizes()
|
||||||
|
}
|
||||||
|
|
||||||
case key.Matches(msg, g.Help):
|
case key.Matches(msg, g.Help):
|
||||||
m.help.ShowAll = !m.help.ShowAll
|
m.help.ShowAll = !m.help.ShowAll
|
||||||
m.recalcSizes()
|
m.recalcSizes()
|
||||||
@@ -320,6 +395,16 @@ func (m *Model) setFocusedViewport(vp viewport.Model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollBothViewports(left bool) {
|
||||||
|
if left {
|
||||||
|
m.requestViewport.ScrollLeft(6)
|
||||||
|
m.responseViewport.ScrollLeft(6)
|
||||||
|
} else {
|
||||||
|
m.requestViewport.ScrollRight(6)
|
||||||
|
m.responseViewport.ScrollRight(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) scrollFocusedViewportVertical(delta int) {
|
func (m *Model) scrollFocusedViewportVertical(delta int) {
|
||||||
vp := m.focusedViewport()
|
vp := m.focusedViewport()
|
||||||
vp.SetYOffset(vp.YOffset() + delta)
|
vp.SetYOffset(vp.YOffset() + delta)
|
||||||
@@ -351,11 +436,11 @@ func (m *Model) refreshBody() {
|
|||||||
m.requestViewport.SetXOffset(0)
|
m.requestViewport.SetXOffset(0)
|
||||||
|
|
||||||
if e.Sending {
|
if e.Sending {
|
||||||
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
|
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
|
||||||
} else if e.ResponseRaw != "" {
|
} else if e.ResponseRaw != "" {
|
||||||
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
||||||
} else {
|
} else {
|
||||||
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
|
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "( •_•)>⌐■", "press send to fire"))))
|
||||||
}
|
}
|
||||||
m.responseViewport.SetYOffset(0)
|
m.responseViewport.SetYOffset(0)
|
||||||
m.responseViewport.SetXOffset(0)
|
m.responseViewport.SetXOffset(0)
|
||||||
@@ -396,11 +481,27 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
}
|
}
|
||||||
req.Header = headers
|
req.Header = headers
|
||||||
|
|
||||||
|
tlsCfg := &tls.Config{InsecureSkipVerify: config.Global.App.SslInsecure} // #nosec G402 -- controlled by ssl_insecure config
|
||||||
|
if !config.Global.App.SslInsecure {
|
||||||
|
if certPool, err := x509.SystemCertPool(); err == nil {
|
||||||
|
caPath := filepath.Join(config.ExpandPath(config.Global.App.CertDir), "mitmproxy-ca-cert.pem")
|
||||||
|
if pem, err := os.ReadFile(caPath); err == nil {
|
||||||
|
certPool.AppendCertsFromPEM(pem)
|
||||||
|
}
|
||||||
|
tlsCfg.RootCAs = certPool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: tlsCfg,
|
||||||
|
}
|
||||||
|
if up := config.Global.App.UpstreamProxy; up != "" {
|
||||||
|
if proxyURL, err := url.Parse(up); err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: transport,
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
||||||
},
|
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
@@ -480,6 +581,7 @@ func entryToDB(e Entry) db.ReplayEntry {
|
|||||||
ResponseRaw: e.ResponseRaw,
|
ResponseRaw: e.ResponseRaw,
|
||||||
StatusCode: e.StatusCode,
|
StatusCode: e.StatusCode,
|
||||||
ErrorMsg: errMsg,
|
ErrorMsg: errMsg,
|
||||||
|
Flagged: e.Flagged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
ilovetui "github.com/anotherhadi/ilovetui"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
@@ -73,7 +74,18 @@ func (m *Model) renderResponsePanel(w, h int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width}))
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
if m.filterActive {
|
||||||
|
filterKey := keys.Keys.Replay.Filter.Help().Key
|
||||||
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
|
if m.filterAccepted {
|
||||||
|
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.filterInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(replayKeyMap{width: m.width})))
|
||||||
|
}
|
||||||
|
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
|
||||||
|
}
|
||||||
|
return pad.Render(m.help.View(replayKeyMap{width: m.width}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
@@ -81,7 +93,7 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
ilovetui.S.Faint.Render(util.CenterLines("(╥﹏╥)", "send a request from History or Intercept")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(╥﹏╥)", "send a request from History or Intercept")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,19 +106,25 @@ func (m *Model) renderList() string {
|
|||||||
selBg := ilovetui.S.Selection
|
selBg := ilovetui.S.Selection
|
||||||
|
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
const fixedW = 2 + 7 + 1 + 3 + 1
|
const fixedW = 2 + 2 + 7 + 1 + 3 + 1
|
||||||
hostPathW := w - fixedW
|
hostPathW := w - fixedW
|
||||||
if hostPathW < 0 {
|
if hostPathW < 0 {
|
||||||
hostPathW = 0
|
hostPathW = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
statusStr, statusSt := entryStatus(e)
|
statusStr, statusSt := entryStatus(e)
|
||||||
|
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
|
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
|
||||||
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
statusSt.Background(selBg).Render(statusStr),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
@@ -114,8 +132,13 @@ func (m *Model) renderList() string {
|
|||||||
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
|
flagSt.Width(2).Render(flagStr),
|
||||||
style.S.Method(e.Method).Render(e.Method),
|
style.S.Method(e.Method).Render(e.Method),
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type EditorFinishedMsg struct {
|
|||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveEditor() string {
|
func ResolveEditor() string {
|
||||||
editor := config.Global.App.ExternalEditor
|
editor := config.Global.App.ExternalEditor
|
||||||
if editor == "" {
|
if editor == "" {
|
||||||
editor = os.Getenv("EDITOR")
|
editor = os.Getenv("EDITOR")
|
||||||
@@ -29,14 +29,17 @@ func resolveEditor() string {
|
|||||||
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
||||||
f, err := os.CreateTemp("", "spilltea-*.http")
|
f, err := os.CreateTemp("", "spilltea-*.http")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("editor: create temp file: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tmpPath := f.Name()
|
tmpPath := f.Name()
|
||||||
if _, err := f.WriteString(content); err != nil {
|
if _, err := f.WriteString(content); err != nil {
|
||||||
log.Printf("editor: writing temp file: %v", err)
|
log.Printf("editor: writing temp file: %v", err)
|
||||||
}
|
}
|
||||||
f.Close()
|
if err := f.Close(); err != nil {
|
||||||
return tea.ExecProcess(exec.Command(resolveEditor(), tmpPath), func(err error) tea.Msg {
|
log.Printf("editor: closing temp file: %v", err)
|
||||||
|
}
|
||||||
|
return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp
|
||||||
defer os.Remove(tmpPath)
|
defer os.Remove(tmpPath)
|
||||||
return callback(tmpPath, err)
|
return callback(tmpPath, err)
|
||||||
})
|
})
|
||||||
@@ -47,7 +50,7 @@ func OpenExternalEditor(content string) tea.Cmd {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return EditorFinishedMsg{Err: err}
|
return EditorFinishedMsg{Err: err}
|
||||||
}
|
}
|
||||||
data, readErr := os.ReadFile(tmpPath)
|
data, readErr := os.ReadFile(tmpPath) // #nosec G304 -- tmpPath is from os.CreateTemp, controlled by this process
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return EditorFinishedMsg{Err: readErr}
|
return EditorFinishedMsg{Err: readErr}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ func Truncate(s string, max int) string {
|
|||||||
return s[:max-1] + "…"
|
return s[:max-1] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmptyState renders centered placeholder lines, word-wrapping to fit maxW.
|
||||||
|
func EmptyState(maxW int, lines ...string) string {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Width(maxW).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
// CenterLines centers each line horizontally relative to the longest one.
|
// CenterLines centers each line horizontally relative to the longest one.
|
||||||
func CenterLines(lines ...string) string {
|
func CenterLines(lines ...string) string {
|
||||||
maxWidth := 0
|
maxWidth := 0
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{pkgs}: let
|
||||||
|
defaultCertDir = "$HOME/.local/share/spilltea";
|
||||||
|
defaultHost = "127.0.0.1";
|
||||||
|
defaultPort = "8080";
|
||||||
|
in
|
||||||
|
pkgs.writeShellApplication {
|
||||||
|
name = "spilltea-browser";
|
||||||
|
runtimeInputs = with pkgs; [nss.tools librewolf netcat-gnu procps];
|
||||||
|
text = ''
|
||||||
|
CERT_DIR="${defaultCertDir}"
|
||||||
|
HOST="${defaultHost}"
|
||||||
|
PORT="${defaultPort}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--cert-dir) CERT_DIR="$2"; shift 2 ;;
|
||||||
|
--host) HOST="$2"; shift 2 ;;
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown argument: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
CERT_FILE="$CERT_DIR/mitmproxy-ca-cert.pem"
|
||||||
|
|
||||||
|
if [[ ! -f "$CERT_FILE" ]]; then
|
||||||
|
echo "Certificate not found at $CERT_FILE"
|
||||||
|
echo "Launch spilltea first, or use --cert-dir if you changed cert_dir in your config."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! pgrep -x spilltea > /dev/null 2>&1; then
|
||||||
|
echo "spilltea is not running. Launch spilltea first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! nc -z "$HOST" "$PORT" > /dev/null 2>&1; then
|
||||||
|
echo "Nothing is listening on $HOST:$PORT."
|
||||||
|
echo "Is spilltea configured on that port? Use --host and --port if you changed them in your config."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROFILE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$PROFILE"' EXIT
|
||||||
|
|
||||||
|
certutil -N -d sql:"$PROFILE" --empty-password
|
||||||
|
certutil -d sql:"$PROFILE" -A -n "spilltea" -t "CT,," -i "$CERT_FILE"
|
||||||
|
|
||||||
|
cat > "$PROFILE/user.js" <<EOF
|
||||||
|
user_pref("network.proxy.type", 1);
|
||||||
|
user_pref("network.proxy.http", "$HOST");
|
||||||
|
user_pref("network.proxy.http_port", $PORT);
|
||||||
|
user_pref("network.proxy.ssl", "$HOST");
|
||||||
|
user_pref("network.proxy.ssl_port", $PORT);
|
||||||
|
user_pref("network.proxy.no_proxies_on", "");
|
||||||
|
user_pref("network.stricttransportsecurity.preloadlist", false);
|
||||||
|
user_pref("dom.security.https_only_mode", false);
|
||||||
|
EOF
|
||||||
|
|
||||||
|
librewolf --profile "$PROFILE" --no-remote
|
||||||
|
'';
|
||||||
|
}
|
||||||
+12
-4
@@ -105,6 +105,10 @@ schema = 3
|
|||||||
version = 'v1.5.0'
|
version = 'v1.5.0'
|
||||||
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
|
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
|
||||||
|
|
||||||
|
[mod.'github.com/inconshreveable/mousetrap']
|
||||||
|
version = 'v1.1.0'
|
||||||
|
hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE='
|
||||||
|
|
||||||
[mod.'github.com/klauspost/compress']
|
[mod.'github.com/klauspost/compress']
|
||||||
version = 'v1.17.8'
|
version = 'v1.17.8'
|
||||||
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
|
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
|
||||||
@@ -173,6 +177,10 @@ schema = 3
|
|||||||
version = 'v1.10.0'
|
version = 'v1.10.0'
|
||||||
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
|
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cobra']
|
||||||
|
version = 'v1.10.2'
|
||||||
|
hash = 'sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k='
|
||||||
|
|
||||||
[mod.'github.com/spf13/pflag']
|
[mod.'github.com/spf13/pflag']
|
||||||
version = 'v1.0.10'
|
version = 'v1.0.10'
|
||||||
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
|
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
|
||||||
@@ -210,16 +218,16 @@ schema = 3
|
|||||||
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
||||||
|
|
||||||
[mod.'golang.org/x/net']
|
[mod.'golang.org/x/net']
|
||||||
version = 'v0.54.0'
|
version = 'v0.55.0'
|
||||||
hash = 'sha256-/EoIXzTQzK/yP/lxOyx0Z/bhns4FdPTIF4uyt4gIP80='
|
hash = 'sha256-Phi2mSmBGOJcvqPPAit3uqF3UP8SKRI9dHj6yTM3s5s='
|
||||||
|
|
||||||
[mod.'golang.org/x/sync']
|
[mod.'golang.org/x/sync']
|
||||||
version = 'v0.20.0'
|
version = 'v0.20.0'
|
||||||
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
|
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
|
||||||
|
|
||||||
[mod.'golang.org/x/sys']
|
[mod.'golang.org/x/sys']
|
||||||
version = 'v0.44.0'
|
version = 'v0.45.0'
|
||||||
hash = 'sha256-JDlj+PKsG6I6kjv5JyOUNreY51u5An0oZ5OZMHZSk+A='
|
hash = 'sha256-hkBoNazrDA67ER6sWhb+EKxx9nJ24+nz3zGy+zT5Hvw='
|
||||||
|
|
||||||
[mod.'golang.org/x/text']
|
[mod.'golang.org/x/text']
|
||||||
version = 'v0.37.0'
|
version = 'v0.37.0'
|
||||||
|
|||||||
+11
-1
@@ -2,13 +2,22 @@
|
|||||||
pkgs,
|
pkgs,
|
||||||
buildGoApplication,
|
buildGoApplication,
|
||||||
}: let
|
}: let
|
||||||
|
browser = import ./browser.nix {inherit pkgs;};
|
||||||
pname = "spilltea";
|
pname = "spilltea";
|
||||||
version = "0.0.6";
|
version = "0.0.8";
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
pkg = buildGoApplication {
|
pkg = buildGoApplication {
|
||||||
inherit pname version ldflags;
|
inherit pname version ldflags;
|
||||||
src = ../.;
|
src = ../.;
|
||||||
modules = ./gomod2nix.toml;
|
modules = ./gomod2nix.toml;
|
||||||
|
nativeBuildInputs = [pkgs.installShellFiles];
|
||||||
|
env.GOTOOLCHAIN = "local";
|
||||||
|
postInstall = ''
|
||||||
|
installShellCompletion --cmd spilltea \
|
||||||
|
--bash <($out/bin/spilltea completion bash) \
|
||||||
|
--zsh <($out/bin/spilltea completion zsh) \
|
||||||
|
--fish <($out/bin/spilltea completion fish)
|
||||||
|
'';
|
||||||
meta = with pkgs.lib; {
|
meta = with pkgs.lib; {
|
||||||
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
homepage = "https://github.com/anotherhadi/spilltea";
|
homepage = "https://github.com/anotherhadi/spilltea";
|
||||||
@@ -18,4 +27,5 @@
|
|||||||
in {
|
in {
|
||||||
"${pname}" = pkg;
|
"${pname}" = pkg;
|
||||||
default = pkg;
|
default = pkg;
|
||||||
|
browser = browser;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ in
|
|||||||
packages = with pkgs;
|
packages = with pkgs;
|
||||||
[
|
[
|
||||||
go
|
go
|
||||||
|
gosec
|
||||||
|
govulncheck
|
||||||
python3
|
python3
|
||||||
doctoc
|
doctoc
|
||||||
stylua
|
stylua
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ var PluginsFS embed.FS
|
|||||||
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
|
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
|
||||||
// files that already exist. Returns the number of files written.
|
// files that already exist. Returns the number of files written.
|
||||||
func InstallDefaultPlugins(dir string) (int, error) {
|
func InstallDefaultPlugins(dir string) (int, error) {
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return 0, fmt.Errorf("create plugins dir: %w", err)
|
return 0, fmt.Errorf("create plugins dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ func InstallDefaultPlugins(dir string) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
|
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
if err := os.WriteFile(dst, data, 0o600); err != nil {
|
||||||
return written, fmt.Errorf("write %s: %w", dst, err)
|
return written, fmt.Errorf("write %s: %w", dst, err)
|
||||||
}
|
}
|
||||||
written++
|
written++
|
||||||
|
|||||||
Reference in New Issue
Block a user