diff --git a/src/github.com/matrix-org/go-neb/services/echo/echo.go b/src/github.com/matrix-org/go-neb/services/echo/echo.go index d102b20..ccdc5e4 100644 --- a/src/github.com/matrix-org/go-neb/services/echo/echo.go +++ b/src/github.com/matrix-org/go-neb/services/echo/echo.go @@ -4,6 +4,7 @@ import ( "github.com/matrix-org/go-neb/matrix" "github.com/matrix-org/go-neb/plugin" "github.com/matrix-org/go-neb/types" + "net/http" "strings" ) @@ -29,6 +30,9 @@ func (e *echoService) Plugin(roomID string) plugin.Plugin { }, } } +func (e *echoService) OnReceiveWebhook(w http.ResponseWriter, req http.Request) { + w.WriteHeader(200) // Do nothing +} func init() { types.RegisterService(func(serviceID string) types.Service { diff --git a/src/github.com/matrix-org/go-neb/services/github/github.go b/src/github.com/matrix-org/go-neb/services/github/github.go index c81d1a0..9d86414 100644 --- a/src/github.com/matrix-org/go-neb/services/github/github.go +++ b/src/github.com/matrix-org/go-neb/services/github/github.go @@ -6,8 +6,10 @@ import ( "github.com/google/go-github/github" "github.com/matrix-org/go-neb/matrix" "github.com/matrix-org/go-neb/plugin" + "github.com/matrix-org/go-neb/services/github/webhook" "github.com/matrix-org/go-neb/types" "golang.org/x/oauth2" + "net/http" "regexp" "strconv" ) @@ -60,6 +62,10 @@ func (s *githubService) Plugin(roomID string) plugin.Plugin { }, } } +func (s *githubService) OnReceiveWebhook(w http.ResponseWriter, req http.Request) { + // defer entirely to the webhook package + webhook.OnReceiveRequest(w, req, "") +} // githubClient returns a github Client which can perform Github API operations. // If `token` is empty, a non-authenticated client will be created. This should be diff --git a/src/github.com/matrix-org/go-neb/services/github/webhook/webhook.go b/src/github.com/matrix-org/go-neb/services/github/webhook/webhook.go new file mode 100644 index 0000000..c11cc11 --- /dev/null +++ b/src/github.com/matrix-org/go-neb/services/github/webhook/webhook.go @@ -0,0 +1,259 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + log "github.com/Sirupsen/logrus" + "github.com/google/go-github/github" + "html" + "io/ioutil" + "net/http" + "strings" +) + +// OnReceiveRequest processes incoming github webhook requests. The secretToken +// parameter is optional. +func OnReceiveRequest(w http.ResponseWriter, r http.Request, secretToken string) { + // Verify the HMAC signature if NEB was configured with a secret token + eventType := r.Header.Get("X-GitHub-Event") + signatureSHA1 := r.Header.Get("X-Hub-Signature") + content, err := ioutil.ReadAll(r.Body) + if err != nil { + log.WithError(err).Print("Failed to read Github webhook body") + w.WriteHeader(400) + return + } + // Verify request if a secret token has been supplied. + if secretToken != "" { + sigHex := strings.Split(signatureSHA1, "=")[1] + var sigBytes []byte + sigBytes, err = hex.DecodeString(sigHex) + if err != nil { + log.WithError(err).WithField("X-Hub-Signature", sigHex).Print( + "Failed to decode signature as hex.") + w.WriteHeader(400) + return + } + + if !checkMAC([]byte(content), sigBytes, []byte(secretToken)) { + log.WithFields(log.Fields{ + "X-Hub-Signature": signatureSHA1, + }).Print("Received Github event which failed MAC check.") + w.WriteHeader(403) + return + } + } + + log.WithFields(log.Fields{ + "event_type": eventType, + "signature": signatureSHA1, + }).Print("Received Github event") + + htmlStr, repo, err := parseGithubEvent(eventType, content) + if err != nil { + log.WithError(err).Print("Failed to parse github event") + w.WriteHeader(500) + return + } + + if err := handleWebhookEvent(eventType, htmlStr, repo); err != nil { + log.WithError(err).Print("Failed to handle Github webhook event") + w.WriteHeader(500) + return + } + w.WriteHeader(200) +} + +// checkMAC reports whether messageMAC is a valid HMAC tag for message. +func checkMAC(message, messageMAC, key []byte) bool { + mac := hmac.New(sha1.New, key) + mac.Write(message) + expectedMAC := mac.Sum(nil) + return hmac.Equal(messageMAC, expectedMAC) +} + +// parseGithubEvent parses a github event type and JSON data and returns an explanatory +// HTML string and the github repository this event affects, or an error. +func parseGithubEvent(eventType string, data []byte) (string, *github.Repository, error) { + if eventType == "pull_request" { + var ev github.PullRequestEvent + if err := json.Unmarshal(data, &ev); err != nil { + return "", nil, err + } + return pullRequestHTMLMessage(ev), ev.Repo, nil + } else if eventType == "issues" { + var ev github.IssuesEvent + if err := json.Unmarshal(data, &ev); err != nil { + return "", nil, err + } + return issueHTMLMessage(ev), ev.Repo, nil + } else if eventType == "push" { + var ev github.PushEvent + if err := json.Unmarshal(data, &ev); err != nil { + return "", nil, err + } + + // The 'push' event repository format is subtly different from normal, so munge the bits we need. + fullName := *ev.Repo.Owner.Name + "/" + *ev.Repo.Name + repo := github.Repository{ + Owner: &github.User{ + Login: ev.Repo.Owner.Name, + }, + Name: ev.Repo.Name, + FullName: &fullName, + } + return pushHTMLMessage(ev), &repo, nil + } else if eventType == "issue_comment" { + var ev github.IssueCommentEvent + if err := json.Unmarshal(data, &ev); err != nil { + return "", nil, err + } + return issueCommentHTMLMessage(ev), ev.Repo, nil + } else if eventType == "pull_request_review_comment" { + var ev github.PullRequestReviewCommentEvent + if err := json.Unmarshal(data, &ev); err != nil { + return "", nil, err + } + return prReviewCommentHTMLMessage(ev), ev.Repo, nil + } + return "", nil, fmt.Errorf("Unrecognized event type") +} + +func handleWebhookEvent(eventType string, htmlStr string, repo *github.Repository) error { + return nil +} + +func pullRequestHTMLMessage(p github.PullRequestEvent) string { + var actionTarget string + if p.PullRequest.Assignee != nil && p.PullRequest.Assignee.Login != nil { + actionTarget = fmt.Sprintf(" to %s", *p.PullRequest.Assignee.Login) + } + return fmt.Sprintf( + "[%s] %s %s pull request #%d: %s [%s]%s - %s", + html.EscapeString(*p.Repo.FullName), + html.EscapeString(*p.Sender.Login), + html.EscapeString(*p.Action), + *p.Number, + html.EscapeString(*p.PullRequest.Title), + html.EscapeString(*p.PullRequest.State), + html.EscapeString(actionTarget), + html.EscapeString(*p.PullRequest.HTMLURL), + ) +} + +func issueHTMLMessage(p github.IssuesEvent) string { + var actionTarget string + if p.Issue.Assignee != nil && p.Issue.Assignee.Login != nil { + actionTarget = fmt.Sprintf(" to %s", *p.Issue.Assignee.Login) + } + return fmt.Sprintf( + "[%s] %s %s issue #%d: %s [%s]%s - %s", + html.EscapeString(*p.Repo.FullName), + html.EscapeString(*p.Sender.Login), + html.EscapeString(*p.Action), + *p.Issue.Number, + html.EscapeString(*p.Issue.Title), + html.EscapeString(*p.Issue.State), + html.EscapeString(actionTarget), + html.EscapeString(*p.Issue.HTMLURL), + ) +} + +func issueCommentHTMLMessage(p github.IssueCommentEvent) string { + var kind string + if p.Issue.PullRequestLinks == nil { + kind = "issue" + } else { + kind = "pull request" + } + + return fmt.Sprintf( + "[%s] %s commented on %s's %s #%d: %s - %s", + html.EscapeString(*p.Repo.FullName), + html.EscapeString(*p.Comment.User.Login), + html.EscapeString(*p.Issue.User.Login), + kind, + *p.Issue.Number, + html.EscapeString(*p.Issue.Title), + html.EscapeString(*p.Issue.HTMLURL), + ) +} + +func prReviewCommentHTMLMessage(p github.PullRequestReviewCommentEvent) string { + assignee := "None" + if p.PullRequest.Assignee != nil { + assignee = html.EscapeString(*p.PullRequest.Assignee.Login) + } + return fmt.Sprintf( + "[%s] %s made a line comment on %s's pull request #%d (assignee: %s): %s - %s", + html.EscapeString(*p.Repo.FullName), + html.EscapeString(*p.Sender.Login), + html.EscapeString(*p.PullRequest.User.Login), + *p.PullRequest.Number, + assignee, + html.EscapeString(*p.PullRequest.Title), + html.EscapeString(*p.Comment.HTMLURL), + ) +} + +func pushHTMLMessage(p github.PushEvent) string { + // /refs/heads/alice/branch-name => alice/branch-name + branch := strings.Replace(*p.Ref, "refs/heads/", "", -1) + + // this branch was deleted, no HeadCommit object and deleted=true + if p.HeadCommit == nil && p.Deleted != nil && *p.Deleted { + return fmt.Sprintf( + `[%s] %s deleted %s`, + html.EscapeString(*p.Repo.FullName), + html.EscapeString(*p.Pusher.Name), + html.EscapeString(branch), + ) + } + + if p.Commits != nil && len(p.Commits) > 1 { + // multi-commit message + // [] pushed commits to : + // + var cList []string + for _, c := range p.Commits { + cList = append(cList, fmt.Sprintf( + `%s: %s`, + html.EscapeString(nameForAuthor(c.Author)), + html.EscapeString(*c.Message), + )) + } + return fmt.Sprintf( + `[%s] %s pushed %d commits to %s: %s
%s`, + html.EscapeString(*p.Repo.FullName), + html.EscapeString(nameForAuthor(p.HeadCommit.Committer)), + len(p.Commits), + html.EscapeString(branch), + html.EscapeString(*p.HeadCommit.URL), + strings.Join(cList, "
"), + ) + } + + // single commit message + // [] pushed to : - + return fmt.Sprintf( + `[%s] %s pushed to %s: %s - %s`, + html.EscapeString(*p.Repo.FullName), + html.EscapeString(nameForAuthor(p.HeadCommit.Committer)), + html.EscapeString(branch), + html.EscapeString(*p.HeadCommit.Message), + html.EscapeString(*p.HeadCommit.URL), + ) +} + +func nameForAuthor(a *github.CommitAuthor) string { + if a == nil { + return "" + } + if a.Login != nil { // prefer to use their GH username than the name they commited as + return *a.Login + } + return *a.Name +} diff --git a/src/github.com/matrix-org/go-neb/types/types.go b/src/github.com/matrix-org/go-neb/types/types.go index 7bdc6a8..c5dc8ce 100644 --- a/src/github.com/matrix-org/go-neb/types/types.go +++ b/src/github.com/matrix-org/go-neb/types/types.go @@ -3,7 +3,7 @@ package types import ( "errors" "github.com/matrix-org/go-neb/plugin" - // "net/http" + "net/http" "net/url" ) @@ -32,7 +32,7 @@ type Service interface { ServiceType() string RoomIDs() []string Plugin(roomID string) plugin.Plugin - // OnReceiveWebhook(req http.Request) + OnReceiveWebhook(w http.ResponseWriter, req http.Request) } var servicesByType = map[string]func(string) Service{}