srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-19 23:17:00 +0200
committersrdusr <[email protected]>2026-05-19 23:17:00 +0200
commit1ac926d9936947e7b7e1cadaf2fcdeeda52b8c83 (patch)
tree10763b6ba5941760a77733814690f3f2c36e9630
parent3f2c38c4b62f3502dcb64e7b050f3de049518542 (diff)
downloadmitmux-1ac926d9936947e7b7e1cadaf2fcdeeda52b8c83.tar.gz
mitmux-1ac926d9936947e7b7e1cadaf2fcdeeda52b8c83.zip
Bulk export as HAR ('E' from the history list)
Single-entry export (previous commit) covers "attach this one to a report"; this covers the other common need - getting a batch of captured traffic into another tool. HAR 1.2 was chosen specifically for interop: Chrome/Firefox DevTools, Burp, Postman, and others can all import it, which a mitmux-specific format couldn't do. cmd/mitmux/har.go: harEntryFromDetail parses one entry's raw request/ response bytes into HAR's structured fields (method, url, httpVersion, headers, query string, status, body) via the same net/http parsing the proxy's own capture path and prettyResponse already use elsewhere in this codebase - reused, not reimplemented. A binary body is base64- encoded (HAR's "encoding" field) instead of passed through as a JSON string: encoding/json replaces invalid UTF-8 with U+FFFD by default, which would silently corrupt exactly the bodies (images, protobufs, ...) where byte-exactness matters. harDocFrom builds the full document from a batch of entries, skipping (not failing on) any that fail to parse - one malformed capture, e.g. a deliberately broken Repeater request, shouldn't block exporting everything else. 'E' from the history list exports the current view - the visible, filtered set if a search is active, everything otherwise, same "respects the active filter" behavior 'x' delete already has and explicitly the opposite of 'X' clear-all, which always targets everything regardless of filter. Reuses the same modal path-prompt state as single-entry export (exportEditing/exportInput), discriminated by a new exportBulk bool so both share one text-input widget and key-handling pattern rather than duplicating it. Since the list only ever holds Summary metadata (no raw bytes), exporting has to fetch each entry's full detail first - exportHAR does this as a sequence of Get calls inside a single tea.Cmd, which runs in bubbletea's own command goroutine, so the UI stays responsive through what's effectively a blocking round trip per entry. A failed fetch is skipped the same way a failed parse is; the status line reports the total skipped either way, not just entries written, so a partial export is visible rather than silently different from what was expected. Verified live in tmux against a running daemon: exported 3 real captured entries (including a binary PNG response) to a HAR file, validated the output is well-formed JSON with the correct HAR 1.2 structure, and confirmed the base64-encoded image entry decodes back to byte-identical PNG data (magic bytes checked). Separately applied a search filter (3 entries -> 2) and confirmed 'E' exported exactly the 2 filtered entries, not all 3 - the same filter-respecting behavior the status line and help text both claim. go build/vet/gofmt/test/mod tidy all clean.
-rw-r--r--PLAN.md30
-rw-r--r--README.md21
-rw-r--r--cmd/mitmux/har.go233
-rw-r--r--cmd/mitmux/har_test.go143
-rw-r--r--cmd/mitmux/main.go105
5 files changed, 522 insertions, 10 deletions
diff --git a/PLAN.md b/PLAN.md
index c1fc0b6..4864012 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -253,11 +253,31 @@ truncated, matching the Detail view's own labels) so the exported file
carries the same trust information the UI already shows, not a blanker
claim.
-Still open from the expanded "worth considering" list: bulk export
-(HAR, for interop with other tools), copy-as-curl, and scope/target
-filtering (to keep noise - trackers, CDNs, unrelated third-party
-hosts - out of history and search). All requested explicitly; none
-started yet.
+Shipped since: bulk export. `E` from the history list exports the
+current view (respecting an active search filter - explicitly the
+filtered set, not always everything, unlike `X` clear-all which is
+deliberately the opposite) as a HAR 1.2 file, chosen specifically for
+interop: DevTools, Burp, Postman, and others can all import it, which a
+mitmux-specific format couldn't do. Building it means parsing each
+entry's raw request/response bytes back into structured HAR fields
+(method, url, headers, status, body) via the same net/http parsing the
+capture path and prettyResponse already use - reused, not
+reimplemented. A binary body is base64-encoded (HAR's "encoding" field)
+rather than passed through as a JSON string, which would silently
+corrupt it: encoding/json replaces invalid UTF-8 with U+FFFD by
+default, exactly the failure mode that would quietly corrupt an
+exported image or protobuf body with no error anywhere. An entry that
+fails to fetch (daemon round trip) or parse (a deliberately malformed
+Repeater request, say) is skipped rather than aborting the whole
+export - the status line reports how many, so a partial export is
+visible, not silent.
+
+Still open from the expanded "worth considering" list: import (no path
+back in yet - HAR export was prioritized as the more common daily need,
+getting captured evidence OUT for a report or another tool, over
+bringing traffic IN), copy-as-curl, and scope/target filtering (to keep
+noise - trackers, CDNs, unrelated third-party hosts - out of history
+and search). All requested explicitly; none started yet.
Skipped deliberately (from the research, matches this tool's stated
scope): active/passive vulnerability scanning, plugin marketplace,
diff --git a/README.md b/README.md
index df744ba..f0bcc0c 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,7 @@ below is enough to get going.
| `c` | mark for comparison - press `c` on another entry to diff |
| `x` | delete the selected entry (asks `y`/`n` to confirm) |
| `X` | clear ALL history, not just the current search filter (asks `y`/`n` to confirm) |
+| `E` | export the current view (respects an active search filter) as a HAR file |
| `d` | Decoder |
| `/` | search |
| `m` | match-and-replace rules |
@@ -191,6 +192,26 @@ standard, URL-safe, padded, and unpadded variants in turn rather than
requiring you to know which one you're looking at. Single-transform
only - not chained/pipelined the way Burp's Decoder supports.
+### Export
+
+Two independent export paths, both a modal path-prompt (`enter` writes
+and confirms, `esc` cancels):
+
+- `e` from Detail view exports the single selected entry - request and
+ response raw bytes, plain text, exactly what Detail view already
+ shows. Each side is annotated when it isn't wire-exact (truncated or
+ reconstructed), matching Detail view's own labels.
+- `E` from the history list exports the current view - the visible,
+ filtered set if a search is active, everything otherwise - as one
+ [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) file, for
+ importing into Chrome/Firefox DevTools, Burp, Postman, or anything
+ else that reads HAR 1.2. A binary body (an image, say) is base64-
+ encoded in the HAR rather than corrupted as text. An entry that fails
+ to fetch or parse is skipped rather than aborting the whole export;
+ the status line reports how many, if any.
+
+There's no import yet (see `PLAN.md`).
+
### Search syntax
Plain text searches headers and bodies on both sides of the exchange.
diff --git a/cmd/mitmux/har.go b/cmd/mitmux/har.go
new file mode 100644
index 0000000..250ba5e
--- /dev/null
+++ b/cmd/mitmux/har.go
@@ -0,0 +1,233 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "unicode/utf8"
+
+ "mitmux/internal/ipc"
+)
+
+// HAR (HTTP Archive) 1.2 - a JSON format understood by Chrome/Firefox
+// DevTools, Burp, Postman, Wireshark, and others, chosen specifically
+// for interop: getting captured traffic INTO other tools, which a
+// mitmux-specific format couldn't do. Only the fields those tools
+// actually read are populated; optional per-spec fields with no
+// meaningful mitmux equivalent (page timings, HAR "pages", cache
+// tracking) are omitted rather than filled with placeholder zeros.
+type harDoc struct {
+ Log harLog `json:"log"`
+}
+
+type harLog struct {
+ Version string `json:"version"`
+ Creator harCreator `json:"creator"`
+ Entries []harEntry `json:"entries"`
+}
+
+type harCreator struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+}
+
+type harHeader struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+type harPostData struct {
+ MimeType string `json:"mimeType"`
+ Text string `json:"text"`
+ // Encoding is "base64" for a non-UTF-8 body - HAR's "text" field is
+ // a JSON string, which can't hold arbitrary bytes; encoding/json
+ // would otherwise silently replace invalid UTF-8 with U+FFFD,
+ // corrupting exactly the binary bodies (images, protobufs, ...)
+ // where byte-exactness matters most.
+ Encoding string `json:"encoding,omitempty"`
+}
+
+type harRequest struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ HTTPVersion string `json:"httpVersion"`
+ Headers []harHeader `json:"headers"`
+ QueryString []harHeader `json:"queryString"`
+ Cookies []harHeader `json:"cookies"`
+ HeadersSize int `json:"headersSize"`
+ BodySize int `json:"bodySize"`
+ PostData *harPostData `json:"postData,omitempty"`
+}
+
+type harContent struct {
+ Size int `json:"size"`
+ MimeType string `json:"mimeType"`
+ Text string `json:"text,omitempty"`
+ Encoding string `json:"encoding,omitempty"`
+}
+
+type harResponse struct {
+ Status int `json:"status"`
+ StatusText string `json:"statusText"`
+ HTTPVersion string `json:"httpVersion"`
+ Headers []harHeader `json:"headers"`
+ Cookies []harHeader `json:"cookies"`
+ Content harContent `json:"content"`
+ RedirectURL string `json:"redirectURL"`
+ HeadersSize int `json:"headersSize"`
+ BodySize int `json:"bodySize"`
+}
+
+type harTimings struct {
+ Send float64 `json:"send"`
+ Wait float64 `json:"wait"`
+ Receive float64 `json:"receive"`
+}
+
+type harEntry struct {
+ StartedDateTime string `json:"startedDateTime"`
+ Time float64 `json:"time"`
+ Request harRequest `json:"request"`
+ Response harResponse `json:"response"`
+ Cache struct{} `json:"cache"`
+ Timings harTimings `json:"timings"`
+}
+
+func harHeadersFrom(h http.Header) []harHeader {
+ out := make([]harHeader, 0, len(h))
+ for k, vv := range h {
+ for _, v := range vv {
+ out = append(out, harHeader{Name: k, Value: v})
+ }
+ }
+ return out
+}
+
+func harQueryFrom(rawQuery string) []harHeader {
+ values, err := url.ParseQuery(rawQuery)
+ if err != nil {
+ return nil
+ }
+ out := make([]harHeader, 0, len(values))
+ for k, vv := range values {
+ for _, v := range vv {
+ out = append(out, harHeader{Name: k, Value: v})
+ }
+ }
+ return out
+}
+
+// harBodyText returns body as HAR text content, base64-encoded (with
+// encoding="base64") if it isn't valid UTF-8 - see harPostData.Encoding.
+func harBodyText(body []byte) (text, encoding string) {
+ if len(body) == 0 {
+ return "", ""
+ }
+ if utf8.Valid(body) {
+ return string(body), ""
+ }
+ return base64.StdEncoding.EncodeToString(body), "base64"
+}
+
+// harEntryFromDetail parses d's raw request/response bytes (the same
+// net/http parsing prettyResponse and the proxy's own capture path
+// already use) into one HAR entry. scheme is needed separately because
+// an HTTP/1.1 request line only carries the origin-form path, not a
+// full URL.
+func harEntryFromDetail(d *ipc.EntryDetail) (harEntry, error) {
+ req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(d.RequestRaw)))
+ if err != nil {
+ return harEntry{}, fmt.Errorf("parse request: %w", err)
+ }
+ reqBody, _ := io.ReadAll(req.Body)
+ req.Body.Close()
+
+ entry := harEntry{
+ StartedDateTime: d.StartedAt.Format("2006-01-02T15:04:05.000Z07:00"),
+ Time: float64(d.Duration.Milliseconds()),
+ Request: harRequest{
+ Method: req.Method,
+ URL: fmt.Sprintf("%s://%s%s", d.Scheme, d.Host, req.URL.RequestURI()),
+ HTTPVersion: req.Proto,
+ Headers: harHeadersFrom(req.Header),
+ QueryString: harQueryFrom(req.URL.RawQuery),
+ Cookies: []harHeader{},
+ HeadersSize: -1,
+ BodySize: len(reqBody),
+ },
+ Timings: harTimings{Send: 0, Wait: float64(d.Duration.Milliseconds()), Receive: 0},
+ }
+ if len(reqBody) > 0 {
+ text, encoding := harBodyText(reqBody)
+ entry.Request.PostData = &harPostData{
+ MimeType: req.Header.Get("Content-Type"),
+ Text: text,
+ Encoding: encoding,
+ }
+ }
+
+ if len(d.ResponseRaw) == 0 {
+ // No response (dial/network error, or nothing came back) - HAR
+ // requires a response object, so fill in the minimum honest
+ // placeholder rather than omitting it or fabricating a status.
+ entry.Response = harResponse{HTTPVersion: "", Headers: []harHeader{}, Cookies: []harHeader{}, HeadersSize: -1, BodySize: -1}
+ return entry, nil
+ }
+
+ resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(d.ResponseRaw)), req)
+ if err != nil {
+ return harEntry{}, fmt.Errorf("parse response: %w", err)
+ }
+ respBody, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+
+ text, encoding := harBodyText(respBody)
+ entry.Response = harResponse{
+ Status: resp.StatusCode,
+ StatusText: http.StatusText(resp.StatusCode),
+ HTTPVersion: resp.Proto,
+ Headers: harHeadersFrom(resp.Header),
+ Cookies: []harHeader{},
+ Content: harContent{
+ Size: len(respBody),
+ MimeType: resp.Header.Get("Content-Type"),
+ Text: text,
+ Encoding: encoding,
+ },
+ HeadersSize: -1,
+ BodySize: len(respBody),
+ }
+ return entry, nil
+}
+
+// harDocFrom builds a full HAR document from details, skipping (not
+// failing on) any entry that fails to parse - one malformed capture
+// (a deliberately broken Repeater request, say) shouldn't block
+// exporting everything else. Returns the count skipped alongside the
+// document so the caller can report it.
+func harDocFrom(details []*ipc.EntryDetail) (harDoc, int) {
+ doc := harDoc{Log: harLog{
+ Version: "1.2",
+ Creator: harCreator{Name: "mitmux", Version: "1"},
+ Entries: make([]harEntry, 0, len(details)),
+ }}
+ skipped := 0
+ for _, d := range details {
+ e, err := harEntryFromDetail(d)
+ if err != nil {
+ skipped++
+ continue
+ }
+ doc.Log.Entries = append(doc.Log.Entries, e)
+ }
+ return doc, skipped
+}
+
+func harMarshal(doc harDoc) ([]byte, error) {
+ return json.MarshalIndent(doc, "", " ")
+}
diff --git a/cmd/mitmux/har_test.go b/cmd/mitmux/har_test.go
new file mode 100644
index 0000000..58bfb99
--- /dev/null
+++ b/cmd/mitmux/har_test.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "mitmux/internal/ipc"
+ "mitmux/internal/store"
+)
+
+func TestHarEntryFromDetailBasic(t *testing.T) {
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{
+ ID: 1, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a",
+ StatusCode: 200, StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Duration: 250 * time.Millisecond,
+ },
+ RequestRaw: []byte("GET /a?x=1 HTTP/1.1\r\nHost: example.com\r\nX-Foo: bar\r\n\r\n"),
+ ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"),
+ }
+ e, err := harEntryFromDetail(d)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if e.Request.Method != "GET" {
+ t.Errorf("method = %q, want GET", e.Request.Method)
+ }
+ if e.Request.URL != "https://example.com/a?x=1" {
+ t.Errorf("url = %q, want https://example.com/a?x=1", e.Request.URL)
+ }
+ if len(e.Request.QueryString) != 1 || e.Request.QueryString[0].Name != "x" || e.Request.QueryString[0].Value != "1" {
+ t.Errorf("queryString = %+v, want [{x 1}]", e.Request.QueryString)
+ }
+ foundHeader := false
+ for _, h := range e.Request.Headers {
+ if h.Name == "X-Foo" && h.Value == "bar" {
+ foundHeader = true
+ }
+ }
+ if !foundHeader {
+ t.Errorf("expected X-Foo: bar header, got %+v", e.Request.Headers)
+ }
+ if e.Response.Status != 200 {
+ t.Errorf("status = %d, want 200", e.Response.Status)
+ }
+ if e.Response.Content.Text != "hello" {
+ t.Errorf("response text = %q, want hello", e.Response.Content.Text)
+ }
+ if e.Response.Content.Encoding != "" {
+ t.Errorf("expected no encoding for plain text body, got %q", e.Response.Content.Encoding)
+ }
+}
+
+func TestHarEntryFromDetailBinaryBodyBase64(t *testing.T) {
+ binBody := []byte{0xff, 0xfe, 0x00, 0x01, 0x02}
+ raw := append([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 5\r\n\r\n"), binBody...)
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"},
+ RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"),
+ ResponseRaw: raw,
+ }
+ e, err := harEntryFromDetail(d)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if e.Response.Content.Encoding != "base64" {
+ t.Fatalf("expected base64 encoding for binary body, got %q", e.Response.Content.Encoding)
+ }
+ decoded, err := base64.StdEncoding.DecodeString(e.Response.Content.Text)
+ if err != nil {
+ t.Fatalf("failed to decode base64 text: %v", err)
+ }
+ if string(decoded) != string(binBody) {
+ t.Errorf("decoded body = %v, want %v", decoded, binBody)
+ }
+}
+
+func TestHarEntryFromDetailNoResponse(t *testing.T) {
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/", Error: "dial: connection refused"},
+ RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"),
+ }
+ e, err := harEntryFromDetail(d)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if e.Response.Status != 0 {
+ t.Errorf("expected status 0 for no response, got %d", e.Response.Status)
+ }
+}
+
+func TestHarEntryFromDetailMalformedRequest(t *testing.T) {
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"},
+ RequestRaw: []byte("not a valid http request at all"),
+ }
+ if _, err := harEntryFromDetail(d); err == nil {
+ t.Error("expected an error for a malformed request, got nil")
+ }
+}
+
+func TestHarDocFromSkipsMalformedEntries(t *testing.T) {
+ good := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"},
+ RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"),
+ ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"),
+ }
+ bad := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"},
+ RequestRaw: []byte("garbage"),
+ }
+ doc, skipped := harDocFrom([]*ipc.EntryDetail{good, bad})
+ if skipped != 1 {
+ t.Errorf("skipped = %d, want 1", skipped)
+ }
+ if len(doc.Log.Entries) != 1 {
+ t.Errorf("entries = %d, want 1", len(doc.Log.Entries))
+ }
+ if doc.Log.Version != "1.2" {
+ t.Errorf("version = %q, want 1.2", doc.Log.Version)
+ }
+}
+
+func TestHarMarshalProducesValidJSON(t *testing.T) {
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"},
+ RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"),
+ ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"),
+ }
+ doc, _ := harDocFrom([]*ipc.EntryDetail{d})
+ data, err := harMarshal(doc)
+ if err != nil {
+ t.Fatalf("harMarshal: %v", err)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(data, &out); err != nil {
+ t.Fatalf("output is not valid JSON: %v", err)
+ }
+ if _, ok := out["log"]; !ok {
+ t.Errorf("expected top-level \"log\" key, got %v", out)
+ }
+}
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index d4811dc..467f0d5 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -213,10 +213,12 @@ type model struct {
// exportEditing mirrors the grep-match/extract edit-buffer pattern:
// a modal textinput overlay for the destination path, enter writes
- // and confirms, esc cancels. Detail-view only (like pretty-print) -
- // exporting needs the full EntryDetail with raw bytes, which is
- // already loaded there.
+ // and confirms, esc cancels. Used from two places sharing the same
+ // buffer/state: Detail view (single entry, plain text - exportBulk
+ // false) and the history list ('E', the currently visible/filtered
+ // entries as one HAR file - exportBulk true).
exportEditing bool
+ exportBulk bool
exportInput textinput.Model
statusMsg string
@@ -393,6 +395,47 @@ func (m *model) clearHistory() tea.Cmd {
}
}
+type harExportedMsg struct {
+ path string
+ count int
+ skipped int
+ err error
+}
+
+// exportHAR fetches the full detail (raw bytes included - entries only
+// carries Summary metadata) for every given entry and writes them all
+// as one HAR file. Runs as a single tea.Cmd - the sequential Get calls
+// happen in the goroutine bubbletea already runs commands in, so this
+// doesn't block the UI even though it's a series of blocking round
+// trips, one per entry. A failed fetch for one entry is skipped rather
+// than aborting the whole export, same reasoning as harDocFrom skipping
+// an entry that fails to parse: one bad entry shouldn't cost you every
+// other one.
+func (m *model) exportHAR(entries []store.Summary, path string) tea.Cmd {
+ client := m.client
+ return func() tea.Msg {
+ details := make([]*ipc.EntryDetail, 0, len(entries))
+ fetchFailed := 0
+ for _, e := range entries {
+ d, err := client.Get(e.ID)
+ if err != nil {
+ fetchFailed++
+ continue
+ }
+ details = append(details, d)
+ }
+ doc, parseFailed := harDocFrom(details)
+ data, err := harMarshal(doc)
+ if err != nil {
+ return harExportedMsg{err: err}
+ }
+ if err := writeExportFile(path, string(data)); err != nil {
+ return harExportedMsg{err: err}
+ }
+ return harExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed}
+ }
+}
+
func (m *model) loadList() tea.Msg {
var entries []store.Summary
var err error
@@ -810,6 +853,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "history cleared"
return m, tea.Batch(m.loadList, m.loadStatus)
+ case harExportedMsg:
+ if msg.err != nil {
+ m.statusMsg = "export error: " + msg.err.Error()
+ return m, nil
+ }
+ m.statusMsg = fmt.Sprintf("exported %d entries to %s", msg.count, msg.path)
+ if msg.skipped > 0 {
+ m.statusMsg += fmt.Sprintf(" (%d skipped - failed to fetch or parse)", msg.skipped)
+ }
+ return m, nil
+
case detailLoadedMsg:
if msg.err != nil {
m.statusMsg = "get error: " + msg.err.Error()
@@ -921,6 +975,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "cancelled"
return m, nil
}
+ if m.exportEditing {
+ switch msg.String() {
+ case "enter":
+ path := m.exportInput.Value()
+ entries := m.entries
+ m.exportEditing = false
+ m.exportInput.Blur()
+ if len(entries) == 0 {
+ return m, nil
+ }
+ m.statusMsg = "exporting..."
+ return m, m.exportHAR(entries, path)
+ case "esc":
+ m.exportEditing = false
+ m.exportInput.Blur()
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ }
+ var cmd tea.Cmd
+ m.exportInput, cmd = m.exportInput.Update(msg)
+ return m, cmd
+ }
if m.searching {
switch msg.String() {
case "enter":
@@ -996,6 +1073,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.confirmPrompt = fmt.Sprintf("clear %s history entries (not just this view)? y/n", count)
m.confirmYes = func() tea.Cmd { return m.clearHistory() }
return m, nil
+ case "E":
+ if len(m.entries) == 0 {
+ return m, nil
+ }
+ m.exportEditing = true
+ m.exportBulk = true
+ m.exportInput.SetValue("mitmux-export.har")
+ m.exportInput.CursorEnd()
+ return m, m.exportInput.Focus()
case "d":
m.mode = viewDecoder
m.statusMsg = ""
@@ -1073,6 +1159,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "e":
if m.detail != nil {
m.exportEditing = true
+ m.exportBulk = false
m.exportInput.SetValue(fmt.Sprintf("mitmux-entry-%d.txt", m.detail.ID))
m.exportInput.CursorEnd()
return m, m.exportInput.Focus()
@@ -1554,6 +1641,7 @@ func (m *model) helpView() string {
"c mark for comparison, then press c on another entry to diff",
"x delete the selected entry (asks to confirm)",
"X clear ALL history, not just the current filter (asks to confirm)",
+ "E export the current view (respects an active search filter) as a HAR file",
"d decoder (URL/Base64/Hex/HTML encode/decode)",
"/ search: plain text, host:value, AND/OR/NOT,",
" status:404 / status:4xx / status:>=400,",
@@ -1637,6 +1725,13 @@ func (m *model) listView() string {
}
b.WriteString(m.table.View())
b.WriteString("\n")
+ if m.exportEditing {
+ b.WriteString("export HAR (current view) to: ")
+ b.WriteString(m.exportInput.View())
+ b.WriteString("\n")
+ b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit"))
+ return b.String()
+ }
if m.confirmPrompt != "" {
b.WriteString(statusStyle.Render(m.confirmPrompt))
b.WriteString("\n")
@@ -1644,9 +1739,9 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
- help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · / search · m rules · ? help · q quit"
+ help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR · / search · m rules · ? help · q quit"
if m.query != "" {
- help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · / search · esc clear filter · ? help · q quit"
+ help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR (this filter) · / search · esc clear filter · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()