package github import ( "context" "fmt" "net/http" "sort" "strings" gogithub "github.com/google/go-github/github" "github.com/matrix-org/go-neb/database" "github.com/matrix-org/go-neb/services/github/client" "github.com/matrix-org/go-neb/services/github/webhook" "github.com/matrix-org/go-neb/types" log "github.com/sirupsen/logrus" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) // WebhookServiceType of the Github Webhook service. const WebhookServiceType = "github-webhook" // WebhookService contains the Config fields for the Github Webhook Service. // // Before you can set up a Github Service, you need to set up a Github Realm. This // service does not require a syncing client. // // This service will send notices into a Matrix room when Github sends webhook events // to it. It requires a public domain which Github can reach. Notices will be sent // as the service user ID, not the ClientUserID. // // Example request: // { // ClientUserID: "@alice:localhost", // RealmID: "github-realm-id", // Rooms: { // "!qmElAGdFYCHoCJuaNt:localhost": { // Repos: { // "matrix-org/go-neb": { // Events: ["push", "issues", "pull_request", "labels"] // } // } // } // } // } type WebhookService struct { types.DefaultService webhookEndpointURL string // The user ID to create/delete webhooks as. ClientUserID id.UserID // The ID of an existing "github" realm. This realm will be used to obtain // the Github credentials of the ClientUserID. RealmID string // A map from Matrix room ID to Github "owner/repo"-style repositories. Rooms map[id.RoomID]struct { // A map of "owner/repo"-style repositories to the events to listen for. Repos map[string]struct { // owner/repo => { events: ["push","issue","pull_request"] } // The webhook events to listen for. Currently supported: // push : When users push to this repository. // pull_request : When a pull request is made to this repository. // issues : When an issue is opened/edited/closed/reopened. // issue_comment : When an issue or pull request is commented on. // pull_request_review_comment : When a line comment is made on a pull request. // labels : When any issue or pull request is labeled/unlabeled. Unique to Go-NEB. // milestones : When any issue or pull request is milestoned/demilestoned. Unique to Go-NEB. // assignments : When any issue or pull request is assigned/unassigned. Unique to Go-NEB. // Most of these events are directly from: https://developer.github.com/webhooks/#events Events []string } } // Optional. The secret token to supply when creating the webhook. If supplied, // Go-NEB will perform security checks on incoming webhook requests using this token. SecretToken string } // OnReceiveWebhook receives requests from Github and possibly sends requests to Matrix as a result. // // If the "owner/repo" string in the webhook request case-insensitively matches a repo in this Service // config AND the event type matches an event type registered for that repo, then a message will be sent // into Matrix. // // If the "owner/repo" string doesn't exist in this Service config, then the webhook will be deleted from // Github. func (s *WebhookService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli types.MatrixClient) { evType, repo, msg, err := webhook.OnReceiveRequest(req, s.SecretToken) if err != nil { w.WriteHeader(err.Code) return } logger := log.WithFields(log.Fields{ "event": evType, "repo": *repo.FullName, }) repoExistsInConfig := false for roomID, roomConfig := range s.Rooms { for ownerRepo, repoConfig := range roomConfig.Repos { if !strings.EqualFold(*repo.FullName, ownerRepo) { continue } repoExistsInConfig = true // even if we don't notify for it. notifyRoom := false for _, notifyType := range repoConfig.Events { if evType == notifyType { notifyRoom = true break } } if notifyRoom { logger.WithFields(log.Fields{ "message": msg, "room_id": roomID, }).Print("Sending notification to room") if _, e := cli.SendMessageEvent(roomID, event.EventMessage, msg); e != nil { logger.WithError(e).WithField("room_id", roomID).Print( "Failed to send notification to room.") } } } } if !repoExistsInConfig { segs := strings.Split(*repo.FullName, "/") if len(segs) != 2 { logger.Error("Received event with malformed owner/repo.") w.WriteHeader(400) return } if err := s.deleteHook(segs[0], segs[1]); err != nil { logger.WithError(err).Print("Failed to delete webhook") } else { logger.Info("Deleted webhook") } } w.WriteHeader(200) } // Register will create webhooks for the repos specified in Rooms // // The hooks made are a delta between the old service and the current configuration. If all webhooks are made, // Register() succeeds. If any webhook fails to be created, Register() fails. A delta is used to allow clients to incrementally // build up the service config without recreating the hooks every time a change is made. // // Hooks are deleted when this service receives a webhook event from Github for a repo which has no user configurations. // // Hooks can get out of sync if a user manually deletes a hook in the Github UI. In this case, toggling the repo configuration will // force NEB to recreate the hook. func (s *WebhookService) Register(oldService types.Service, client types.MatrixClient) error { if s.RealmID == "" || s.ClientUserID == "" { return fmt.Errorf("RealmID and ClientUserID is required") } realm, err := s.loadRealm() if err != nil { return err } // In order to register the GH service as a client, you must have authed with GH. cli := s.githubClientFor(s.ClientUserID, false) if cli == nil { return fmt.Errorf( "User %s does not have a Github auth session with realm %s", s.ClientUserID, realm.ID()) } // Fetch the old service list and work out the difference between the two services. var oldRepos []string if oldService != nil { old, ok := oldService.(*WebhookService) if !ok { log.WithFields(log.Fields{ "service_id": oldService.ServiceID(), "service_type": oldService.ServiceType(), }).Print("Cannot cast old github service to WebhookService") // non-fatal though, we'll just make the hooks } else { oldRepos = old.repoList() } } reposForWebhooks := s.repoList() // Add hooks for the newly added repos but don't remove hooks for the removed repos: we'll clean those out later newRepos, removedRepos := difference(reposForWebhooks, oldRepos) if len(reposForWebhooks) == 0 && len(removedRepos) == 0 { // The user didn't specify any webhooks. This may be a bug or it may be // a conscious decision to remove all webhooks for this service. Figure out // which it is by checking if we'd be removing any webhooks. return fmt.Errorf("No webhooks specified") } for _, r := range newRepos { logger := log.WithField("repo", r) err := s.createHook(cli, r) if err != nil { logger.WithError(err).Error("Failed to create webhook") return err } logger.Info("Created webhook") } if err := s.joinWebhookRooms(client); err != nil { return err } log.Infof("%+v", s) return nil } // PostRegister cleans up removed repositories from the old service by // working out the delta between the old and new hooks. func (s *WebhookService) PostRegister(oldService types.Service) { // Fetch the old service list var oldRepos []string if oldService != nil { old, ok := oldService.(*WebhookService) if !ok { log.WithFields(log.Fields{ "service_id": oldService.ServiceID(), "service_type": oldService.ServiceType(), }).Print("Cannot cast old github service to WebhookService") return } oldRepos = old.repoList() } newRepos := s.repoList() // Register() handled adding the new repos, we just want to clean up after ourselves _, removedRepos := difference(newRepos, oldRepos) for _, r := range removedRepos { segs := strings.Split(r, "/") if err := s.deleteHook(segs[0], segs[1]); err != nil { log.WithFields(log.Fields{ log.ErrorKey: err, "repo": r, }).Warn("Failed to remove webhook") } } // If we are not tracking any repos any more then we are back to square 1 and not doing anything // so remove ourselves from the database. This is safe because this is still within the critical // section for this service. if len(newRepos) == 0 { logger := log.WithFields(log.Fields{ "service_type": s.ServiceType(), "service_id": s.ServiceID(), }) logger.Info("Removing service as no webhooks are registered.") if err := database.GetServiceDB().DeleteService(s.ServiceID()); err != nil { logger.WithError(err).Error("Failed to delete service") } } } func (s *WebhookService) joinWebhookRooms(client types.MatrixClient) error { for roomID := range s.Rooms { if _, err := client.JoinRoom(roomID.String(), "", nil); err != nil { // TODO: Leave the rooms we successfully joined? return err } } return nil } // Returns a list of "owner/repos" func (s *WebhookService) repoList() []string { var repos []string if s.Rooms == nil { return repos } for _, roomConfig := range s.Rooms { for ownerRepo := range roomConfig.Repos { if strings.Count(ownerRepo, "/") != 1 { log.WithField("repo", ownerRepo).Error("Bad owner/repo key in config") continue } exists := false for _, r := range repos { if r == ownerRepo { exists = true break } } if !exists { repos = append(repos, ownerRepo) } } } return repos } func (s *WebhookService) createHook(cli *gogithub.Client, ownerRepo string) error { o := strings.Split(ownerRepo, "/") owner := o[0] repo := o[1] // make a hook for all GH events since we'll filter it when we receive webhook requests name := "web" // https://developer.github.com/v3/repos/hooks/#create-a-hook cfg := map[string]interface{}{ "content_type": "json", "url": s.webhookEndpointURL, } if s.SecretToken != "" { cfg["secret"] = s.SecretToken } events := []string{"push", "pull_request", "issues", "issue_comment", "pull_request_review_comment"} _, res, err := cli.Repositories.CreateHook(context.Background(), owner, repo, &gogithub.Hook{ Name: &name, Config: cfg, Events: events, }) if res.StatusCode == 422 { errResponse, ok := err.(*gogithub.ErrorResponse) if !ok { return err } for _, ghErr := range errResponse.Errors { if strings.Contains(ghErr.Message, "already exists") { log.WithField("repo", ownerRepo).Print("422 : Hook already exists") return nil } } return err } return err } func (s *WebhookService) deleteHook(owner, repo string) error { logger := log.WithFields(log.Fields{ "endpoint": s.webhookEndpointURL, "repo": owner + "/" + repo, }) logger.Info("Removing hook") cli := s.githubClientFor(s.ClientUserID, false) if cli == nil { logger.WithField("user_id", s.ClientUserID).Print("Cannot delete webhook: no authenticated client exists for user ID.") return fmt.Errorf("no authenticated client exists for user ID") } // Get a list of webhooks for this owner/repo and find the one which has the // same endpoint URL which is what github uses to determine equivalence. hooks, _, err := cli.Repositories.ListHooks(context.Background(), owner, repo, nil) if err != nil { return err } var hook *gogithub.Hook for _, h := range hooks { if h.Config["url"] == nil { logger.Print("Ignoring nil config.url") continue } hookURL, ok := h.Config["url"].(string) if !ok { logger.Print("Ignoring non-string config.url") continue } if hookURL == s.webhookEndpointURL { hook = h break } } if hook == nil { return fmt.Errorf("Failed to find hook with endpoint: %s", s.webhookEndpointURL) } _, err = cli.Repositories.DeleteHook(context.Background(), owner, repo, *hook.ID) return err } // difference returns the elements that are only in the first list and // the elements that are only in the second. As a side-effect this sorts // the input lists in-place. func difference(a, b []string) (onlyA, onlyB []string) { sort.Strings(a) sort.Strings(b) for { if len(b) == 0 { onlyA = append(onlyA, a...) return } if len(a) == 0 { onlyB = append(onlyB, b...) return } xA := a[0] xB := b[0] if xA < xB { onlyA = append(onlyA, xA) a = a[1:] } else if xA > xB { onlyB = append(onlyB, xB) b = b[1:] } else { a = a[1:] b = b[1:] } } } func (s *WebhookService) githubClientFor(userID id.UserID, allowUnauth bool) *gogithub.Client { token, err := getTokenForUser(s.RealmID, userID) if err != nil { log.WithFields(log.Fields{ log.ErrorKey: err, "user_id": userID, "realm_id": s.RealmID, }).Print("Failed to get token for user") } if token != "" { return client.New(token) } else if allowUnauth { return client.New("") } else { return nil } } func (s *WebhookService) loadRealm() (types.AuthRealm, error) { if s.RealmID == "" { return nil, fmt.Errorf("Missing RealmID") } // check realm exists realm, err := database.GetServiceDB().LoadAuthRealm(s.RealmID) if err != nil { return nil, err } // make sure the realm is of the type we expect if realm.Type() != "github" { return nil, fmt.Errorf("Realm is of type '%s', not 'github'", realm.Type()) } return realm, nil } func init() { types.RegisterService(func(serviceID string, serviceUserID id.UserID, webhookEndpointURL string) types.Service { return &WebhookService{ DefaultService: types.NewDefaultService(serviceID, serviceUserID, WebhookServiceType), webhookEndpointURL: webhookEndpointURL, } }) }