sync
This commit is contained in:
161
cmd/compare-auth/main.go
Normal file
161
cmd/compare-auth/main.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: compare-auth <email> <password>")
|
||||
fmt.Println("")
|
||||
fmt.Println("This script will:")
|
||||
fmt.Println("1. Get an anonymous token")
|
||||
fmt.Println("2. Get an authenticated token via browser")
|
||||
fmt.Println("3. Decode and compare both JWT tokens")
|
||||
fmt.Println("4. Show what's different")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Set up logging
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
entry := logrus.NewEntry(log)
|
||||
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
fmt.Println("COMPARING ANONYMOUS vs AUTHENTICATED TOKENS")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
fmt.Println()
|
||||
|
||||
// Get anonymous token
|
||||
fmt.Println("📝 Step 1: Getting anonymous token...")
|
||||
client := bkosmi.NewGraphQLWSClient("https://app.kosmi.io/room/@test", "@test", entry)
|
||||
anonToken, err := client.GetAnonymousTokenForTest()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to get anonymous token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Anonymous token obtained (length: %d)\n", len(anonToken))
|
||||
fmt.Println()
|
||||
|
||||
// Get authenticated token
|
||||
fmt.Println("📝 Step 2: Getting authenticated token via browser...")
|
||||
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
|
||||
authToken, err := browserAuth.GetToken()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to get authenticated token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Authenticated token obtained (length: %d)\n", len(authToken))
|
||||
fmt.Println()
|
||||
|
||||
// Decode both tokens
|
||||
fmt.Println("📝 Step 3: Decoding JWT tokens...")
|
||||
fmt.Println()
|
||||
|
||||
anonClaims := decodeJWT(anonToken)
|
||||
authClaims := decodeJWT(authToken)
|
||||
|
||||
fmt.Println("ANONYMOUS TOKEN:")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
printClaims(anonClaims)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("AUTHENTICATED TOKEN:")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
printClaims(authClaims)
|
||||
fmt.Println()
|
||||
|
||||
// Compare
|
||||
fmt.Println("DIFFERENCES:")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
compareClaims(anonClaims, authClaims)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("📝 Step 4: Testing connection with both tokens...")
|
||||
fmt.Println()
|
||||
|
||||
// We can't easily test the actual connection here, but we've shown the token differences
|
||||
fmt.Println("✅ Analysis complete!")
|
||||
fmt.Println()
|
||||
fmt.Println("RECOMMENDATION:")
|
||||
fmt.Println("If the authenticated token has a different 'sub' (user ID), that user")
|
||||
fmt.Println("might not have the same permissions or profile as the anonymous user.")
|
||||
}
|
||||
|
||||
func decodeJWT(token string) map[string]interface{} {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return map[string]interface{}{"error": "invalid JWT format"}
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
payload := parts[1]
|
||||
// Add padding if needed
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
// Replace URL-safe characters
|
||||
payload = strings.ReplaceAll(payload, "-", "+")
|
||||
payload = strings.ReplaceAll(payload, "_", "/")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("decode error: %v", err)}
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("parse error: %v", err)}
|
||||
}
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
func printClaims(claims map[string]interface{}) {
|
||||
keys := []string{"sub", "aud", "iss", "typ", "iat", "exp", "nbf", "jti"}
|
||||
for _, key := range keys {
|
||||
if val, ok := claims[key]; ok {
|
||||
fmt.Printf(" %-10s: %v\n", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compareClaims(anon, auth map[string]interface{}) {
|
||||
allKeys := make(map[string]bool)
|
||||
for k := range anon {
|
||||
allKeys[k] = true
|
||||
}
|
||||
for k := range auth {
|
||||
allKeys[k] = true
|
||||
}
|
||||
|
||||
different := false
|
||||
for key := range allKeys {
|
||||
anonVal := anon[key]
|
||||
authVal := auth[key]
|
||||
|
||||
if fmt.Sprintf("%v", anonVal) != fmt.Sprintf("%v", authVal) {
|
||||
different = true
|
||||
fmt.Printf(" %-10s: ANON=%v AUTH=%v\n", key, anonVal, authVal)
|
||||
}
|
||||
}
|
||||
|
||||
if !different {
|
||||
fmt.Println(" (No differences found - tokens are essentially the same)")
|
||||
}
|
||||
}
|
||||
|
||||
92
cmd/decode-token/main.go
Normal file
92
cmd/decode-token/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: decode-token <jwt-token>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
token := os.Args[1]
|
||||
parts := strings.Split(token, ".")
|
||||
|
||||
if len(parts) != 3 {
|
||||
fmt.Println("Invalid JWT token format")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Decode header
|
||||
fmt.Println("=== JWT HEADER ===")
|
||||
headerBytes, err := base64.RawStdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to decode header: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var header map[string]interface{}
|
||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||
fmt.Printf("Failed to parse header: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
headerJSON, _ := json.MarshalIndent(header, "", " ")
|
||||
fmt.Println(string(headerJSON))
|
||||
|
||||
// Decode payload
|
||||
fmt.Println("\n=== JWT PAYLOAD (CLAIMS) ===")
|
||||
payloadBytes, err := base64.RawStdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to decode payload: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
fmt.Printf("Failed to parse payload: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
payloadJSON, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Println(string(payloadJSON))
|
||||
|
||||
fmt.Println("\n=== KEY FIELDS ===")
|
||||
if sub, ok := payload["sub"].(string); ok {
|
||||
fmt.Printf("User ID (sub): %s\n", sub)
|
||||
}
|
||||
if typ, ok := payload["typ"].(string); ok {
|
||||
fmt.Printf("Token Type (typ): %s\n", typ)
|
||||
}
|
||||
if aud, ok := payload["aud"].(string); ok {
|
||||
fmt.Printf("Audience (aud): %s\n", aud)
|
||||
}
|
||||
if exp, ok := payload["exp"].(float64); ok {
|
||||
fmt.Printf("Expires (exp): %v\n", exp)
|
||||
}
|
||||
if iat, ok := payload["iat"].(float64); ok {
|
||||
fmt.Printf("Issued At (iat): %v\n", iat)
|
||||
}
|
||||
|
||||
// Check for user profile fields
|
||||
fmt.Println("\n=== USER PROFILE FIELDS ===")
|
||||
hasProfile := false
|
||||
for key, value := range payload {
|
||||
if strings.Contains(strings.ToLower(key), "name") ||
|
||||
strings.Contains(strings.ToLower(key), "user") ||
|
||||
strings.Contains(strings.ToLower(key), "display") ||
|
||||
strings.Contains(strings.ToLower(key), "email") {
|
||||
fmt.Printf("%s: %v\n", key, value)
|
||||
hasProfile = true
|
||||
}
|
||||
}
|
||||
if !hasProfile {
|
||||
fmt.Println("(No user profile fields found in token)")
|
||||
}
|
||||
}
|
||||
|
||||
74
cmd/extract-queries/main.go
Normal file
74
cmd/extract-queries/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HAR struct {
|
||||
Log struct {
|
||||
Entries []struct {
|
||||
WebSocketMessages []struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
} `json:"_webSocketMessages"`
|
||||
} `json:"entries"`
|
||||
} `json:"log"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: extract-queries <har-file>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var har HAR
|
||||
if err := json.Unmarshal(data, &har); err != nil {
|
||||
fmt.Printf("Failed to parse HAR: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== WebSocket Messages ===\n")
|
||||
|
||||
for _, entry := range har.Log.Entries {
|
||||
for i, msg := range entry.WebSocketMessages {
|
||||
if msg.Type == "send" {
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(msg.Data), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
msgType, _ := payload["type"].(string)
|
||||
fmt.Printf("[%d] Type: %s\n", i, msgType)
|
||||
|
||||
if msgType == "subscribe" {
|
||||
if p, ok := payload["payload"].(map[string]interface{}); ok {
|
||||
if opName, ok := p["operationName"].(string); ok {
|
||||
fmt.Printf(" Operation: %s\n", opName)
|
||||
}
|
||||
if query, ok := p["query"].(string); ok {
|
||||
// Pretty print the query
|
||||
query = strings.ReplaceAll(query, "\\n", "\n")
|
||||
if len(query) > 500 {
|
||||
fmt.Printf(" Query:\n%s\n [...truncated...]\n\n", query[:500])
|
||||
} else {
|
||||
fmt.Printf(" Query:\n%s\n\n", query)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
244
cmd/monitor-auth/README.md
Normal file
244
cmd/monitor-auth/README.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Kosmi Auth & Reconnection Monitor
|
||||
|
||||
A comprehensive WebSocket monitoring tool for reverse engineering Kosmi's authentication and reconnection behavior.
|
||||
|
||||
## Features
|
||||
|
||||
- 📡 Captures all WebSocket traffic (send/receive)
|
||||
- 🔐 Monitors authentication flows (login, token acquisition)
|
||||
- 🔄 Tests reconnection behavior
|
||||
- 💾 Captures localStorage, sessionStorage, and cookies
|
||||
- 📝 Logs to both console and file
|
||||
- 🌐 Monitors HTTP requests/responses
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd /Users/erikfredericks/dev-ai/HSO/irc-kosmi-relay
|
||||
go build -o bin/monitor-auth ./cmd/monitor-auth
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Monitor Anonymous Connection (Default)
|
||||
|
||||
Captures the anonymous token acquisition and WebSocket connection:
|
||||
|
||||
```bash
|
||||
./bin/monitor-auth -room "https://app.kosmi.io/room/@hyperspaceout"
|
||||
```
|
||||
|
||||
**What it captures:**
|
||||
- Anonymous token request/response
|
||||
- WebSocket connection handshake
|
||||
- Message subscription
|
||||
- Room join
|
||||
- Incoming messages
|
||||
|
||||
### 2. Monitor Login Flow
|
||||
|
||||
Captures the full authentication flow when logging in:
|
||||
|
||||
```bash
|
||||
./bin/monitor-auth -login
|
||||
```
|
||||
|
||||
**What to do:**
|
||||
1. Script opens browser to Kosmi
|
||||
2. Manually log in with your credentials
|
||||
3. Navigate to a room
|
||||
4. Script captures all auth traffic
|
||||
|
||||
**What it captures:**
|
||||
- Login form submission
|
||||
- Token response
|
||||
- Token storage (localStorage/cookies)
|
||||
- Authenticated WebSocket connection
|
||||
- User information
|
||||
|
||||
### 3. Test Reconnection Behavior
|
||||
|
||||
Simulates network disconnection and observes reconnection:
|
||||
|
||||
```bash
|
||||
./bin/monitor-auth -reconnect
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Connects to Kosmi
|
||||
2. Simulates network offline for 10 seconds
|
||||
3. Restores network
|
||||
4. Observes reconnection behavior
|
||||
|
||||
**What it captures:**
|
||||
- Disconnection events
|
||||
- Reconnection attempts
|
||||
- Token refresh (if any)
|
||||
- Re-subscription
|
||||
|
||||
## Output
|
||||
|
||||
All captured data is written to:
|
||||
- **Console**: Real-time output with emojis for easy reading
|
||||
- **File**: `auth-monitor.log` in current directory
|
||||
|
||||
### Log Format
|
||||
|
||||
```
|
||||
HH:MM:SS.mmm [TYPE] Message
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
11:45:23.123 🌐 [HTTP REQUEST] POST https://engine.kosmi.io/
|
||||
11:45:23.456 📨 [HTTP RESPONSE] 200 https://engine.kosmi.io/
|
||||
11:45:23.789 🔌 [WS MONITOR] WebSocket created: wss://engine.kosmi.io/gql-ws
|
||||
11:45:24.012 📤 [WS MONITOR] SEND #1: {"type":"connection_init",...}
|
||||
11:45:24.234 📥 [WS MONITOR] RECEIVE #1: {"type":"connection_ack"}
|
||||
```
|
||||
|
||||
## Analyzing Captured Data
|
||||
|
||||
### Finding Authentication API
|
||||
|
||||
Look for POST requests to `https://engine.kosmi.io/`:
|
||||
|
||||
```bash
|
||||
grep "POST.*engine.kosmi.io" auth-monitor.log
|
||||
grep "Response Body" auth-monitor.log | grep -A 10 "login"
|
||||
```
|
||||
|
||||
### Finding Token Storage
|
||||
|
||||
Look for localStorage/sessionStorage writes:
|
||||
|
||||
```bash
|
||||
grep "localStorage" auth-monitor.log
|
||||
grep "token" auth-monitor.log
|
||||
```
|
||||
|
||||
### Finding Reconnection Logic
|
||||
|
||||
Look for WebSocket CLOSED/OPENED events:
|
||||
|
||||
```bash
|
||||
grep "WebSocket CLOSED" auth-monitor.log
|
||||
grep "WebSocket OPENED" auth-monitor.log
|
||||
```
|
||||
|
||||
## Common Patterns to Look For
|
||||
|
||||
### 1. Login Mutation
|
||||
|
||||
```graphql
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Token Refresh Mutation
|
||||
|
||||
```graphql
|
||||
mutation RefreshToken($refreshToken: String!) {
|
||||
refreshToken(refreshToken: $refreshToken) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Typing Indicators
|
||||
|
||||
```graphql
|
||||
mutation SetTyping($roomId: String!, $isTyping: Boolean!) {
|
||||
setTyping(roomId: $roomId, isTyping: $isTyping) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
subscription OnUserTyping($roomId: String!) {
|
||||
userTyping(roomId: $roomId) {
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
isTyping
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Script won't stop with Ctrl+C
|
||||
|
||||
**Fixed in latest version**. Rebuild if you have an old version:
|
||||
```bash
|
||||
go build -o bin/monitor-auth ./cmd/monitor-auth
|
||||
```
|
||||
|
||||
If still stuck, you can force quit:
|
||||
```bash
|
||||
# In another terminal
|
||||
pkill -f monitor-auth
|
||||
```
|
||||
|
||||
### Browser doesn't open
|
||||
|
||||
Make sure Playwright is installed:
|
||||
```bash
|
||||
go run github.com/playwright-community/playwright-go/cmd/playwright@latest install
|
||||
```
|
||||
|
||||
### No WebSocket traffic captured
|
||||
|
||||
The monitoring script injects BEFORE page load. If you see "WebSocket hook active" in the console, it's working. If not, try:
|
||||
1. Refresh the page
|
||||
2. Check browser console for errors
|
||||
3. Ensure you're on a Kosmi room page
|
||||
|
||||
### Log file is empty
|
||||
|
||||
Check that you have write permissions in the current directory:
|
||||
```bash
|
||||
touch auth-monitor.log
|
||||
ls -l auth-monitor.log
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Use with real credentials**: The monitoring script is safe - it runs locally and doesn't send data anywhere. Use real credentials to capture actual auth flows.
|
||||
|
||||
2. **Compare anonymous vs authenticated**: Run twice - once without `-login` and once with - to see the differences.
|
||||
|
||||
3. **Watch the browser**: Keep an eye on the browser window to see what triggers each WebSocket message.
|
||||
|
||||
4. **Search the log file**: Use `grep`, `jq`, or text editor to analyze the captured data.
|
||||
|
||||
5. **Test edge cases**: Try invalid credentials, expired tokens, network failures, etc.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After capturing auth data:
|
||||
|
||||
1. Review `auth-monitor.log`
|
||||
2. Identify actual GraphQL mutation formats
|
||||
3. Update `bridge/kosmi/auth.go` if needed
|
||||
4. Test with real credentials in production
|
||||
5. Verify token refresh works correctly
|
||||
|
||||
## See Also
|
||||
|
||||
- `TYPING_INDICATORS.md` - Guide for implementing typing indicators
|
||||
- `IMPLEMENTATION_SUMMARY.md` - Overall project documentation
|
||||
- `chat-summaries/2025-11-01_*_reconnection-and-auth-implementation.md` - Implementation details
|
||||
|
||||
545
cmd/monitor-auth/main.go
Normal file
545
cmd/monitor-auth/main.go
Normal file
@@ -0,0 +1,545 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRoomURL = "https://app.kosmi.io/room/@hyperspaceout"
|
||||
logFile = "auth-monitor.log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
roomURL := flag.String("room", defaultRoomURL, "Kosmi room URL")
|
||||
loginMode := flag.Bool("login", false, "Monitor login flow (navigate to login page first)")
|
||||
testReconnect := flag.Bool("reconnect", false, "Test reconnection behavior (will pause network)")
|
||||
flag.Parse()
|
||||
|
||||
log.Println("🔍 Starting Kosmi Auth & Reconnection Monitor")
|
||||
log.Printf("📡 Room URL: %s", *roomURL)
|
||||
log.Printf("🔐 Login mode: %v", *loginMode)
|
||||
log.Printf("🔄 Reconnect test: %v", *testReconnect)
|
||||
log.Printf("📝 Log file: %s", logFile)
|
||||
log.Println()
|
||||
|
||||
// Create log file
|
||||
f, err := os.Create(logFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create log file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Helper to log to both console and file
|
||||
logBoth := func(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Println(msg)
|
||||
fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), msg)
|
||||
}
|
||||
|
||||
// Set up interrupt handler
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
|
||||
// Launch Playwright
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start Playwright: %v", err)
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
cleanup := func() {
|
||||
logBoth("\n👋 Shutting down...")
|
||||
if pw != nil {
|
||||
pw.Stop()
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Handle interrupt
|
||||
go func() {
|
||||
<-interrupt
|
||||
cleanup()
|
||||
}()
|
||||
|
||||
// Launch browser (visible so we can interact for login)
|
||||
browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
|
||||
Headless: playwright.Bool(false),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to launch browser: %v", err)
|
||||
}
|
||||
|
||||
// Create context
|
||||
context, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
UserAgent: playwright.String("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create context: %v", err)
|
||||
}
|
||||
|
||||
// Monitor all network requests
|
||||
context.On("request", func(request playwright.Request) {
|
||||
url := request.URL()
|
||||
method := request.Method()
|
||||
|
||||
// Log all Kosmi-related requests
|
||||
if containsKosmi(url) {
|
||||
logBoth("🌐 [HTTP REQUEST] %s %s", method, url)
|
||||
|
||||
// Log POST data (likely contains login credentials or GraphQL mutations)
|
||||
if method == "POST" {
|
||||
postData, err := request.PostData()
|
||||
if err == nil && postData != "" {
|
||||
logBoth(" 📤 POST Data: %s", postData)
|
||||
}
|
||||
}
|
||||
|
||||
// Log headers for auth-related requests
|
||||
headers := request.Headers()
|
||||
if authHeader, ok := headers["authorization"]; ok {
|
||||
logBoth(" 🔑 Authorization: %s", authHeader)
|
||||
}
|
||||
if cookie, ok := headers["cookie"]; ok {
|
||||
logBoth(" 🍪 Cookie: %s", truncate(cookie, 100))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Monitor all network responses
|
||||
context.On("response", func(response playwright.Response) {
|
||||
url := response.URL()
|
||||
status := response.Status()
|
||||
|
||||
if containsKosmi(url) {
|
||||
logBoth("📨 [HTTP RESPONSE] %d %s", status, url)
|
||||
|
||||
// Try to get response body for auth endpoints
|
||||
if status >= 200 && status < 300 {
|
||||
body, err := response.Body()
|
||||
if err == nil && len(body) > 0 && len(body) < 50000 {
|
||||
// Try to parse as JSON
|
||||
var jsonData interface{}
|
||||
if json.Unmarshal(body, &jsonData) == nil {
|
||||
prettyJSON, _ := json.MarshalIndent(jsonData, " ", " ")
|
||||
logBoth(" 📦 Response Body: %s", string(prettyJSON))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log Set-Cookie headers
|
||||
headers := response.Headers()
|
||||
if setCookie, ok := headers["set-cookie"]; ok {
|
||||
logBoth(" 🍪 Set-Cookie: %s", setCookie)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create page
|
||||
page, err := context.NewPage()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create page: %v", err)
|
||||
}
|
||||
|
||||
// Inject comprehensive monitoring script BEFORE navigation
|
||||
logBoth("📝 Injecting WebSocket, storage, and reconnection monitoring script...")
|
||||
monitorScript := getMonitoringScript()
|
||||
logBoth(" Script length: %d characters", len(monitorScript))
|
||||
if err := page.AddInitScript(playwright.Script{
|
||||
Content: playwright.String(monitorScript),
|
||||
}); err != nil {
|
||||
log.Fatalf("Failed to inject script: %v", err)
|
||||
}
|
||||
logBoth(" ✅ Script injected successfully")
|
||||
|
||||
// Listen to console messages
|
||||
page.On("console", func(msg playwright.ConsoleMessage) {
|
||||
text := msg.Text()
|
||||
msgType := msg.Type()
|
||||
|
||||
// Format the output nicely
|
||||
prefix := "💬"
|
||||
switch msgType {
|
||||
case "log":
|
||||
prefix = "📋"
|
||||
case "error":
|
||||
prefix = "❌"
|
||||
case "warning":
|
||||
prefix = "⚠️"
|
||||
case "info":
|
||||
prefix = "ℹ️"
|
||||
}
|
||||
|
||||
logBoth("%s [BROWSER %s] %s", prefix, msgType, text)
|
||||
})
|
||||
|
||||
// Navigate based on mode
|
||||
if *loginMode {
|
||||
logBoth("🔐 Login mode: Navigate to login page first")
|
||||
logBoth(" Please log in manually in the browser")
|
||||
logBoth(" We'll capture all auth traffic")
|
||||
|
||||
// Navigate to main Kosmi page (will redirect to login if not authenticated)
|
||||
logBoth("🌐 Navigating to https://app.kosmi.io...")
|
||||
timeout := 30000.0 // 30 seconds
|
||||
_, err := page.Goto("https://app.kosmi.io", playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
Timeout: &timeout,
|
||||
})
|
||||
if err != nil {
|
||||
logBoth("⚠️ Navigation error: %v", err)
|
||||
logBoth(" Continuing anyway - page might still be usable")
|
||||
} else {
|
||||
logBoth("✅ Page loaded successfully")
|
||||
}
|
||||
|
||||
logBoth("")
|
||||
logBoth("📋 Instructions:")
|
||||
logBoth(" 1. Log in with your credentials in the browser")
|
||||
logBoth(" 2. Navigate to the room: %s", *roomURL)
|
||||
logBoth(" 3. Browser console messages should appear here")
|
||||
logBoth(" 4. Press Ctrl+C when done")
|
||||
logBoth("")
|
||||
logBoth("💡 Expected console messages:")
|
||||
logBoth(" - '[Monitor] Installing...'")
|
||||
logBoth(" - '[WS MONITOR] WebSocket created...'")
|
||||
logBoth(" - '[FETCH] Request to...'")
|
||||
logBoth("")
|
||||
|
||||
} else {
|
||||
// Navigate directly to room (anonymous flow)
|
||||
logBoth("🌐 Navigating to %s...", *roomURL)
|
||||
timeout := 30000.0 // 30 seconds
|
||||
_, err := page.Goto(*roomURL, playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
Timeout: &timeout,
|
||||
})
|
||||
if err != nil {
|
||||
logBoth("⚠️ Navigation error: %v", err)
|
||||
logBoth(" Continuing anyway - page might still be usable")
|
||||
} else {
|
||||
logBoth("✅ Page loaded successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for WebSocket to connect
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Capture storage data
|
||||
logBoth("\n📦 Capturing storage data...")
|
||||
captureStorage(page, logBoth)
|
||||
|
||||
if *testReconnect {
|
||||
logBoth("\n🔄 Testing reconnection behavior...")
|
||||
logBoth(" This will simulate network disconnection")
|
||||
logBoth(" Watch how the browser handles reconnection")
|
||||
|
||||
// Simulate network offline
|
||||
logBoth(" 📡 Setting network to OFFLINE...")
|
||||
if err := context.SetOffline(true); err != nil {
|
||||
logBoth(" ❌ Failed to set offline: %v", err)
|
||||
} else {
|
||||
logBoth(" ✅ Network is now OFFLINE")
|
||||
logBoth(" ⏳ Waiting 10 seconds...")
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
// Restore network
|
||||
logBoth(" 📡 Setting network to ONLINE...")
|
||||
if err := context.SetOffline(false); err != nil {
|
||||
logBoth(" ❌ Failed to set online: %v", err)
|
||||
} else {
|
||||
logBoth(" ✅ Network is now ONLINE")
|
||||
logBoth(" ⏳ Observing reconnection behavior...")
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture final storage state
|
||||
logBoth("\n📦 Capturing final storage state...")
|
||||
captureStorage(page, logBoth)
|
||||
|
||||
// Keep monitoring
|
||||
logBoth("\n⏳ Monitoring all traffic... Press Ctrl+C to stop")
|
||||
if *loginMode {
|
||||
logBoth("💡 TIP: Try logging in, navigating to rooms, and logging out")
|
||||
logBoth(" We'll capture all authentication flows")
|
||||
}
|
||||
|
||||
// Wait forever (interrupt handler will exit)
|
||||
select {}
|
||||
}
|
||||
|
||||
// captureStorage captures localStorage, sessionStorage, and cookies
|
||||
func captureStorage(page playwright.Page, logBoth func(string, ...interface{})) {
|
||||
// Capture localStorage
|
||||
localStorageResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
const data = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
data[key] = localStorage.getItem(key);
|
||||
}
|
||||
return data;
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if localStorage, ok := localStorageResult.(map[string]interface{}); ok {
|
||||
logBoth(" 📦 localStorage:")
|
||||
for key, value := range localStorage {
|
||||
logBoth(" %s: %s", key, truncate(fmt.Sprintf("%v", value), 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture sessionStorage
|
||||
sessionStorageResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
const data = {};
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
data[key] = sessionStorage.getItem(key);
|
||||
}
|
||||
return data;
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if sessionStorage, ok := sessionStorageResult.(map[string]interface{}); ok {
|
||||
logBoth(" 📦 sessionStorage:")
|
||||
for key, value := range sessionStorage {
|
||||
logBoth(" %s: %s", key, truncate(fmt.Sprintf("%v", value), 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture cookies
|
||||
cookiesResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
return document.cookie.split(';').map(c => {
|
||||
const parts = c.trim().split('=');
|
||||
return {
|
||||
name: parts[0],
|
||||
value: parts.slice(1).join('=')
|
||||
};
|
||||
}).filter(c => c.name && c.value);
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if cookiesArray, ok := cookiesResult.([]interface{}); ok {
|
||||
logBoth(" 🍪 Cookies:")
|
||||
for _, cookieItem := range cookiesArray {
|
||||
if cookie, ok := cookieItem.(map[string]interface{}); ok {
|
||||
name := cookie["name"]
|
||||
value := cookie["value"]
|
||||
logBoth(" %s: %s", name, truncate(fmt.Sprintf("%v", value), 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture WebSocket status
|
||||
wsStatusResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
return window.__KOSMI_WS_STATUS__ || { error: "No WebSocket found" };
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if wsStatus, ok := wsStatusResult.(map[string]interface{}); ok {
|
||||
logBoth(" 🔌 WebSocket Status:")
|
||||
for key, value := range wsStatus {
|
||||
logBoth(" %s: %v", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getMonitoringScript returns the comprehensive monitoring script
|
||||
func getMonitoringScript() string {
|
||||
return `
|
||||
(function() {
|
||||
if (window.__KOSMI_MONITOR_INSTALLED__) return;
|
||||
|
||||
console.log('[Monitor] Installing comprehensive monitoring hooks...');
|
||||
|
||||
// Store original WebSocket constructor
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
let wsInstance = null;
|
||||
let messageCount = 0;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
// Hook WebSocket constructor
|
||||
window.WebSocket = function(url, protocols) {
|
||||
console.log('🔌 [WS MONITOR] WebSocket created:', url, 'protocols:', protocols);
|
||||
const socket = new OriginalWebSocket(url, protocols);
|
||||
|
||||
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
||||
wsInstance = socket;
|
||||
|
||||
// Track connection lifecycle
|
||||
socket.addEventListener('open', (event) => {
|
||||
console.log('✅ [WS MONITOR] WebSocket OPENED');
|
||||
reconnectAttempts = 0;
|
||||
updateWSStatus('OPEN', url);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
console.log('🔴 [WS MONITOR] WebSocket CLOSED:', event.code, event.reason, 'wasClean:', event.wasClean);
|
||||
reconnectAttempts++;
|
||||
updateWSStatus('CLOSED', url, { code: event.code, reason: event.reason, reconnectAttempts });
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
console.error('❌ [WS MONITOR] WebSocket ERROR:', event);
|
||||
updateWSStatus('ERROR', url);
|
||||
});
|
||||
|
||||
// Intercept outgoing messages
|
||||
const originalSend = socket.send;
|
||||
socket.send = function(data) {
|
||||
messageCount++;
|
||||
console.log('📤 [WS MONITOR] SEND #' + messageCount + ':', data);
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
console.log(' Type:', parsed.type, 'ID:', parsed.id);
|
||||
if (parsed.payload) {
|
||||
console.log(' Payload:', JSON.stringify(parsed.payload, null, 2));
|
||||
|
||||
// Check for auth-related messages
|
||||
if (parsed.type === 'connection_init' && parsed.payload.token) {
|
||||
console.log(' 🔑 [AUTH] Connection init with token:', parsed.payload.token.substring(0, 50) + '...');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
|
||||
// Intercept incoming messages
|
||||
socket.addEventListener('message', (event) => {
|
||||
messageCount++;
|
||||
console.log('📥 [WS MONITOR] RECEIVE #' + messageCount + ':', event.data);
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
console.log(' Type:', parsed.type, 'ID:', parsed.id);
|
||||
if (parsed.payload) {
|
||||
console.log(' Payload:', JSON.stringify(parsed.payload, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
// Preserve WebSocket properties
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
|
||||
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
|
||||
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
|
||||
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;
|
||||
|
||||
// Monitor localStorage changes
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function(key, value) {
|
||||
console.log('💾 [STORAGE] localStorage.setItem:', key, '=', value.substring(0, 100));
|
||||
if (key.toLowerCase().includes('token') || key.toLowerCase().includes('auth')) {
|
||||
console.log(' 🔑 [AUTH] Auth-related storage detected!');
|
||||
}
|
||||
return originalSetItem.call(this, key, value);
|
||||
};
|
||||
|
||||
// Monitor sessionStorage changes
|
||||
const originalSessionSetItem = sessionStorage.setItem;
|
||||
sessionStorage.setItem = function(key, value) {
|
||||
console.log('💾 [STORAGE] sessionStorage.setItem:', key, '=', value.substring(0, 100));
|
||||
if (key.toLowerCase().includes('token') || key.toLowerCase().includes('auth')) {
|
||||
console.log(' 🔑 [AUTH] Auth-related storage detected!');
|
||||
}
|
||||
return originalSessionSetItem.call(this, key, value);
|
||||
};
|
||||
|
||||
// Monitor fetch requests (for GraphQL mutations)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options) {
|
||||
if (typeof url === 'string' && url.includes('kosmi.io')) {
|
||||
console.log('🌐 [FETCH] Request to:', url);
|
||||
if (options && options.body) {
|
||||
console.log(' 📤 Body:', options.body);
|
||||
try {
|
||||
const parsed = JSON.parse(options.body);
|
||||
if (parsed.query) {
|
||||
console.log(' 📝 GraphQL Query:', parsed.query.substring(0, 200));
|
||||
if (parsed.query.includes('login') || parsed.query.includes('auth')) {
|
||||
console.log(' 🔑 [AUTH] Auth-related GraphQL detected!');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalFetch.apply(this, arguments).then(response => {
|
||||
if (typeof url === 'string' && url.includes('kosmi.io')) {
|
||||
console.log('📨 [FETCH] Response from:', url, 'status:', response.status);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to update WebSocket status
|
||||
function updateWSStatus(state, url, extra = {}) {
|
||||
window.__KOSMI_WS_STATUS__ = {
|
||||
state,
|
||||
url,
|
||||
timestamp: new Date().toISOString(),
|
||||
messageCount,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
// Monitor network connectivity
|
||||
window.addEventListener('online', () => {
|
||||
console.log('🌐 [NETWORK] Browser is ONLINE');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('🌐 [NETWORK] Browser is OFFLINE');
|
||||
});
|
||||
|
||||
window.__KOSMI_MONITOR_INSTALLED__ = true;
|
||||
console.log('[Monitor] ✅ All monitoring hooks installed');
|
||||
})();
|
||||
`
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func containsKosmi(url string) bool {
|
||||
return contains(url, "kosmi.io") || contains(url, "engine.kosmi.io") || contains(url, "app.kosmi.io")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
145
cmd/parse-har/main.go
Normal file
145
cmd/parse-har/main.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HAR struct {
|
||||
Log struct {
|
||||
Entries []struct {
|
||||
Request struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
} `json:"request"`
|
||||
WebSocketMessages []struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
Time float64 `json:"time"`
|
||||
} `json:"_webSocketMessages"`
|
||||
} `json:"entries"`
|
||||
} `json:"log"`
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal("Usage: parse-har <har-file>")
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var har HAR
|
||||
if err := json.Unmarshal(data, &har); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// First pass: collect all responses
|
||||
responses := make(map[string]bool) // ID -> has data
|
||||
for _, entry := range har.Log.Entries {
|
||||
for _, wsMsg := range entry.WebSocketMessages {
|
||||
if wsMsg.Type != "receive" {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal([]byte(wsMsg.Data), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "next" {
|
||||
// Check if payload has data
|
||||
if data, ok := msg.Payload["data"].(map[string]interface{}); ok && len(data) > 0 {
|
||||
responses[msg.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== WebSocket Operations (in order) ===\n")
|
||||
|
||||
msgCount := 0
|
||||
for _, entry := range har.Log.Entries {
|
||||
for _, wsMsg := range entry.WebSocketMessages {
|
||||
if wsMsg.Type != "send" {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal([]byte(wsMsg.Data), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "connection_init" {
|
||||
fmt.Printf("[%d] connection_init\n", msgCount)
|
||||
msgCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "subscribe" {
|
||||
opName := ""
|
||||
query := ""
|
||||
variables := map[string]interface{}{}
|
||||
extensions := map[string]interface{}{}
|
||||
|
||||
if payload, ok := msg.Payload["operationName"].(string); ok {
|
||||
opName = payload
|
||||
}
|
||||
if q, ok := msg.Payload["query"].(string); ok {
|
||||
query = q
|
||||
}
|
||||
if v, ok := msg.Payload["variables"].(map[string]interface{}); ok {
|
||||
variables = v
|
||||
}
|
||||
if e, ok := msg.Payload["extensions"].(map[string]interface{}); ok {
|
||||
extensions = e
|
||||
}
|
||||
|
||||
hasResponse := ""
|
||||
if responses[msg.ID] {
|
||||
hasResponse = " ✅ GOT DATA"
|
||||
} else {
|
||||
hasResponse = " ❌ NO DATA"
|
||||
}
|
||||
fmt.Printf("[%d] %s (ID: %s)%s\n", msgCount, opName, msg.ID, hasResponse)
|
||||
|
||||
// Show query type
|
||||
if strings.Contains(query, "mutation") {
|
||||
fmt.Printf(" Type: MUTATION\n")
|
||||
} else if strings.Contains(query, "subscription") {
|
||||
fmt.Printf(" Type: SUBSCRIPTION\n")
|
||||
} else if strings.Contains(query, "query") {
|
||||
fmt.Printf(" Type: QUERY\n")
|
||||
}
|
||||
|
||||
// Show variables
|
||||
if len(variables) > 0 {
|
||||
fmt.Printf(" Variables: %v\n", variables)
|
||||
}
|
||||
|
||||
// Show extensions
|
||||
if len(extensions) > 0 {
|
||||
fmt.Printf(" Extensions: %v\n", extensions)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
msgCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal operations: %d\n", msgCount)
|
||||
}
|
||||
|
||||
74
cmd/test-browser-auth/main.go
Normal file
74
cmd/test-browser-auth/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: test-browser-auth <email> <password>")
|
||||
fmt.Println("")
|
||||
fmt.Println("This tests the browser-based authentication by:")
|
||||
fmt.Println("1. Launching headless Chrome")
|
||||
fmt.Println("2. Logging in to Kosmi")
|
||||
fmt.Println("3. Extracting the JWT token")
|
||||
fmt.Println("4. Parsing token expiry")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Set up logging
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.DebugLevel)
|
||||
entry := logrus.NewEntry(log)
|
||||
|
||||
fmt.Println("🚀 Testing browser-based authentication...")
|
||||
fmt.Println()
|
||||
|
||||
// Create browser auth manager
|
||||
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
|
||||
|
||||
// Get token
|
||||
token, err := browserAuth.GetToken()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Authentication failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✅ Authentication successful!")
|
||||
fmt.Println()
|
||||
fmt.Printf("Token (first 50 chars): %s...\n", token[:min(50, len(token))])
|
||||
fmt.Printf("Token length: %d characters\n", len(token))
|
||||
fmt.Println()
|
||||
|
||||
// Check if authenticated
|
||||
if browserAuth.IsAuthenticated() {
|
||||
fmt.Println("✅ Token is valid")
|
||||
} else {
|
||||
fmt.Println("❌ Token is invalid or expired")
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
userID := browserAuth.GetUserID()
|
||||
if userID != "" {
|
||||
fmt.Printf("User ID: %s\n", userID)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("🎉 Test completed successfully!")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
309
cmd/test-browser-login/main.go
Normal file
309
cmd/test-browser-login/main.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: test-browser-login <email> <password>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Set up Chrome options - VISIBLE browser for debugging
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", false), // NOT headless - we want to see it
|
||||
chromedp.Flag("disable-gpu", false),
|
||||
)
|
||||
|
||||
// Create allocator context
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
// Set a reasonable timeout for the entire login process
|
||||
ctx, cancel = context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var token string
|
||||
|
||||
// Run the browser automation tasks
|
||||
err := chromedp.Run(ctx,
|
||||
// Navigate to Kosmi
|
||||
chromedp.Navigate("https://app.kosmi.io"),
|
||||
|
||||
// Wait for page to load completely
|
||||
chromedp.WaitReady("body"),
|
||||
chromedp.Sleep(2*time.Second),
|
||||
|
||||
// Click Login button
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Looking for Log in button...")
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
fmt.Printf("Found buttons: %v\n", buttonTexts)
|
||||
|
||||
var found bool
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
// Try both "Login" and "Log in"
|
||||
const btn = buttons.find(el => {
|
||||
const text = el.textContent.trim();
|
||||
return text === 'Login' || text === 'Log in';
|
||||
});
|
||||
if (btn) {
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`, &found).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("Login button not found")
|
||||
}
|
||||
fmt.Println("✓ Clicked Login button")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login modal
|
||||
chromedp.Sleep(3*time.Second),
|
||||
|
||||
// Click "Login with Email" button
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Looking for Login with Email button...")
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
fmt.Printf("Found buttons: %v\n", buttonTexts)
|
||||
|
||||
var found bool
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const btn = Array.from(document.querySelectorAll('button')).find(el => el.textContent.includes('Email'));
|
||||
if (btn) {
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`, &found).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("Login with Email button not found")
|
||||
}
|
||||
fmt.Println("✓ Clicked Login with Email button")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for email form
|
||||
chromedp.Sleep(3*time.Second),
|
||||
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
|
||||
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Password input found, filling in email...")
|
||||
var inputTypes []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('input')).map(el => el.type + (el.placeholder ? ' ('+el.placeholder+')' : ''))
|
||||
`, &inputTypes).Do(ctx)
|
||||
fmt.Printf("Available inputs: %v\n", inputTypes)
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Click on the email input to focus it
|
||||
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type email character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Printf("Typing email: %s\n", email)
|
||||
return chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
// Click on the password input to focus it
|
||||
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type password character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Printf("Typing password (length: %d)...\n", len(password))
|
||||
return chromedp.SendKeys(`input[type="password"]`, password, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
// Verify password was filled correctly
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var actualLength int
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
return passwordInput ? passwordInput.value.length : 0;
|
||||
})()
|
||||
`, &actualLength).Do(ctx)
|
||||
|
||||
fmt.Printf("✓ Password filled (actual length in browser: %d, expected: %d)\n", actualLength, len(password))
|
||||
|
||||
if actualLength != len(password) {
|
||||
return fmt.Errorf("password length mismatch: got %d, expected %d", actualLength, len(password))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Looking for submit button...")
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim() + (el.disabled ? ' (disabled)' : ''))
|
||||
`, &buttonTexts).Do(ctx)
|
||||
fmt.Printf("Submit buttons available: %v\n", buttonTexts)
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait a moment for form validation
|
||||
chromedp.Sleep(1*time.Second),
|
||||
|
||||
// Click the login submit button (be very specific)
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Attempting to click submit button...")
|
||||
var result string
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
console.log('All buttons:', buttons.map(b => ({
|
||||
text: b.textContent.trim(),
|
||||
disabled: b.disabled,
|
||||
visible: b.offsetParent !== null
|
||||
})));
|
||||
|
||||
// Find the submit button in the login form
|
||||
// It should be visible, enabled, and contain "Login" but not be the main nav button
|
||||
const submitBtn = buttons.find(el => {
|
||||
const text = el.textContent.trim();
|
||||
const isLoginBtn = text === 'Login' || text.startsWith('Login');
|
||||
const isEnabled = !el.disabled;
|
||||
const isVisible = el.offsetParent !== null;
|
||||
const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null;
|
||||
|
||||
return isLoginBtn && isEnabled && isVisible && isInForm;
|
||||
});
|
||||
|
||||
if (submitBtn) {
|
||||
console.log('Found submit button:', submitBtn.textContent.trim());
|
||||
submitBtn.click();
|
||||
return 'CLICKED: ' + submitBtn.textContent.trim();
|
||||
}
|
||||
|
||||
return 'NOT_FOUND';
|
||||
})()
|
||||
`, &result).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Submit button result: %s\n", result)
|
||||
|
||||
if result == "NOT_FOUND" {
|
||||
return fmt.Errorf("Login submit button not found or not clickable")
|
||||
}
|
||||
|
||||
fmt.Println("✓ Clicked submit button")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login to complete
|
||||
chromedp.Sleep(5*time.Second),
|
||||
|
||||
// Check for errors
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var errorText string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
|
||||
return errorEl ? errorEl.textContent : '';
|
||||
})()
|
||||
`, &errorText).Do(ctx)
|
||||
|
||||
if errorText != "" {
|
||||
fmt.Printf("❌ Login error: %s\n", errorText)
|
||||
return fmt.Errorf("login failed: %s", errorText)
|
||||
}
|
||||
|
||||
fmt.Println("✓ No error messages")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Extract token from localStorage
|
||||
chromedp.Evaluate(`localStorage.getItem('token')`, &token),
|
||||
|
||||
// Check token details
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var userInfo string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return 'NO_TOKEN';
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return 'INVALID_TOKEN';
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch (e) {
|
||||
return 'ERROR: ' + e.message;
|
||||
}
|
||||
})()
|
||||
`, &userInfo).Do(ctx)
|
||||
|
||||
fmt.Printf("\n=== Token Payload ===\n%s\n", userInfo)
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Keep browser open for inspection
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("\n✓ Login complete! Browser will stay open for 30 seconds for inspection...")
|
||||
return nil
|
||||
}),
|
||||
chromedp.Sleep(30*time.Second),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("\n❌ Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
fmt.Println("\n❌ No token found in localStorage")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✅ Token obtained (length: %d)\n", len(token))
|
||||
fmt.Printf("First 50 chars: %s...\n", token[:min(50, len(token))])
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
73
cmd/test-introspection/main.go
Normal file
73
cmd/test-introspection/main.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// GraphQL introspection query to find all mutations
|
||||
query := map[string]interface{}{
|
||||
"query": `{
|
||||
__schema {
|
||||
mutationType {
|
||||
fields {
|
||||
name
|
||||
description
|
||||
args {
|
||||
name
|
||||
type {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(query)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to marshal: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Request failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read response: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pretty print JSON response
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
fmt.Println("Response (raw):")
|
||||
fmt.Println(string(body))
|
||||
} else {
|
||||
prettyJSON, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Println(string(prettyJSON))
|
||||
}
|
||||
}
|
||||
|
||||
89
cmd/test-login/main.go
Normal file
89
cmd/test-login/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: test-login <email> <password>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Try the guessed mutation format
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
username
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"email": email,
|
||||
"password": password,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.MarshalIndent(mutation, "", " ")
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to marshal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("📤 Sending request:")
|
||||
fmt.Println(string(jsonBody))
|
||||
fmt.Println()
|
||||
|
||||
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Request failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fmt.Printf("📥 Response Status: %d\n", resp.StatusCode)
|
||||
fmt.Println()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Pretty print JSON response
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
fmt.Println("Response (raw):")
|
||||
fmt.Println(string(body))
|
||||
} else {
|
||||
prettyJSON, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Println("Response (JSON):")
|
||||
fmt.Println(string(prettyJSON))
|
||||
}
|
||||
}
|
||||
|
||||
94
cmd/test-profile-query/main.go
Normal file
94
cmd/test-profile-query/main.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: test-profile-query <jwt-token>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
token := os.Args[1]
|
||||
|
||||
// Try different queries to get user profile
|
||||
queries := []struct{
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "me query",
|
||||
query: `query { me { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
{
|
||||
name: "currentUser query",
|
||||
query: `query { currentUser { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
{
|
||||
name: "user query",
|
||||
query: `query { user { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
{
|
||||
name: "viewer query",
|
||||
query: `query { viewer { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
fmt.Printf("\n=== Testing: %s ===\n", q.name)
|
||||
testQuery(token, q.query)
|
||||
}
|
||||
}
|
||||
|
||||
func testQuery(token, query string) {
|
||||
payload := map[string]interface{}{
|
||||
"query": query,
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to marshal query: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Request failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read response: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Status: %d\n", resp.StatusCode)
|
||||
|
||||
// Pretty print JSON
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
fmt.Printf("Response: %s\n", string(body))
|
||||
} else {
|
||||
prettyJSON, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Printf("Response:\n%s\n", string(prettyJSON))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user