From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/ui/format.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 internal/ui/format.go (limited to 'internal/ui/format.go') diff --git a/internal/ui/format.go b/internal/ui/format.go new file mode 100644 index 0000000..8038d42 --- /dev/null +++ b/internal/ui/format.go @@ -0,0 +1,120 @@ +package ui + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/shiv/internal/store" +) + +// PathOf extracts the path+query portion from a full URL string. +func PathOf(rawURL string) string { + for _, prefix := range []string{"https://", "http://"} { + if strings.HasPrefix(rawURL, prefix) { + rest := rawURL[len(prefix):] + if slash := strings.Index(rest, "/"); slash >= 0 { + return rest[slash:] + } + return "/" + } + } + return rawURL +} + +// PrettyJSON re-indents JSON, returning the original bytes on any error. +func PrettyJSON(body []byte) []byte { + if len(body) == 0 { + return body + } + var buf bytes.Buffer + if err := json.Indent(&buf, body, "", " "); err != nil { + return body + } + return buf.Bytes() +} + +// hostHeader returns the value to use for the Host header in a reconstructed +// raw request. Default ports (80 for HTTP, 443 for HTTPS) are omitted per +// RFC 7230 §5.4. Non-standard ports are always included. +func hostHeader(tx store.Transaction) string { + if tx.Port == 0 || (tx.TLS && tx.Port == 443) || (!tx.TLS && tx.Port == 80) { + return tx.Host + } + return tx.Host + ":" + strconv.Itoa(tx.Port) +} + +// FormatStoreRequest serialises a Transaction into a raw HTTP/1.1 request string +// suitable for display in the UI or sending via Repeater/Intruder. +// +// The protocol is always written as HTTP/1.1 regardless of what was captured — +func FormatStoreRequest(tx store.Transaction) string { + proto := tx.Proto + if proto == "" { + proto = "HTTP/1.1" + } + + var b strings.Builder + fmt.Fprintf(&b, "%s %s %s\r\n", tx.Method, PathOf(tx.URL), proto) + fmt.Fprintf(&b, "Host: %s\r\n", hostHeader(tx)) + + keys := sortedKeys(tx.ReqHeaders) + for _, k := range keys { + // Skip Host — we already wrote a canonical one above. + if strings.EqualFold(k, "host") { + continue + } + for _, v := range tx.ReqHeaders[k] { + fmt.Fprintf(&b, "%s: %s\r\n", k, v) + } + } + b.WriteString("\r\n") + if len(tx.ReqBody) > 0 { + b.Write(PrettyJSON(tx.ReqBody)) + } + return b.String() +} + +// FormatStoreResponse serialises a Transaction into a raw HTTP response string +// suitable for display in the UI. +// +// The store already holds decompressed body bytes — we must NOT decompress again. +func FormatStoreResponse(tx store.Transaction) string { + var b strings.Builder + // Always render as HTTP/1.1; H2 pseudo-headers are not meaningful in raw text. + const proto = "HTTP/1.1" + fmt.Fprintf(&b, "%s %d\r\n", proto, tx.StatusCode) + + keys := sortedKeys(tx.RespHeaders) + for _, k := range keys { + for _, v := range tx.RespHeaders[k] { + fmt.Fprintf(&b, "%s: %s\r\n", k, v) + } + } + b.WriteString("\r\n") + + if len(tx.RespBody) > 0 { + // tx.RespBody is already decompressed by the proxy before storage. + // Do NOT call Decompress here — the body is plain bytes. + body := tx.RespBody + ct := tx.RespHeaders.Get("Content-Type") + if strings.Contains(ct, "application/json") { + b.Write(PrettyJSON(body)) + } else { + b.Write(body) + } + } + return b.String() +} + +func sortedKeys(h map[string][]string) []string { + keys := make([]string, 0, len(h)) + for k := range h { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} -- cgit v1.2.3