diff --git a/mocks/integration_create_payload.json b/mocks/integration_create_payload.json new file mode 100644 index 0000000..77df6b0 --- /dev/null +++ b/mocks/integration_create_payload.json @@ -0,0 +1,19 @@ +{ + "type": "integration.create", + "data": { + "connection_id": "112402021105124", + "webhook_secret": "whs_abcd", + "project": { + "id": "1230954036934033243", + "platform": "discord", + "platform_id": "3949456393249234923", + "type": "bot" + }, + "user": { + "id": "3949456393249234923", + "platform_id": "3949456393249234923", + "name": "username", + "avatar_url": "" + } + } +} \ No newline at end of file diff --git a/mocks/integration_delete_payload.json b/mocks/integration_delete_payload.json new file mode 100644 index 0000000..cb44375 --- /dev/null +++ b/mocks/integration_delete_payload.json @@ -0,0 +1,6 @@ +{ + "type": "integration.delete", + "data": { + "connection_id": "112402021105124" + } +} \ No newline at end of file diff --git a/mocks/vote_create_payload.json b/mocks/vote_create_payload.json new file mode 100644 index 0000000..6850196 --- /dev/null +++ b/mocks/vote_create_payload.json @@ -0,0 +1,21 @@ +{ + "type": "vote.create", + "data": { + "id": "808499215864008704", + "weight": 1, + "created_at": "2026-02-09T00:47:14.2510149+00:00", + "expires_at": "2026-02-09T12:47:14.2510149+00:00", + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + }, + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + } + } +} \ No newline at end of file diff --git a/mocks/webhook_test_payload.json b/mocks/webhook_test_payload.json new file mode 100644 index 0000000..b7a7432 --- /dev/null +++ b/mocks/webhook_test_payload.json @@ -0,0 +1,17 @@ +{ + "type": "webhook.test", + "data": { + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + }, + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + } + } +} \ No newline at end of file diff --git a/payload.go b/payload.go new file mode 100644 index 0000000..b08b66d --- /dev/null +++ b/payload.go @@ -0,0 +1,37 @@ +package topgg + +import "time" + +// IntegrationCreatePayload represents an 'integration.create' webhook.payload. Fires when a user has connected to your webhook integration. +type IntegrationCreatePayload struct { + ConnectionId string `json:"connection_id"` // The unique identifier for this connection. + Secret string `json:"webhook_secret"` // The secret used to verify future webhook deliveries. + Project PartialProject `json:"project"` // The project that the integration refers to. + User User `json:"user"` // The user who triggered this event. +} + +// IntegrationDeletePayload represents an 'integration.delete' webhook.payload. Fires when a user has disconnected from your webhook integration. +type IntegrationDeletePayload struct { + ConnectionId string `json:"connection_id"` // The unique identifier for this connection. +} + +// TestPayload represents a 'webhook.test' webhook.payload. Fires upon sent test from the project dashboard. +type TestPayload struct { + Project PartialProject `json:"project"` // The project that the test refers to. + User User `json:"user"` // The user who triggered this test. +} + +// VoteCreatePayload represents a 'vote.create' webhook.payload. Fires when a user votes for your project. +type VoteCreatePayload struct { + Id string `json:"id"` // The vote's ID. + Weight int `json:"weight"` // The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. + VotedAt time.Time `json:"created_at"` // When the vote was cast. + ExpiresAt time.Time `json:"expires_at"` // When the vote expires and the user is required to vote again. + Project PartialProject `json:"project"` // The project that received this vote. + User User `json:"user"` // The user who voted for this project. +} + +// Payload represents all possible webhook payloads. +type Payload interface { + IntegrationCreatePayload | IntegrationDeletePayload | TestPayload | VoteCreatePayload +} diff --git a/webhook.go b/webhook.go deleted file mode 100644 index fc01bf4..0000000 --- a/webhook.go +++ /dev/null @@ -1,110 +0,0 @@ -package dbl - -import ( - "encoding/json" - "io/ioutil" - "net/http" - "net/url" -) - -type ListenerFunc func(*WebhookPayload) - -type WebhookListener struct { - token string - handler ListenerFunc - mux *http.ServeMux -} - -type WebhookPayload struct { - // ID of the bot that received a vote - Bot string - - // ID of the user who voted - User string - - // The type of the vote (should always be "upvote" except when using the test button it's "test") - Type string - - // Whether the weekend multiplier is in effect, meaning users votes count as two - IsWeekend bool - - // Query string params found on the /bot/:ID/vote page. Example: ?a=1&b=2 - Query url.Values -} - -type wPayload struct { - // ID of the bot that received a vote - Bot string `json:"bot"` - - // ID of the user who voted - User string `json:"user"` - - // The type of the vote (should always be "upvote" except when using the test button it's "test") - Type string `json:"type"` - - // Whether the weekend multiplier is in effect, meaning users votes count as two - IsWeekend bool `json:"isWeekend"` - - // Query string params found on the /bot/:ID/vote page. Example: ?a=1&b=2 - Query string `json:"query"` -} - -// Create a new webhook listener -func NewListener(token string, handler func(*WebhookPayload)) *WebhookListener { - return &WebhookListener{ - token: token, - handler: ListenerFunc(handler), - } -} - -// Starts listening on specific address. A Blocking call. -// Returns non-nil error from ListenAndServe -func (wl *WebhookListener) Serve(addr string) error { - wl.mux = http.NewServeMux() - - wl.mux.HandleFunc("/", wl.handlePayload) - - return http.ListenAndServe(addr, wl.mux) -} - -func (wl *WebhookListener) handlePayload(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.WriteHeader(http.StatusMethodNotAllowed) - - return - } - - if r.Header.Get("Authorization") != wl.token { - w.WriteHeader(http.StatusUnauthorized) - - return - } - - body, err := ioutil.ReadAll(r.Body) - - if err != nil { - return - } - - p := &wPayload{} - - if err = json.Unmarshal(body, p); err != nil { - return - } - - m, err := url.ParseQuery(p.Query) - - if err != nil { - return - } - - w.WriteHeader(http.StatusNoContent) - - wl.handler(&WebhookPayload{ - Bot: p.Bot, - User: p.User, - Type: p.Type, - IsWeekend: p.IsWeekend, - Query: m, - }) -} diff --git a/webhook_test.go b/webhook_test.go deleted file mode 100644 index 2647c9c..0000000 --- a/webhook_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package dbl - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -const ( - testToken = "wblAV@d!Od9uL761Rz23BEQC$#YCJdQ0nDlZUEfnDxY" -) - -var ( - testPayload = []byte(`{"bot":"441751906428256277","user":"105122038586286080","type":"upvote","isWeekend":false,"query":""}`) - testListener = NewListener(testToken, func(p *WebhookPayload) {}) -) - -func TestHookMethod(t *testing.T) { - rec := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodGet, "/", bytes.NewBuffer(testPayload)) - - testListener.handlePayload(rec, req) - - assert.Equal(t, http.StatusMethodNotAllowed, rec.Code, "GET method should not be allowed") -} - -func TestHookAuthentication(t *testing.T) { - rec := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(testPayload)) - - testListener.handlePayload(rec, req) - - assert.Equal(t, http.StatusUnauthorized, rec.Code, "Unauthorized request should not be processed") -} - -func TestWebhookProcessing(t *testing.T) { - rec := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(testPayload)) - req.Header.Set("Authorization", testToken) - - testListener.handlePayload(rec, req) - - assert.Equal(t, http.StatusNoContent, rec.Code, "Request should succeed w/o content") -} diff --git a/webhooks.go b/webhooks.go new file mode 100644 index 0000000..97fe034 --- /dev/null +++ b/webhooks.go @@ -0,0 +1,184 @@ +package topgg + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "strconv" + "strings" + "time" +) + +type rawListener = func(http.ResponseWriter, json.RawMessage, string) + +// Webhooks represents a Top.gg webhook manager. +type Webhooks struct { + Secret string // The secret to use to authorize external requests. + Timeout time.Duration // The timeout for reading payloads. Defaults to five seconds. + TimestampWindow time.Duration // The accepted time window for timestamps before they get rejected to help mitigate replay attacks. Defaults to 30 seconds. + listeners map[string]rawListener +} + +// NewWebhooks is a function that creates a new webhook manager instance. +func NewWebhooks(Secret string) *Webhooks { + return &Webhooks{ + Secret: Secret, + Timeout: 5 * time.Second, + TimestampWindow: 30 * time.Second, + listeners: make(map[string]rawListener), + } +} + +func reportPayloadUnmarshalFailure(body []byte, err error) { + slog.Warn(fmt.Sprintf("Unable to parse Top.gg webhook payload. Please report this bug to the SDK maintainers.\nCause: %s\n--- BEGIN BODY DUMP ---\n%s\n--- END BODY DUMP ---", err.Error(), body)) +} + +func newRawListener[P Payload](listener func(http.ResponseWriter, *P, string)) rawListener { + return func(res http.ResponseWriter, rawPayload json.RawMessage, trace string) { + var payload P + + if err := json.Unmarshal(rawPayload, &payload); err != nil { + reportPayloadUnmarshalFailure(rawPayload, err) + + res.WriteHeader(http.StatusNoContent) + } else { + listener(res, &payload, trace) + } + } +} + +// OnIntegrationCreate is a method that registers a listener that fires when a user has connected to your webhook integration. +func (webhooks *Webhooks) OnIntegrationCreate(listener func(http.ResponseWriter, *IntegrationCreatePayload, string)) { + webhooks.listeners["integration.create"] = newRawListener(listener) +} + +// OnIntegrationDelete is a method that registers a listener that fires when a user has disconnected from your webhook integration. +func (webhooks *Webhooks) OnIntegrationDelete(listener func(http.ResponseWriter, *IntegrationDeletePayload, string)) { + webhooks.listeners["integration.delete"] = newRawListener(listener) +} + +// OnTest is a method that registers a listener that fires when a test webhook was sent from the dashboard. +func (webhooks *Webhooks) OnTest(listener func(http.ResponseWriter, *TestPayload, string)) { + webhooks.listeners["webhook.test"] = newRawListener(listener) +} + +// OnVoteCreate is a method that registers a listener that fires when a user votes for your project. +func (webhooks *Webhooks) OnVoteCreate(listener func(http.ResponseWriter, *VoteCreatePayload, string)) { + webhooks.listeners["vote.create"] = newRawListener(listener) +} + +type rawPayload struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// Handler is the handler function to be passed to http.HandleFunc. +func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { + currentTimestamp := time.Now().UTC().Unix() + + if req.Method != http.MethodPost { + res.WriteHeader(http.StatusMethodNotAllowed) + + return + } + + signatureHeader := req.Header.Get("x-topgg-signature") + trace := req.Header.Get("x-topgg-trace") + + if signatureHeader == "" || trace == "" { + res.WriteHeader(http.StatusUnauthorized) + + return + } + + timestamp := int64(0) + var signature string + + for pair := range strings.SplitSeq(signatureHeader, ",") { + parts := strings.Split(pair, "=") + + if len(parts) != 2 { + res.WriteHeader(http.StatusUnprocessableEntity) + + return + } + + switch parts[0] { + case "t": + t, err := strconv.Atoi(parts[1]) + + if err != nil { + break + } + + timestamp = int64(t) + case APIVersion: + signature = parts[1] + } + } + + if timestamp == 0 || signature == "" { + res.WriteHeader(http.StatusUnprocessableEntity) + + return + } else if math.Abs(currentTimestamp-timestamp) > int64(webhooks.TimestampWindow.Seconds()) { + res.WriteHeader(http.StatusForbidden) + + return + } + + defer req.Body.Close() + + controller := http.NewResponseController(res) + + if err := controller.SetReadDeadline(time.Now().Add(webhooks.Timeout)); err != nil { + res.WriteHeader(http.StatusInternalServerError) + + return + } + + body, err := io.ReadAll(io.LimitReader(req.Body, 2*1024*1024)) + + if err != nil { + res.WriteHeader(http.StatusBadRequest) + + return + } + + mac := hmac.New(sha256.New, []byte(webhooks.Secret)) + mac.Write(fmt.Appendf(nil, "%d.%s", timestamp, body)) + + digest := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(digest), []byte(signature)) { + res.WriteHeader(http.StatusForbidden) + + return + } + + payload := &rawPayload{} + + if err = json.Unmarshal(body, payload); err != nil { + reportPayloadUnmarshalFailure(body, err) + + res.WriteHeader(http.StatusNoContent) + + return + } + + for event, listener := range webhooks.listeners { + if event == payload.Type { + listener(res, body, trace) + + return + } + } + + res.WriteHeader(http.StatusNoContent) +} diff --git a/webhooks_test.go b/webhooks_test.go new file mode 100644 index 0000000..8726243 --- /dev/null +++ b/webhooks_test.go @@ -0,0 +1,84 @@ +package topgg + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const mockWebhookSecret = "testsecret1234" +const mockWebhookTrace = "trace" + +var webhookEvents = []string{"integration.create", "integration.delete", "webhook.test", "vote.create"} + +func defaultResponse(res http.ResponseWriter, name, trace string) { + res.WriteHeader(http.StatusOK) + res.Write(fmt.Appendf(nil, "%s:%s", name, trace)) +} + +func mockSignature(body []byte) string { + timestamp := time.Now().UTC().Unix() + + mac := hmac.New(sha256.New, []byte(mockWebhookSecret)) + mac.Write(fmt.Appendf(nil, "%d.%s", timestamp, body)) + + digest := hex.EncodeToString(mac.Sum(nil)) + + return fmt.Sprintf("t=%d,"+APIVersion+"=%s", timestamp, digest) +} + +func TestWebhooks(t *testing.T) { + webhooks := NewWebhooks(mockWebhookSecret) + + webhooks.OnIntegrationCreate(func(res http.ResponseWriter, payload *IntegrationCreatePayload, trace string) { + defaultResponse(res, "integration.create", trace) + }) + + webhooks.OnIntegrationDelete(func(res http.ResponseWriter, payload *IntegrationDeletePayload, trace string) { + defaultResponse(res, "integration.delete", trace) + }) + + webhooks.OnTest(func(res http.ResponseWriter, payload *TestPayload, trace string) { + defaultResponse(res, "webhook.test", trace) + }) + + webhooks.OnVoteCreate(func(res http.ResponseWriter, payload *VoteCreatePayload, trace string) { + defaultResponse(res, "vote.create", trace) + }) + + for _, event := range webhookEvents { + rec := httptest.NewRecorder() + + file, err := os.Open(fmt.Sprintf("mocks/%s_payload.json", strings.ReplaceAll(event, ".", "_"))) + + assert.Nilf(t, err, "The mock request body JSON file for %s must be available.", event) + + defer file.Close() + + body, err := io.ReadAll(file) + + assert.Nilf(t, err, "The mock request body JSON file for %s must be readable.", event) + + req := httptest.NewRequest(http.MethodPost, "/webhook", io.NopCloser(bytes.NewReader(body))) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-topgg-signature", mockSignature(body)) + req.Header.Set("x-topgg-trace", mockWebhookTrace) + + webhooks.Handler(rec, req) + + assert.Equalf(t, http.StatusOK, rec.Code, "Sending a %s payload must be responded with 200.", event) + assert.Equalf(t, fmt.Sprintf("%s:%s", event, mockWebhookTrace), rec.Body.String(), "Sending a %s payload must be responded with the expected response body.", event) + } +}