mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Add app analyzing/debugging docs and scripts (#174)
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Interaction struct {
|
||||
Type string `json:"type"` // ToNative, FromNative, ToNetwork
|
||||
Payload string `json:"payload"` // Raw string
|
||||
Parsed interface{} `json:"parsed"` // JSON if possible
|
||||
Timestamp string `json:"timestamp"` // If available
|
||||
ID string `json:"id"` // Internal ID if available
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run extract-log-interactions.go <log_file>")
|
||||
return
|
||||
}
|
||||
|
||||
logFile := os.Args[1]
|
||||
file, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Error opening file: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Regex patterns
|
||||
toNativeRegex := regexp.MustCompile(`To Native : "(.*)"`)
|
||||
fromNativeRegex := regexp.MustCompile(`From Native : "(.*)"`)
|
||||
toNetworkRegex := regexp.MustCompile(`To Network "(.*)"`)
|
||||
timestampRegex := regexp.MustCompile(`Js_Console_Msg: "(\d{2}:\d{2}:\d{2}\.\d{3})`)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lastTimestamp := ""
|
||||
|
||||
fmt.Println("### Bose SoundTouch Internal Log Interactions")
|
||||
fmt.Println("-------------------------------------------------")
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Track timestamp from console msgs
|
||||
if tsMatch := timestampRegex.FindStringSubmatch(line); len(tsMatch) > 1 {
|
||||
lastTimestamp = tsMatch[1]
|
||||
}
|
||||
|
||||
if match := toNetworkRegex.FindStringSubmatch(line); len(match) > 1 {
|
||||
printAppInteraction("TO NETWORK", match[1], lastTimestamp, "")
|
||||
} else if match := toNativeRegex.FindStringSubmatch(line); len(match) > 1 {
|
||||
payload := cleanPayload(match[1])
|
||||
id := extractID(payload)
|
||||
printAppInteraction("TO NATIVE", payload, lastTimestamp, id)
|
||||
} else if match := fromNativeRegex.FindStringSubmatch(line); len(match) > 1 {
|
||||
payload := cleanPayload(match[1])
|
||||
id := extractID(payload)
|
||||
printAppInteraction("FROM NATIVE", payload, lastTimestamp, id)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Printf("Error reading file: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanPayload(p string) string {
|
||||
// Remove escaped quotes and leading/trailing quotes
|
||||
p = strings.ReplaceAll(p, `\"`, `"`)
|
||||
return p
|
||||
}
|
||||
|
||||
func extractID(p string) string {
|
||||
// Try to find "id":X
|
||||
idRegex := regexp.MustCompile(`"id":\s*(\d+)`)
|
||||
match := idRegex.FindStringSubmatch(p)
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func printAppInteraction(typ, payload, ts, id string) {
|
||||
fmt.Printf("\n### %s", typ)
|
||||
if ts != "" {
|
||||
fmt.Printf(" [%s]", ts)
|
||||
}
|
||||
if id != "" {
|
||||
fmt.Printf(" (ID: %s)", id)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Try to prettify if it's JSON
|
||||
var obj interface{}
|
||||
if err := json.Unmarshal([]byte(payload), &obj); err == nil {
|
||||
pretty, _ := json.MarshalIndent(obj, "", " ")
|
||||
fmt.Printf("/*\n%s\n*/\n", string(pretty))
|
||||
} else {
|
||||
// Just print raw (might be XML or plain text)
|
||||
fmt.Printf("/*\n%s\n*/\n", payload)
|
||||
}
|
||||
fmt.Println("-------------------------------------------------")
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/google/gopacket/pcap"
|
||||
)
|
||||
|
||||
// This tool extracts WebSocket payloads and DNS queries from a .pcap file
|
||||
// and prints them in a format compatible with soundtouch-service interactions.
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run scripts/extract-ws.go <pcap_file> [filter_ip]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pcapFile := os.Args[1]
|
||||
filterIP := ""
|
||||
if len(os.Args) > 2 {
|
||||
filterIP = os.Args[2]
|
||||
fmt.Printf("[DEBUG] Filtering WebSocket for IP: %s\n", filterIP)
|
||||
}
|
||||
|
||||
handle, err := pcap.OpenOffline(pcapFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
fmt.Printf("[DEBUG] Reading file: %s\n", pcapFile)
|
||||
|
||||
// Prepare output files
|
||||
baseName := strings.TrimSuffix(pcapFile, filepath.Ext(pcapFile))
|
||||
wsFile, err := os.Create(baseName + ".ws.http")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer wsFile.Close()
|
||||
|
||||
dnsFile, err := os.Create(baseName + ".dns.txt")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer dnsFile.Close()
|
||||
|
||||
mdnsFile, err := os.Create(baseName + ".mdns.txt")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer mdnsFile.Close()
|
||||
|
||||
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
|
||||
|
||||
for packet := range packetSource.Packets() {
|
||||
// Handle DNS
|
||||
if dnsLayer := packet.Layer(layers.LayerTypeDNS); dnsLayer != nil {
|
||||
dns, _ := dnsLayer.(*layers.DNS)
|
||||
extractDNS(packet, dns, dnsFile, mdnsFile)
|
||||
}
|
||||
|
||||
// Handle SSDP (UDP Port 1900)
|
||||
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
|
||||
udp, _ := udpLayer.(*layers.UDP)
|
||||
if udp.DstPort == 1900 || udp.SrcPort == 1900 {
|
||||
extractSSDP(packet, udp, baseName+".ssdp.txt")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle WebSockets (TCP)
|
||||
if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
|
||||
tcp, _ := tcpLayer.(*layers.TCP)
|
||||
extractWebSocket(packet, tcp, filterIP, wsFile)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG] Extraction complete. Results written to:\n- %s\n- %s\n- %s\n- %s\n",
|
||||
baseName+".ws.http", baseName+".dns.txt", baseName+".mdns.txt", baseName+".ssdp.txt")
|
||||
}
|
||||
|
||||
func extractSSDP(packet gopacket.Packet, udp *layers.UDP, ssdpFilename string) {
|
||||
payload := string(udp.Payload)
|
||||
if !strings.Contains(payload, "HTTP/1.1") && !strings.Contains(payload, "NOTIFY") && !strings.Contains(payload, "M-SEARCH") {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(ssdpFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
timestamp := packet.Metadata().Timestamp.Format("2006-01-02 15:04:05.000")
|
||||
|
||||
fmt.Fprintf(f, "[%s] %s:%d -> %s:%d\n", timestamp, srcIP, udp.SrcPort, dstIP, udp.DstPort)
|
||||
fmt.Fprintf(f, "%s\n", strings.TrimSpace(payload))
|
||||
fmt.Fprintln(f, "-------------------------------------------------")
|
||||
}
|
||||
|
||||
func extractDNS(packet gopacket.Packet, dns *layers.DNS, dnsFile, mdnsFile *os.File) {
|
||||
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
|
||||
isMDNS := false
|
||||
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
|
||||
udp, _ := udpLayer.(*layers.UDP)
|
||||
if udp.DstPort == 5353 || udp.SrcPort == 5353 {
|
||||
isMDNS = true
|
||||
}
|
||||
}
|
||||
|
||||
out := dnsFile
|
||||
if isMDNS {
|
||||
out = mdnsFile
|
||||
}
|
||||
|
||||
timestamp := packet.Metadata().Timestamp.Format("2006-01-02 15:04:05.000")
|
||||
prefix := fmt.Sprintf("[%s] %s -> %s", timestamp, srcIP, dstIP)
|
||||
|
||||
for _, q := range dns.Questions {
|
||||
fmt.Fprintf(out, "%s | QUERY: %s (%s)\n", prefix, string(q.Name), q.Type)
|
||||
}
|
||||
for _, a := range dns.Answers {
|
||||
val := ""
|
||||
if a.IP != nil {
|
||||
val = a.IP.String()
|
||||
} else if len(a.CNAME) > 0 {
|
||||
val = string(a.CNAME)
|
||||
} else if len(a.PTR) > 0 {
|
||||
val = string(a.PTR)
|
||||
} else if len(a.TXTs) > 0 {
|
||||
var txts []string
|
||||
for _, t := range a.TXTs {
|
||||
txts = append(txts, string(t))
|
||||
}
|
||||
val = strings.Join(txts, " ")
|
||||
} else {
|
||||
val = fmt.Sprintf("Type: %s", a.Type)
|
||||
}
|
||||
fmt.Fprintf(out, "%s | ANSWER: %s -> %s\n", prefix, string(a.Name), val)
|
||||
}
|
||||
}
|
||||
|
||||
func extractWebSocket(packet gopacket.Packet, tcp *layers.TCP, filterIP string, wsFile *os.File) {
|
||||
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
|
||||
if filterIP != "" && srcIP != filterIP && dstIP != filterIP {
|
||||
return
|
||||
}
|
||||
|
||||
payload := tcp.Payload
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for WebSocket Frame (Sliding search)
|
||||
for i := 0; i < len(payload)-2; i++ {
|
||||
firstByte := payload[i]
|
||||
// Opcode 1 (Text) or 2 (Binary).
|
||||
if (firstByte&0xF0) == 0x80 && (firstByte&0x0F == 1 || firstByte&0x0F == 2) {
|
||||
secondByte := payload[i+1]
|
||||
mask := (secondByte & 0x80) != 0
|
||||
length := int(secondByte & 0x7F)
|
||||
offset := i + 2
|
||||
|
||||
if length == 126 {
|
||||
if len(payload) < offset+2 {
|
||||
continue
|
||||
}
|
||||
length = int(payload[offset])<<8 | int(payload[offset+1])
|
||||
offset += 2
|
||||
} else if length == 127 {
|
||||
if len(payload) < offset+8 {
|
||||
continue
|
||||
}
|
||||
length = int(payload[offset+4])<<24 | int(payload[offset+5])<<16 | int(payload[offset+6])<<8 | int(payload[offset+7])
|
||||
offset += 8
|
||||
}
|
||||
|
||||
if mask {
|
||||
if len(payload) < offset+4+length {
|
||||
continue
|
||||
}
|
||||
maskKey := payload[offset : offset+4]
|
||||
offset += 4
|
||||
data := make([]byte, length)
|
||||
for j := 0; j < length; j++ {
|
||||
data[j] = payload[offset+j] ^ maskKey[j%4]
|
||||
}
|
||||
printInteraction(packet, tcp, data, wsFile)
|
||||
i = offset + length - 1
|
||||
} else {
|
||||
if len(payload) >= offset+length {
|
||||
data := payload[offset : offset+length]
|
||||
printInteraction(packet, tcp, data, wsFile)
|
||||
i = offset + length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printInteraction(packet gopacket.Packet, tcp *layers.TCP, data []byte, out io.Writer) {
|
||||
src := packet.NetworkLayer().NetworkFlow().Src().String()
|
||||
dst := packet.NetworkLayer().NetworkFlow().Dst().String()
|
||||
|
||||
fmt.Fprintf(out, "### WebSocket Message: %s -> %s\n", src, dst)
|
||||
fmt.Fprintf(out, "// Timestamp: %s\n", packet.Metadata().Timestamp)
|
||||
fmt.Fprintf(out, "// Ports: %d -> %d\n", tcp.SrcPort, tcp.DstPort)
|
||||
fmt.Fprintln(out)
|
||||
|
||||
// Try to detect if it's GZIP
|
||||
content := ""
|
||||
if len(data) > 2 && data[0] == 0x1f && data[1] == 0x8b {
|
||||
fmt.Fprintln(out, "// [Detected GZIP compression]")
|
||||
content = decompressGzip(data)
|
||||
} else {
|
||||
content = string(data)
|
||||
}
|
||||
|
||||
fmt.Fprintln(out, "/*")
|
||||
fmt.Fprintln(out, strings.TrimSpace(content))
|
||||
fmt.Fprintln(out, "*/")
|
||||
fmt.Fprintln(out, "")
|
||||
fmt.Fprintln(out, "-------------------------------------------------")
|
||||
fmt.Fprintln(out, "")
|
||||
}
|
||||
|
||||
func decompressGzip(data []byte) string {
|
||||
b := bytes.NewBuffer(data)
|
||||
r, err := gzip.NewReader(b)
|
||||
if err != nil {
|
||||
return "[Error: Failed to create GZIP reader: " + err.Error() + "]"
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
res, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "[Error: Failed to decompress GZIP: " + err.Error() + "]"
|
||||
}
|
||||
return string(res)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# HTTP Body Diff Tool
|
||||
|
||||
This tool extracts and compares response bodies from two `.http` files.
|
||||
It supports XML and JSON normalization (pretty-printing) and automatically masks common "noisy" fields like timestamps to make actual differences easier to spot.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
go run scripts/http-diff/main.go <path/to/file1.http> <path/to/file2.http>
|
||||
```
|
||||
|
||||
To generate a side-by-side HTML report:
|
||||
|
||||
```bash
|
||||
go run scripts/http-diff/main.go --html report.html <path/to/file1.http> <path/to/file2.http>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Body Extraction**: Automatically finds the response body within the `/* ... */` comment block at the end of the file.
|
||||
- **Side-by-Side View**: Generates an HTML report with a clear side-by-side comparison.
|
||||
- **Normalization**:
|
||||
- Pretty-prints XML and JSON.
|
||||
- Trims whitespace from XML character data.
|
||||
- **Noise Reduction**:
|
||||
- Automatically replaces ISO 8601 timestamps with `[TIMESTAMP]`.
|
||||
- Masks specific XML tags: `<updatedOn>`, `<createdOn>`, `<lastModified>`, `<timestamp>`.
|
||||
- Masks specific JSON keys: `timestamp`, `updatedOn`, `createdOn`, `expires_at`.
|
||||
- **Diff Output**:
|
||||
- Displays a line-by-line diff.
|
||||
- Show context for unchanged parts (first and last two lines, with `...` in between).
|
||||
- Uses `+` for additions and `-` for deletions.
|
||||
@@ -0,0 +1,299 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
)
|
||||
|
||||
func main() {
|
||||
htmlOutput := flag.String("html", "", "Path to save HTML diff report")
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Usage: http-diff [options] <file1.http> <file2.http>")
|
||||
fmt.Println("Options:")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
file1 := args[0]
|
||||
file2 := args[1]
|
||||
|
||||
body1, err := extractBody(file1)
|
||||
if err != nil {
|
||||
fmt.Printf("Error extracting body from %s: %v\n", file1, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
body2, err := extractBody(file2)
|
||||
if err != nil {
|
||||
fmt.Printf("Error extracting body from %s: %v\n", file2, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
norm1 := normalize(body1)
|
||||
norm2 := normalize(body2)
|
||||
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(norm1, norm2, false)
|
||||
lineDiffs := dmp.DiffCleanupSemantic(diffs)
|
||||
|
||||
if *htmlOutput != "" {
|
||||
err := generateHTML(*htmlOutput, file1, file2, lineDiffs)
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating HTML: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("HTML report generated: %s\n", *htmlOutput)
|
||||
return
|
||||
}
|
||||
|
||||
// Custom line-by-line diff for better readability
|
||||
for _, diff := range lineDiffs {
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffInsert:
|
||||
lines := strings.Split(diff.Text, "\n")
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
fmt.Printf("+ %s\n", line)
|
||||
}
|
||||
}
|
||||
case diffmatchpatch.DiffDelete:
|
||||
lines := strings.Split(diff.Text, "\n")
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
fmt.Printf("- %s\n", line)
|
||||
}
|
||||
}
|
||||
case diffmatchpatch.DiffEqual:
|
||||
// Optionally skip unchanged lines or show context
|
||||
lines := strings.Split(diff.Text, "\n")
|
||||
// Filter out empty lines from splitting
|
||||
var cleanLines []string
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
cleanLines = append(cleanLines, l)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cleanLines) > 6 {
|
||||
fmt.Printf(" %s\n", cleanLines[0])
|
||||
fmt.Printf(" %s\n", cleanLines[1])
|
||||
fmt.Printf(" ...\n")
|
||||
fmt.Printf(" %s\n", cleanLines[len(cleanLines)-2])
|
||||
fmt.Printf(" %s\n", cleanLines[len(cleanLines)-1])
|
||||
} else {
|
||||
for _, line := range cleanLines {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractBody(path string) (string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use a non-greedy regex to find the LAST /* ... */ block which typically contains the body
|
||||
re := regexp.MustCompile(`(?s)/\*\s*(<\?xml.*?|\{.*?|\[.*?)\s*\*/`)
|
||||
matches := re.FindAllStringSubmatch(string(content), -1)
|
||||
if len(matches) > 0 {
|
||||
// Return the last match as it's more likely to be the response body
|
||||
lastMatch := matches[len(matches)-1]
|
||||
return strings.TrimSpace(lastMatch[1]), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find body in /* ... */ block")
|
||||
}
|
||||
|
||||
func normalize(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Mask common timestamps and changing fields
|
||||
timestampRegex := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})`)
|
||||
body = timestampRegex.ReplaceAllString(body, "[TIMESTAMP]")
|
||||
|
||||
// Mask account IDs if they are variable, but usually they match in these files.
|
||||
// Let's stick to timestamps for now.
|
||||
|
||||
// Try XML first
|
||||
if strings.HasPrefix(body, "<?xml") || strings.Contains(body, "<") {
|
||||
// For XML, let's also try to mask specific tags like <updatedOn> or <createdOn>
|
||||
tagsToMask := []string{"updatedOn", "createdOn", "lastModified", "timestamp"}
|
||||
for _, tag := range tagsToMask {
|
||||
re := regexp.MustCompile(fmt.Sprintf(`<%s>.*?</%s>`, tag, tag))
|
||||
body = re.ReplaceAllString(body, fmt.Sprintf("<%s>[MASKED]</%s>", tag, tag))
|
||||
}
|
||||
|
||||
// Also mask empty attributes that might be noisy, like displayName=""
|
||||
body = regexp.MustCompile(`\s+displayName=""`).ReplaceAllString(body, "")
|
||||
|
||||
var out bytes.Buffer
|
||||
decoder := xml.NewDecoder(strings.NewReader(body))
|
||||
encoder := xml.NewEncoder(&out)
|
||||
encoder.Indent("", " ")
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
// If it's not valid XML, maybe it's just a fragment, continue or return body
|
||||
break
|
||||
}
|
||||
// Trim whitespace from CharData to normalize
|
||||
if cd, ok := token.(xml.CharData); ok {
|
||||
token = xml.CharData(bytes.TrimSpace(cd))
|
||||
}
|
||||
err = encoder.EncodeToken(token)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
encoder.Flush()
|
||||
if out.Len() > 0 {
|
||||
return out.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Try JSON
|
||||
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
|
||||
var obj interface{}
|
||||
if err := json.Unmarshal([]byte(body), &obj); err == nil {
|
||||
// Mask some JSON fields if they are common
|
||||
maskJSON(obj)
|
||||
pretty, _ := json.MarshalIndent(obj, "", " ")
|
||||
return string(pretty)
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func maskJSON(data interface{}) {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, val := range v {
|
||||
if strings.Contains(strings.ToLower(k), "timestamp") || k == "updatedOn" || k == "createdOn" || k == "expires_at" {
|
||||
v[k] = "[MASKED]"
|
||||
} else {
|
||||
maskJSON(val)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
maskJSON(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateHTML(path, file1, file2 string, diffs []diffmatchpatch.Diff) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>HTTP Diff Report</title>
|
||||
<style>
|
||||
body { font-family: monospace; line-height: 1.2; background: #f8f9fa; color: #212529; padding: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; background: #fff; padding: 20px; border: 1px solid #dee2e6; border-radius: 4px; }
|
||||
.header { margin-bottom: 20px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
||||
.diff-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.diff-table td { vertical-align: top; padding: 2px 4px; border: 1px solid #eee; overflow-wrap: break-word; }
|
||||
.line-num { width: 40px; text-align: right; color: #999; background: #fdfdfd; user-select: none; }
|
||||
.diff-equal { background: #fff; }
|
||||
.diff-insert { background: #e6ffec; text-decoration: none; color: #1a7f37; }
|
||||
.diff-delete { background: #ffebe9; text-decoration: none; color: #cf222e; }
|
||||
.diff-change-marker { font-weight: bold; margin-right: 5px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.files { font-size: 0.9rem; color: #666; }
|
||||
pre { margin: 0; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>HTTP Response Body Diff</h1>
|
||||
<div class="files">
|
||||
Left: <strong>` + html.EscapeString(file1) + `</strong><br>
|
||||
Right: <strong>` + html.EscapeString(file2) + `</strong>
|
||||
</div>
|
||||
</div>
|
||||
<table class="diff-table">
|
||||
`)
|
||||
|
||||
type sideBySideLine struct {
|
||||
leftText string
|
||||
rightText string
|
||||
class string
|
||||
}
|
||||
var lines []sideBySideLine
|
||||
|
||||
for _, diff := range diffs {
|
||||
text := html.EscapeString(diff.Text)
|
||||
split := strings.Split(text, "\n")
|
||||
// Remove trailing empty string from split if it exists
|
||||
if len(split) > 0 && split[len(split)-1] == "" {
|
||||
split = split[:len(split)-1]
|
||||
}
|
||||
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
for _, line := range split {
|
||||
lines = append(lines, sideBySideLine{leftText: line, rightText: line, class: "diff-equal"})
|
||||
}
|
||||
case diffmatchpatch.DiffInsert:
|
||||
for _, line := range split {
|
||||
lines = append(lines, sideBySideLine{leftText: "", rightText: line, class: "diff-insert"})
|
||||
}
|
||||
case diffmatchpatch.DiffDelete:
|
||||
for _, line := range split {
|
||||
lines = append(lines, sideBySideLine{leftText: line, rightText: "", class: "diff-delete"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
leftMarker := ""
|
||||
rightMarker := ""
|
||||
if line.class == "diff-insert" {
|
||||
rightMarker = "+"
|
||||
} else if line.class == "diff-delete" {
|
||||
leftMarker = "-"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(`
|
||||
<tr class="%s">
|
||||
<td class="line-num">%d</td>
|
||||
<td><pre><span class="diff-change-marker">%s</span>%s</pre></td>
|
||||
<td class="line-num">%d</td>
|
||||
<td><pre><span class="diff-change-marker">%s</span>%s</pre></td>
|
||||
</tr>`, line.class, i+1, leftMarker, line.leftText, i+1, rightMarker, line.rightText))
|
||||
}
|
||||
|
||||
sb.WriteString(`
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
return os.WriteFile(path, []byte(sb.String()), 0644)
|
||||
}
|
||||
Reference in New Issue
Block a user