package replay import ( "bytes" "compress/gzip" "compress/zlib" "crypto/tls" "crypto/x509" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "strings" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/andybalholm/brotli" ilovetui "github.com/anotherhadi/ilovetui" "github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/style" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" "github.com/anotherhadi/spilltea/internal/util" "github.com/klauspost/compress/zstd" ) type sentMsg struct { index int responseRaw string statusCode int err error } func sendCmd(entry Entry, index int) tea.Cmd { return func() tea.Msg { raw, code, err := doSend(entry) return sentMsg{index: index, responseRaw: raw, statusCode: code, err: err} } } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd // Route non-key messages to textarea when editing so internal // textarea messages (e.g. clipboard paste) are handled correctly. if m.editing { if _, ok := msg.(tea.KeyPressMsg); !ok { var taCmd tea.Cmd m.textarea, taCmd = m.textarea.Update(msg) cmds = append(cmds, taCmd) } } switch msg := msg.(type) { case SendToReplayMsg: entry := entryFromMsg(msg) if m.database != nil { id, err := m.database.InsertReplayEntry(entryToDB(entry)) if err == nil { entry.DBID = id } } m.allEntries = append(m.allEntries, entry) m.applyFilter(len(m.allEntries) - 1) case sentMsg: allIdx := msg.index if allIdx >= 0 && allIdx < len(m.allEntries) { e := m.allEntries[allIdx] e.Sending = false e.StatusCode = msg.statusCode e.ResponseRaw = msg.responseRaw if msg.err != nil { e.Err = msg.err e.ResponseRaw = "Error: " + msg.err.Error() } if m.database != nil && e.DBID != 0 { if err := m.database.UpdateReplayEntry(entryToDB(e)); err != nil { log.Printf("replay: update entry: %v", err) } } m.allEntries[allIdx] = e } prevAllIdx := m.currentAllIdx() m.applyFilter(prevAllIdx) case util.EditorFinishedMsg: if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 { m.entries[m.cursor].RequestRaw = msg.Content m.refreshBody() } case tea.MouseWheelMsg: if !m.editing { switch msg.Button { case tea.MouseWheelUp: if msg.Mod.Contains(tea.ModShift) { m.scrollBothViewports(true) } else { m.scrollFocusedViewportVertical(-1) } case tea.MouseWheelDown: if msg.Mod.Contains(tea.ModShift) { m.scrollBothViewports(false) } else { m.scrollFocusedViewportVertical(1) } case tea.MouseWheelLeft: m.scrollBothViewports(true) case tea.MouseWheelRight: m.scrollBothViewports(false) } } case tea.KeyPressMsg: if m.editing { return m.updateEditMode(msg) } return m.updateNormalMode(msg) } return m, tea.Batch(cmds...) } func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { g := keys.Keys.Global 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 { case key.Matches(msg, g.Up): if m.focusedPanel == panelList { if m.cursor > 0 { m.cursor-- m.refreshListViewport() m.refreshBody() } } else { m.scrollFocusedViewportVertical(-1) } case key.Matches(msg, g.Down): if m.focusedPanel == panelList { if m.cursor < len(m.entries)-1 { m.cursor++ m.refreshListViewport() m.refreshBody() } } else { m.scrollFocusedViewportVertical(1) } case key.Matches(msg, g.CycleFocus): switch m.focusedPanel { case panelList: m.focusedPanel = panelRequest case panelRequest: m.focusedPanel = panelResponse default: m.focusedPanel = panelList } case key.Matches(msg, r.Send): if len(m.entries) > 0 && !m.entries[m.cursor].Sending { allIdx := m.currentAllIdx() m.allEntries[allIdx].Sending = true m.allEntries[allIdx].ResponseRaw = "" m.allEntries[allIdx].Err = nil entry := m.allEntries[allIdx] m.applyFilter(allIdx) return m, sendCmd(entry, allIdx) } case key.Matches(msg, r.Edit): if len(m.entries) > 0 { m.textarea.SetValue(m.entries[m.cursor].RequestRaw) m.editing = true m.textarea.Focus() } case key.Matches(msg, r.EditExt): if len(m.entries) > 0 { if m.focusedPanel == panelResponse { return m, util.OpenExternalEditorView(m.entries[m.cursor].ResponseRaw) } return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw) } case key.Matches(msg, r.UndoEdits): if len(m.entries) > 0 { m.entries[m.cursor].RequestRaw = m.entries[m.cursor].OriginalRaw m.refreshBody() } case key.Matches(msg, g.ScrollUp): vp := m.focusedViewport() util.ScrollViewport(&vp, -1) m.setFocusedViewport(vp) case key.Matches(msg, g.ScrollDown): vp := m.focusedViewport() util.ScrollViewport(&vp, 1) m.setFocusedViewport(vp) case key.Matches(msg, g.Left): m.requestViewport.ScrollLeft(6) m.responseViewport.ScrollLeft(6) case key.Matches(msg, g.Right): m.requestViewport.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): if len(m.entries) > 0 { allIdx := m.currentAllIdx() e := m.allEntries[allIdx] if m.database != nil && e.DBID != 0 { if err := m.database.DeleteReplayEntry(e.DBID); err != nil { log.Printf("replay: delete entry: %v", err) } } m.allEntries = append(m.allEntries[:allIdx], m.allEntries[allIdx+1:]...) preferAllIdx := allIdx if preferAllIdx >= len(m.allEntries) { preferAllIdx = len(m.allEntries) - 1 } m.applyFilter(preferAllIdx) } case key.Matches(msg, r.DeleteAll): if m.database != nil { 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.cursor = 0 m.filterActive = false m.filterAccepted = false m.filterInput.SetValue("") m.filterInput.Blur() m.recalcSizes() m.applyFilter(-1) case key.Matches(msg, keys.Keys.Global.GotoTop): m.cursor = 0 m.pager.Page = 0 m.refreshListViewport() m.refreshBody() case key.Matches(msg, keys.Keys.Global.GotoBottom): m.cursor = util.CursorGotoBottom(len(m.entries)) m.refreshListViewport() m.refreshBody() case key.Matches(msg, keys.Keys.Global.PrevPage): m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false) m.refreshListViewport() m.refreshBody() case key.Matches(msg, keys.Keys.Global.NextPage): m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true) m.refreshListViewport() m.refreshBody() case key.Matches(msg, g.SendToDiff): if len(m.entries) > 0 { e := m.entries[m.cursor] var raw, label string if m.focusedPanel == panelResponse { raw = e.ResponseRaw label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode)) } else { raw = e.RequestRaw label = e.Method + " " + e.Host + e.Path } if raw != "" { return m, func() tea.Msg { return diffUI.SendToDiffMsg{Label: label, Raw: raw} } } } case key.Matches(msg, 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): m.help.ShowAll = !m.help.ShowAll m.recalcSizes() } return m, nil } func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Keys.Global.Escape): if len(m.entries) > 0 { m.entries[m.cursor].RequestRaw = m.textarea.Value() } m.editing = false m.textarea.Blur() m.refreshBody() default: var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) return m, cmd } return m, nil } // focusedViewport returns the viewport that should receive scroll events. // When the list is focused, scroll targets the request panel. func (m *Model) focusedViewport() viewport.Model { if m.focusedPanel == panelResponse { return m.responseViewport } return m.requestViewport } func (m *Model) setFocusedViewport(vp viewport.Model) { if m.focusedPanel == panelResponse { m.responseViewport = vp } else { m.requestViewport = vp } } func (m *Model) 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) { vp := m.focusedViewport() vp.SetYOffset(vp.YOffset() + delta) m.setFocusedViewport(vp) } func (m *Model) refreshListViewport() { if m.pager.PerPage > 0 { if len(m.entries) == 0 { m.pager.Page = 0 m.pager.TotalPages = 0 } else { m.pager.Page = m.cursor / m.pager.PerPage m.pager.SetTotalPages(len(m.entries)) } } m.listViewport.SetContent(m.renderList()) } func (m *Model) refreshBody() { if len(m.entries) == 0 { m.requestViewport.SetContent("") m.responseViewport.SetContent("") return } e := m.entries[m.cursor] m.requestViewport.SetContent(style.HighlightHTTP(e.RequestRaw)) m.requestViewport.SetYOffset(0) m.requestViewport.SetXOffset(0) if e.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 != "" { m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw)) } else { 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.SetXOffset(0) } func doSend(entry Entry) (responseRaw string, statusCode int, err error) { parsed := util.ParseRawRequest(entry.RequestRaw) if parsed.Method == "" { return "", 0, fmt.Errorf("empty request") } host := parsed.Host if host == "" { host = entry.Host } headers := make(http.Header) for _, h := range parsed.Headers { if strings.EqualFold(h.Key, "host") { continue } headers.Add(h.Key, h.Value) } scheme := entry.Scheme if scheme == "" { scheme = "https" } urlStr := scheme + "://" + host + parsed.Path var bodyReader io.Reader if parsed.Body != "" { bodyReader = strings.NewReader(parsed.Body) } req, err := http.NewRequest(parsed.Method, urlStr, bodyReader) if err != nil { return "", 0, err } 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{ Timeout: 30 * time.Second, Transport: transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, } resp, err := client.Do(req) if err != nil { return "", 0, err } defer resp.Body.Close() limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit)) if enc := resp.Header.Get("Content-Encoding"); enc != "" { if decoded, decErr := decodeBody(enc, respBody); decErr == nil { respBody = decoded resp.Header.Del("Content-Encoding") resp.Header.Del("Content-Length") } } var sb strings.Builder fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)) for _, line := range util.SortedHeaderLines(resp.Header) { sb.WriteString(line) } sb.WriteString("\n") sb.Write(respBody) return sb.String(), resp.StatusCode, nil } func decodeBody(encoding string, body []byte) ([]byte, error) { switch strings.ToLower(strings.TrimSpace(encoding)) { case "gzip": r, err := gzip.NewReader(bytes.NewReader(body)) if err != nil { return nil, err } defer r.Close() return io.ReadAll(r) case "br": return io.ReadAll(brotli.NewReader(bytes.NewReader(body))) case "deflate": r, err := zlib.NewReader(bytes.NewReader(body)) if err != nil { return nil, err } defer r.Close() return io.ReadAll(r) case "zstd": r, err := zstd.NewReader(bytes.NewReader(body)) if err != nil { return nil, err } defer r.Close() return io.ReadAll(r) } return nil, fmt.Errorf("unsupported encoding: %s", encoding) } func entryToDB(e Entry) db.ReplayEntry { errMsg := "" if e.Err != nil { errMsg = e.Err.Error() } return db.ReplayEntry{ ID: e.DBID, Timestamp: time.Now(), Scheme: e.Scheme, Host: e.Host, Path: e.Path, Method: e.Method, OriginalRaw: e.OriginalRaw, RequestRaw: e.RequestRaw, ResponseRaw: e.ResponseRaw, StatusCode: e.StatusCode, ErrorMsg: errMsg, Flagged: e.Flagged, } } func entryFromMsg(msg SendToReplayMsg) Entry { parsed := util.ParseRawRequest(msg.RequestRaw) host := parsed.Host if host == "" { host = msg.Host } scheme := msg.Scheme if scheme == "" { scheme = util.InferScheme(host) } return Entry{ Scheme: scheme, Host: host, Path: parsed.Path, Method: parsed.Method, OriginalRaw: msg.RequestRaw, RequestRaw: msg.RequestRaw, } }