srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/mitmux/main.go36
-rw-r--r--cmd/mitmux/pretty.go80
2 files changed, 110 insertions, 6 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 55ab263..360b68d 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -122,9 +122,10 @@ type model struct {
query string // applied filter, "" means unfiltered
pendingNew int // live entries captured while a filter hides them
- viewport viewport.Model
- detail *ipc.EntryDetail
- activeTab detailTab
+ viewport viewport.Model
+ detail *ipc.EntryDetail
+ activeTab detailTab
+ prettyMode bool // display-only JSON reformatting of the response body
reqArea viTextarea
respView viewport.Model
@@ -577,7 +578,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.detail = msg.detail
m.activeTab = tabRequest
- m.viewport.SetContent(detailBody(m.detail, m.activeTab))
+ m.viewport.SetContent(m.detailContent())
m.viewport.GotoTop()
return m, nil
@@ -722,6 +723,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.enterIntruder(m.detail)
}
return m, nil
+ case "p":
+ m.prettyMode = !m.prettyMode
+ if m.detail != nil {
+ m.viewport.SetContent(m.detailContent())
+ }
+ return m, nil
case "tab":
if m.activeTab == tabRequest {
m.activeTab = tabResponse
@@ -729,7 +736,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.activeTab = tabRequest
}
if m.detail != nil {
- m.viewport.SetContent(detailBody(m.detail, m.activeTab))
+ m.viewport.SetContent(m.detailContent())
m.viewport.GotoTop()
}
return m, nil
@@ -1023,6 +1030,7 @@ func (m *model) helpView() string {
section("Detail view",
"tab switch request/response",
"↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)",
+ "p toggle pretty-printed JSON (response only, display-only)",
"r / i open in Repeater / Intruder",
"esc / q back to history",
)
@@ -1097,6 +1105,9 @@ func (m *model) detailView() string {
reqLabel := fmt.Sprintf("Request (%d bytes%s)", len(m.detail.RequestRaw), exactSuffix(m.detail.RequestExact))
respLabel := fmt.Sprintf("Response (%d bytes%s)", len(m.detail.ResponseRaw), exactSuffix(m.detail.ResponseExact))
+ if m.prettyMode {
+ respLabel += " [pretty]"
+ }
if m.activeTab == tabRequest {
b.WriteString(tabActive.Render(reqLabel))
b.WriteString(tabInactive.Render(respLabel))
@@ -1107,7 +1118,7 @@ func (m *model) detailView() string {
b.WriteString("\n")
b.WriteString(m.viewport.View())
b.WriteString("\n")
- b.WriteString(helpStyle.Render("tab switch · ↑/↓ scroll · r repeater · i intruder · esc back · q quit"))
+ b.WriteString(helpStyle.Render("tab switch · p pretty-print JSON · ↑/↓ scroll · r repeater · i intruder · esc back · q quit"))
return b.String()
}
@@ -1293,6 +1304,19 @@ func exactSuffix(exact bool) string {
return ", reconstructed"
}
+// detailContent is what actually gets shown in the detail viewport:
+// the raw response, or - when prettyMode is on and the body is JSON -
+// an indented rendering of it. Always falls back to raw on anything
+// that doesn't parse cleanly; the underlying bytes are never touched.
+func (m *model) detailContent() string {
+ if m.prettyMode && m.activeTab == tabResponse && m.detail != nil {
+ if pretty, ok := prettyResponse(m.detail.ResponseRaw); ok {
+ return pretty
+ }
+ }
+ return detailBody(m.detail, m.activeTab)
+}
+
func detailBody(d *ipc.EntryDetail, tab detailTab) string {
if d == nil {
return ""
diff --git a/cmd/mitmux/pretty.go b/cmd/mitmux/pretty.go
new file mode 100644
index 0000000..ede6809
--- /dev/null
+++ b/cmd/mitmux/pretty.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "encoding/json"
+ "io"
+ "net/http"
+ "sort"
+)
+
+// maxPrettyBytes bounds how much of a body gets loaded into memory just
+// to reformat it for reading - a display convenience, not the capture
+// path, so it doesn't need (and shouldn't risk) unbounded memory use.
+const maxPrettyBytes = 5 << 20 // 5 MiB
+
+// prettyResponse parses raw as an HTTP response and, if its body is
+// JSON, returns a version with the body indented for reading - chunked
+// transfer-encoding and gzip content-encoding are both handled via
+// net/http's own tested parsing rather than reimplemented here. This is
+// display-only: the caller always keeps the original raw bytes as what
+// was actually received/sent, and only substitutes this rendering into
+// a read-only view. Framing headers (Transfer-Encoding, Content-Encoding,
+// Content-Length) are dropped from the displayed header block since they
+// no longer describe the reformatted body and would be misleading.
+// Returns ok=false whenever formatting can't be done safely, in which
+// case the caller should show the raw bytes unchanged.
+func prettyResponse(raw []byte) (formatted string, ok bool) {
+ resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(raw)), nil)
+ if err != nil {
+ return "", false
+ }
+ defer resp.Body.Close()
+
+ var reader io.Reader = resp.Body
+ if resp.Header.Get("Content-Encoding") == "gzip" {
+ gz, err := gzip.NewReader(resp.Body)
+ if err != nil {
+ return "", false
+ }
+ defer gz.Close()
+ reader = gz
+ }
+
+ body, err := io.ReadAll(io.LimitReader(reader, maxPrettyBytes))
+ if err != nil || !json.Valid(body) {
+ return "", false
+ }
+ var pretty bytes.Buffer
+ if err := json.Indent(&pretty, body, "", " "); err != nil {
+ return "", false
+ }
+
+ var out bytes.Buffer
+ out.WriteString(resp.Proto + " " + resp.Status + "\r\n")
+ writeHeadersSorted(&out, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length")
+ out.WriteString("\r\n")
+ out.Write(pretty.Bytes())
+ return out.String(), true
+}
+
+func writeHeadersSorted(out *bytes.Buffer, h http.Header, omit ...string) {
+ skip := make(map[string]bool, len(omit))
+ for _, k := range omit {
+ skip[http.CanonicalHeaderKey(k)] = true
+ }
+ keys := make([]string, 0, len(h))
+ for k := range h {
+ if !skip[k] {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ for _, v := range h[k] {
+ out.WriteString(k + ": " + v + "\r\n")
+ }
+ }
+}