From 8a43134e96eda173d9b6f93066001db542ab4dcf Mon Sep 17 00:00:00 2001 From: m-rosinsky Date: Tue, 6 May 2025 16:56:06 -0400 Subject: [PATCH 1/5] [Webhooks] Initial commit --- cli/root.go | 1 + cli/webhook.go | 141 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 16 ++++-- go.sum | 26 +++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 cli/webhook.go diff --git a/cli/root.go b/cli/root.go index 0323fca..66dfd50 100644 --- a/cli/root.go +++ b/cli/root.go @@ -88,6 +88,7 @@ Examples: rootCmd.AddCommand(CreateAuthCommand(auth)) rootCmd.AddCommand(CreateMediaCommand(auth)) rootCmd.AddCommand(CreateVersionCommand()) + rootCmd.AddCommand(CreateWebhookCommand(auth)) return rootCmd } diff --git a/cli/webhook.go b/cli/webhook.go new file mode 100644 index 0000000..aa2f243 --- /dev/null +++ b/cli/webhook.go @@ -0,0 +1,141 @@ +package cli + +import ( + "bufio" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.ngrok.com/ngrok" + "golang.ngrok.com/ngrok/config" + "xurl/auth" +) + +var webhookPort int + +// CreateWebhookCommand creates the webhook command and its subcommands. +func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { + webhookCmd := &cobra.Command{ + Use: "webhook", + Short: "Manage webhooks for the X API", + Long: `Manages X API webhooks. Currently supports starting a local server with an ngrok tunnel to handle CRC checks.`, + } + + webhookStartCmd := &cobra.Command{ + Use: "start", + Short: "Start a local webhook server with an ngrok tunnel", + Long: `Starts a local HTTP server and an ngrok tunnel to listen for X API webhook events, including CRC checks.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Starting webhook server with ngrok...") + + if authInstance == nil || authInstance.TokenStore == nil { + log.Fatalf("Error: Authentication module not initialized properly.") + return + } + + oauth1Token := authInstance.TokenStore.GetOAuth1Tokens() + if oauth1Token == nil || oauth1Token.OAuth1 == nil || oauth1Token.OAuth1.ConsumerSecret == "" { + log.Fatalf("Error: OAuth 1.0a consumer secret not found. Please configure OAuth 1.0a credentials using 'xurl auth oauth1'.") + return + } + consumerSecret := oauth1Token.OAuth1.ConsumerSecret + + // Prompt for ngrok authtoken + fmt.Print("Enter your ngrok authtoken (leave empty to try NGROK_AUTHTOKEN env var): ") + reader := bufio.NewReader(os.Stdin) + ngrokAuthToken, _ := reader.ReadString('\n') + ngrokAuthToken = strings.TrimSpace(ngrokAuthToken) + + ctx := context.Background() + var tunnelOpts []ngrok.ConnectOption + if ngrokAuthToken != "" { + tunnelOpts = append(tunnelOpts, ngrok.WithAuthtoken(ngrokAuthToken)) + } else { + tunnelOpts = append(tunnelOpts, ngrok.WithAuthtokenFromEnv()) // Fallback to env + } + + forwardToAddr := fmt.Sprintf("localhost:%d", webhookPort) + fmt.Printf("Configuring ngrok to forward to local port: %d\n", webhookPort) + + ngrokListener, err := ngrok.Listen(ctx, + config.HTTPEndpoint( + config.WithForwardsTo(forwardToAddr), // Tell ngrok to forward to our specific local port + ), + tunnelOpts..., + ) + if err != nil { + log.Fatalf("Error starting ngrok tunnel: %v", err) + } + defer ngrokListener.Close() + fmt.Printf("Ngrok tunnel established. Forwarding URL: %s -> %s\n", ngrokListener.URL(), forwardToAddr) + fmt.Printf("Use this URL for your X API webhook registration: %s/webhook\n", ngrokListener.URL()) + + + http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + crcToken := r.URL.Query().Get("crc_token") + if crcToken == "" { + http.Error(w, "Error: crc_token missing from request", http.StatusBadRequest) + log.Println("Received GET /webhook without crc_token") + return + } + log.Printf("Received GET %s%s with crc_token: %s\n", r.Host, r.URL.Path, crcToken) + + mac := hmac.New(sha256.New, []byte(consumerSecret)) + mac.Write([]byte(crcToken)) + hashedToken := mac.Sum(nil) + encodedToken := base64.StdEncoding.EncodeToString(hashedToken) + + response := map[string]string{ + "response_token": "sha256=" + encodedToken, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + log.Printf("Responded to CRC check with token: %s\n", response["response_token"]) + + } else if r.Method == http.MethodPost { + log.Printf("Received POST %s%s event:\n", r.Host, r.URL.Path) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error reading request body", http.StatusInternalServerError) + log.Printf("Error reading POST body: %v\n", err) + return + } + defer r.Body.Close() + log.Printf("Body: %s\n", string(body)) + // For now, just acknowledge receipt + w.WriteHeader(http.StatusOK) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // The HTTP server will serve requests coming through the ngrok listener. + // The local port webhookPort is what ngrok forwards to internally. + fmt.Printf("Starting local HTTP server to handle requests from ngrok tunnel (forwarded from %s)...\n", ngrokListener.URL()) + if err := http.Serve(ngrokListener, nil); err != nil { + // Only log fatal if it's not a graceful shutdown of the listener (e.g. by ngrokListener.Close()) + if err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } else { + log.Println("HTTP server closed gracefully.") + } + } + log.Println("Webhook server and ngrok tunnel shut down.") + }, + } + + webhookStartCmd.Flags().IntVarP(&webhookPort, "port", "p", 8080, "Local port for the webhook server to listen on (ngrok will forward to this port)") + + webhookCmd.AddCommand(webhookStartCmd) + return webhookCmd +} \ No newline at end of file diff --git a/go.mod b/go.mod index 57f6c25..4b9da6e 100644 --- a/go.mod +++ b/go.mod @@ -12,15 +12,25 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-stack/stack v1.8.1 // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible // indirect + github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jpillora/backoff v1.0.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sys v0.25.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.ngrok.com/muxado/v2 v2.0.1 // indirect + golang.ngrok.com/ngrok v1.13.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.31.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 9cb3191..58efa51 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -10,8 +12,14 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible h1:VryeOTiaZfAzwx8xBcID1KlJCeoWSIpsNbSk+/D2LNk= +github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= +github.com/inconshreveable/log15/v3 v3.0.0-testing.5 h1:h4e0f3kjgg+RJBlKOabrohjHe47D3bbAB9BgMrc3DYA= +github.com/inconshreveable/log15/v3 v3.0.0-testing.5/go.mod h1:3GQg1SVrLoWGfRv/kAZMsdyU5cp8eFc1P3cw+Wwku94= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -28,17 +36,31 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY= +golang.ngrok.com/muxado/v2 v2.0.1/go.mod h1:wzxJYX4xiAtmwumzL+QsukVwFRXmPNv86vB8RPpOxyM= +golang.ngrok.com/ngrok v1.13.0 h1:6SeOS+DAeIaHlkDmNH5waFHv0xjlavOV3wml0Z59/8k= +golang.ngrok.com/ngrok v1.13.0/go.mod h1:BKOMdoZXfD4w6o3EtE7Cu9TVbaUWBqptrZRWnVcAuI4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -49,7 +71,11 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From b310c11ec14ddd07a696cbd69fbb171704274042 Mon Sep 17 00:00:00 2001 From: m-rosinsky Date: Tue, 6 May 2025 17:21:54 -0400 Subject: [PATCH 2/5] Added quiet and pretty printing options --- auth/auth.go | 2 + cli/webhook.go | 110 ++++++++++++++++++++++++++++++++++++------------- go.mod | 1 + go.sum | 2 + 4 files changed, 86 insertions(+), 29 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index c3c00bd..77d10ba 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -397,6 +397,7 @@ func getOAuth2Scopes() []string { "like.read", "users.email", "dm.read", + "webhook.read", } writeScopes := []string{ @@ -410,6 +411,7 @@ func getOAuth2Scopes() []string { "list.write", "media.write", "dm.write", + "webhook.write", } otherScopes := []string{ diff --git a/cli/webhook.go b/cli/webhook.go index aa2f243..44b675a 100644 --- a/cli/webhook.go +++ b/cli/webhook.go @@ -14,13 +14,18 @@ import ( "os" "strings" + "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/tidwall/pretty" "golang.ngrok.com/ngrok" "golang.ngrok.com/ngrok/config" "xurl/auth" ) var webhookPort int +var outputFileName string // To store the output file name from the flag +var quietMode bool // To store the quiet flag state +var prettyMode bool // To store the pretty-print flag state // CreateWebhookCommand creates the webhook command and its subcommands. func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { @@ -33,24 +38,37 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { webhookStartCmd := &cobra.Command{ Use: "start", Short: "Start a local webhook server with an ngrok tunnel", - Long: `Starts a local HTTP server and an ngrok tunnel to listen for X API webhook events, including CRC checks.`, + Long: `Starts a local HTTP server and an ngrok tunnel to listen for X API webhook events, including CRC checks. POST request bodies can be saved to a file using the -o flag. Use -q for quieter console logging of POST events. Use -p to pretty-print JSON POST bodies in the console.`, Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Starting webhook server with ngrok...") + color.Cyan("Starting webhook server with ngrok...") if authInstance == nil || authInstance.TokenStore == nil { - log.Fatalf("Error: Authentication module not initialized properly.") - return + color.Red("Error: Authentication module not initialized properly.") + os.Exit(1) } oauth1Token := authInstance.TokenStore.GetOAuth1Tokens() if oauth1Token == nil || oauth1Token.OAuth1 == nil || oauth1Token.OAuth1.ConsumerSecret == "" { - log.Fatalf("Error: OAuth 1.0a consumer secret not found. Please configure OAuth 1.0a credentials using 'xurl auth oauth1'.") - return + color.Red("Error: OAuth 1.0a consumer secret not found. Please configure OAuth 1.0a credentials using 'xurl auth oauth1'.") + os.Exit(1) } consumerSecret := oauth1Token.OAuth1.ConsumerSecret + // Handle output file if -o flag is used + var outputFile *os.File + var errOpenFile error + if outputFileName != "" { + outputFile, errOpenFile = os.OpenFile(outputFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if errOpenFile != nil { + color.Red("Error opening output file %s: %v", outputFileName, errOpenFile) + os.Exit(1) + } + defer outputFile.Close() + color.Green("Logging POST request bodies to: %s", outputFileName) + } + // Prompt for ngrok authtoken - fmt.Print("Enter your ngrok authtoken (leave empty to try NGROK_AUTHTOKEN env var): ") + color.Yellow("Enter your ngrok authtoken (leave empty to try NGROK_AUTHTOKEN env var): ") reader := bufio.NewReader(os.Stdin) ngrokAuthToken, _ := reader.ReadString('\n') ngrokAuthToken = strings.TrimSpace(ngrokAuthToken) @@ -60,35 +78,38 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { if ngrokAuthToken != "" { tunnelOpts = append(tunnelOpts, ngrok.WithAuthtoken(ngrokAuthToken)) } else { + color.Cyan("Attempting to use NGROK_AUTHTOKEN environment variable for ngrok authentication.") tunnelOpts = append(tunnelOpts, ngrok.WithAuthtokenFromEnv()) // Fallback to env } forwardToAddr := fmt.Sprintf("localhost:%d", webhookPort) - fmt.Printf("Configuring ngrok to forward to local port: %d\n", webhookPort) + color.Cyan("Configuring ngrok to forward to local port: %s", color.MagentaString("%d", webhookPort)) ngrokListener, err := ngrok.Listen(ctx, config.HTTPEndpoint( - config.WithForwardsTo(forwardToAddr), // Tell ngrok to forward to our specific local port + config.WithForwardsTo(forwardToAddr), ), tunnelOpts..., ) if err != nil { - log.Fatalf("Error starting ngrok tunnel: %v", err) + color.Red("Error starting ngrok tunnel: %v", err) + os.Exit(1) } defer ngrokListener.Close() - fmt.Printf("Ngrok tunnel established. Forwarding URL: %s -> %s\n", ngrokListener.URL(), forwardToAddr) - fmt.Printf("Use this URL for your X API webhook registration: %s/webhook\n", ngrokListener.URL()) - + + color.Green("Ngrok tunnel established!") + fmt.Printf(" Forwarding URL: %s -> %s\n", color.HiGreenString(ngrokListener.URL()), color.MagentaString(forwardToAddr)) + color.Yellow("Use this URL for your X API webhook registration: %s/webhook", color.HiGreenString(ngrokListener.URL())) http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { crcToken := r.URL.Query().Get("crc_token") if crcToken == "" { http.Error(w, "Error: crc_token missing from request", http.StatusBadRequest) - log.Println("Received GET /webhook without crc_token") + log.Printf("[WARN] Received GET /webhook without crc_token") return } - log.Printf("Received GET %s%s with crc_token: %s\n", r.Host, r.URL.Path, crcToken) + log.Printf("[INFO] Received GET %s%s with crc_token: %s", color.BlueString(r.Host), color.BlueString(r.URL.Path), color.YellowString(crcToken)) mac := hmac.New(sha256.New, []byte(consumerSecret)) mac.Write([]byte(crcToken)) @@ -100,41 +121,72 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) - log.Printf("Responded to CRC check with token: %s\n", response["response_token"]) + log.Printf("[INFO] Responded to CRC check with token: %s", color.GreenString(response["response_token"])) } else if r.Method == http.MethodPost { - log.Printf("Received POST %s%s event:\n", r.Host, r.URL.Path) - body, err := io.ReadAll(r.Body) + bodyBytes, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Error reading request body", http.StatusInternalServerError) - log.Printf("Error reading POST body: %v\n", err) + log.Printf("[ERROR] Error reading POST body: %v", err) return } defer r.Body.Close() - log.Printf("Body: %s\n", string(body)) - // For now, just acknowledge receipt + + if quietMode { + log.Printf("[INFO] Received POST %s%s event (quiet mode).", color.BlueString(r.Host), color.BlueString(r.URL.Path)) + } else { + log.Printf("[INFO] Received POST %s%s event:", color.BlueString(r.Host), color.BlueString(r.URL.Path)) + if prettyMode { + // Attempt to pretty-print if it's JSON + var jsonData interface{} + if json.Unmarshal(bodyBytes, &jsonData) == nil { + prettyColored := pretty.Color(pretty.Pretty(bodyBytes), pretty.TerminalStyle) + log.Printf("[DATA] Body:\n%s", string(prettyColored)) + } else { + // Not valid JSON or some other error, print as raw string + log.Printf("[DATA] Body (raw, not valid JSON for pretty print):\n%s", string(bodyBytes)) + } + } else { + log.Printf("[DATA] Body: %s", string(bodyBytes)) + } + } + + // Write to output file if specified + if outputFile != nil { + if _, err := outputFile.Write(bodyBytes); err != nil { + log.Printf("[ERROR] Error writing POST body to output file %s: %v", outputFileName, err) + } else { + // Add a separator for readability + if _, err := outputFile.WriteString("\n--------------------\n"); err != nil { + log.Printf("[ERROR] Error writing separator to output file %s: %v", outputFileName, err) + } + log.Printf("[INFO] POST body written to %s", color.GreenString(outputFileName)) + } + } + w.WriteHeader(http.StatusOK) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } }) - - // The HTTP server will serve requests coming through the ngrok listener. - // The local port webhookPort is what ngrok forwards to internally. - fmt.Printf("Starting local HTTP server to handle requests from ngrok tunnel (forwarded from %s)...\n", ngrokListener.URL()) + + color.Cyan("Starting local HTTP server to handle requests from ngrok tunnel (forwarded from %s)...", color.HiGreenString(ngrokListener.URL())) if err := http.Serve(ngrokListener, nil); err != nil { - // Only log fatal if it's not a graceful shutdown of the listener (e.g. by ngrokListener.Close()) if err != http.ErrServerClosed { - log.Fatalf("HTTP server error: %v", err) + color.Red("HTTP server error: %v", err) + os.Exit(1) } else { - log.Println("HTTP server closed gracefully.") + color.Yellow("HTTP server closed gracefully.") } } - log.Println("Webhook server and ngrok tunnel shut down.") + color.Yellow("Webhook server and ngrok tunnel shut down.") }, } webhookStartCmd.Flags().IntVarP(&webhookPort, "port", "p", 8080, "Local port for the webhook server to listen on (ngrok will forward to this port)") + webhookStartCmd.Flags().StringVarP(&outputFileName, "output", "o", "", "File to write incoming POST request bodies to") + webhookStartCmd.Flags().BoolVarP(&quietMode, "quiet", "q", false, "Enable quiet mode (logs only that a POST event was received, not the full body to console)") + webhookStartCmd.Flags().BoolVarP(&prettyMode, "pretty", "P", false, "Pretty-print JSON POST bodies in console output (ignored if -q is used)") webhookCmd.AddCommand(webhookStartCmd) return webhookCmd diff --git a/go.mod b/go.mod index 4b9da6e..5f693ef 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tidwall/pretty v1.2.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.ngrok.com/muxado/v2 v2.0.1 // indirect golang.ngrok.com/ngrok v1.13.0 // indirect diff --git a/go.sum b/go.sum index 58efa51..c1259bc 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY= From 3c3298df9c7bad9afbafea02ce7255bfe7a19437 Mon Sep 17 00:00:00 2001 From: m-rosinsky Date: Tue, 6 May 2025 17:29:15 -0400 Subject: [PATCH 3/5] Added README section --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 480a0cc..34f1661 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,33 @@ You can also force streaming mode for any endpoint using the `--stream` or `-s` xurl -s /2/users/me ``` +### Temporary Webhook Setup + +`xurl` can help you quickly set up a temporary webhook URL to receive events from the X API. This is useful for development and testing. + +1. **Start the local webhook server with ngrok:** + + Run the `webhook start` command. This will start a local server and use ngrok to create a public URL that forwards to your local server. You will be prompted for your ngrok authtoken if it's not already configured via the `NGROK_AUTHTOKEN` environment variable. + + ```bash + xurl webhook start + # Or with a specific port and output file for POST bodies + xurl webhook start -p 8081 -o webhook_events.log + ``` + + The command will output an ngrok URL (e.g., `https://your-unique-id.ngrok-free.app/webhook`). Note this URL. + +2. **Register the webhook with the X API:** + + Use the ngrok URL obtained in the previous step to register your webhook. You'll typically use app authentication for this. + + ```bash + # Replace https://your-ngrok-url.ngrok-free.app/webhook with the actual URL from the previous step + xurl --auth app /2/webhooks -d '{"url": ""}' -X POST + ``` + + Your local `xurl webhook start` server will then handle the CRC handshake from Twitter and log incoming POST events (and write them to a file if `-o` was used). + ### Media Upload The tool supports uploading media files to the X API using the chunked upload process. From 5925e0a0369402262a2b590acc30e5a6f1effbbe Mon Sep 17 00:00:00 2001 From: m-rosinsky Date: Tue, 6 May 2025 17:37:27 -0400 Subject: [PATCH 4/5] Removed new scopes --- auth/auth.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 77d10ba..c3c00bd 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -397,7 +397,6 @@ func getOAuth2Scopes() []string { "like.read", "users.email", "dm.read", - "webhook.read", } writeScopes := []string{ @@ -411,7 +410,6 @@ func getOAuth2Scopes() []string { "list.write", "media.write", "dm.write", - "webhook.write", } otherScopes := []string{ From f5c7c2c9728e5285f10096561b6bed2f0417ed71 Mon Sep 17 00:00:00 2001 From: Michael Rosinsky Date: Tue, 6 May 2025 17:47:02 -0400 Subject: [PATCH 5/5] Update author email for CLA