mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
3463e51739
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
510 lines
12 KiB
Go
510 lines
12 KiB
Go
package replay
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"compress/zlib"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
"charm.land/bubbles/v2/viewport"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/anotherhadi/spilltea/internal/config"
|
|
"github.com/anotherhadi/spilltea/internal/db"
|
|
"github.com/anotherhadi/spilltea/internal/keys"
|
|
"github.com/anotherhadi/spilltea/internal/style"
|
|
"github.com/anotherhadi/spilltea/internal/util"
|
|
"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.entries = append(m.entries, entry)
|
|
m.cursor = len(m.entries) - 1
|
|
m.pager.SetTotalPages(len(m.entries))
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
|
|
case sentMsg:
|
|
if msg.index >= 0 && msg.index < len(m.entries) {
|
|
e := &m.entries[msg.index]
|
|
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 {
|
|
m.database.UpdateReplayEntry(entryToDB(*e))
|
|
}
|
|
}
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
|
|
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.requestViewport.ScrollLeft(6)
|
|
m.responseViewport.ScrollLeft(6)
|
|
} else {
|
|
m.scrollFocusedViewportVertical(-1)
|
|
}
|
|
case tea.MouseWheelDown:
|
|
if msg.Mod.Contains(tea.ModShift) {
|
|
m.requestViewport.ScrollRight(6)
|
|
m.responseViewport.ScrollRight(6)
|
|
} else {
|
|
m.scrollFocusedViewportVertical(1)
|
|
}
|
|
case tea.MouseWheelLeft:
|
|
m.requestViewport.ScrollLeft(6)
|
|
m.responseViewport.ScrollLeft(6)
|
|
case tea.MouseWheelRight:
|
|
m.requestViewport.ScrollRight(6)
|
|
m.responseViewport.ScrollRight(6)
|
|
}
|
|
}
|
|
|
|
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
|
|
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 {
|
|
m.entries[m.cursor].Sending = true
|
|
m.entries[m.cursor].ResponseRaw = ""
|
|
m.entries[m.cursor].Err = nil
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
return m, sendCmd(m.entries[m.cursor], m.cursor)
|
|
}
|
|
|
|
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 {
|
|
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()
|
|
step := vp.Height() / 2
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
vp.SetYOffset(vp.YOffset() - step)
|
|
m.setFocusedViewport(vp)
|
|
|
|
case key.Matches(msg, g.ScrollDown):
|
|
vp := m.focusedViewport()
|
|
step := vp.Height() / 2
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
vp.SetYOffset(vp.YOffset() + step)
|
|
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.Delete):
|
|
if len(m.entries) > 0 {
|
|
e := m.entries[m.cursor]
|
|
if m.database != nil && e.DBID != 0 {
|
|
m.database.DeleteReplayEntry(e.DBID)
|
|
}
|
|
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.refreshListViewport()
|
|
m.refreshBody()
|
|
}
|
|
|
|
case key.Matches(msg, r.DeleteAll):
|
|
if m.database != nil {
|
|
m.database.DeleteAllReplayEntries()
|
|
}
|
|
m.entries = nil
|
|
m.cursor = 0
|
|
m.pager.SetTotalPages(0)
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
|
|
case key.Matches(msg, keys.Keys.Global.GotoTop):
|
|
m.cursor = 0
|
|
m.pager.Page = 0
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
|
|
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
|
if len(m.entries) > 0 {
|
|
m.cursor = len(m.entries) - 1
|
|
m.pager.Page = m.pager.TotalPages - 1
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
}
|
|
|
|
case key.Matches(msg, keys.Keys.Global.PrevPage):
|
|
step := m.pager.PerPage
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.cursor -= step
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
|
|
case key.Matches(msg, keys.Keys.Global.NextPage):
|
|
step := m.pager.PerPage
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.cursor += step
|
|
if m.cursor >= len(m.entries) {
|
|
m.cursor = len(m.entries) - 1
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
}
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
|
|
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) 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, style.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "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, style.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
|
|
}
|
|
m.responseViewport.SetYOffset(0)
|
|
m.responseViewport.SetXOffset(0)
|
|
}
|
|
|
|
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|
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
|
|
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
},
|
|
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,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|