You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

425 lines
13 KiB

  1. package github
  2. import (
  3. "fmt"
  4. "net/http"
  5. "sort"
  6. "strings"
  7. log "github.com/Sirupsen/logrus"
  8. "github.com/google/go-github/github"
  9. "github.com/matrix-org/go-neb/database"
  10. "github.com/matrix-org/go-neb/matrix"
  11. "github.com/matrix-org/go-neb/services/github/client"
  12. "github.com/matrix-org/go-neb/services/github/webhook"
  13. "github.com/matrix-org/go-neb/types"
  14. "github.com/matrix-org/go-neb/util"
  15. )
  16. // WebhookServiceType of the Github Webhook service.
  17. const WebhookServiceType = "github-webhook"
  18. // WebhookService contains the Config fields for the Github Webhook Service.
  19. //
  20. // Before you can set up a Github Service, you need to set up a Github Realm. This
  21. // service does not require a syncing client.
  22. //
  23. // This service will send notices into a Matrix room when Github sends webhook events
  24. // to it. It requires a public domain which Github can reach. Notices will be sent
  25. // as the service user ID, not the ClientUserID.
  26. type WebhookService struct {
  27. types.DefaultService
  28. webhookEndpointURL string
  29. // The user ID to create/delete webhooks as.
  30. ClientUserID string
  31. // The ID of an existing "github" realm. This realm will be used to obtain
  32. // the Github credentials of the ClientUserID.
  33. RealmID string
  34. // A map from Matrix room ID to Github "owner/repo"-style repositories.
  35. Rooms map[string]struct {
  36. // A map of "owner/repo"-style repositories to the events to listen for.
  37. Repos map[string]struct { // owner/repo => { events: ["push","issue","pull_request"] }
  38. // The webhook events to listen for. Currently supported:
  39. // push : When users push to this repository.
  40. // pull_request : When a pull request is made to this repository.
  41. // issues : When an issue is opened/closed.
  42. // issue_comment : When an issue or pull request is commented on.
  43. // pull_request_review_comment : When a line comment is made on a pull request.
  44. // Full list: https://developer.github.com/webhooks/#events
  45. Events []string
  46. }
  47. }
  48. // Optional. The secret token to supply when creating the webhook. If supplied,
  49. // Go-NEB will perform security checks on incoming webhook requests using this token.
  50. SecretToken string
  51. }
  52. // OnReceiveWebhook receives requests from Github and possibly sends requests to Matrix as a result.
  53. //
  54. // If the "owner/repo" string in the webhook request case-insensitively matches a repo in this Service
  55. // config AND the event type matches an event type registered for that repo, then a message will be sent
  56. // into Matrix.
  57. //
  58. // If the "owner/repo" string doesn't exist in this Service config, then the webhook will be deleted from
  59. // Github.
  60. func (s *WebhookService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) {
  61. evType, repo, msg, err := webhook.OnReceiveRequest(req, s.SecretToken)
  62. if err != nil {
  63. w.WriteHeader(err.Code)
  64. return
  65. }
  66. logger := log.WithFields(log.Fields{
  67. "event": evType,
  68. "repo": *repo.FullName,
  69. })
  70. repoExistsInConfig := false
  71. for roomID, roomConfig := range s.Rooms {
  72. for ownerRepo, repoConfig := range roomConfig.Repos {
  73. if !strings.EqualFold(*repo.FullName, ownerRepo) {
  74. continue
  75. }
  76. repoExistsInConfig = true // even if we don't notify for it.
  77. notifyRoom := false
  78. for _, notifyType := range repoConfig.Events {
  79. if evType == notifyType {
  80. notifyRoom = true
  81. break
  82. }
  83. }
  84. if notifyRoom {
  85. logger.WithFields(log.Fields{
  86. "msg": msg,
  87. "room_id": roomID,
  88. }).Print("Sending notification to room")
  89. if _, e := cli.SendMessageEvent(roomID, "m.room.message", msg); e != nil {
  90. logger.WithError(e).WithField("room_id", roomID).Print(
  91. "Failed to send notification to room.")
  92. }
  93. }
  94. }
  95. }
  96. if !repoExistsInConfig {
  97. segs := strings.Split(*repo.FullName, "/")
  98. if len(segs) != 2 {
  99. logger.Error("Received event with malformed owner/repo.")
  100. w.WriteHeader(400)
  101. return
  102. }
  103. if err := s.deleteHook(segs[0], segs[1]); err != nil {
  104. logger.WithError(err).Print("Failed to delete webhook")
  105. } else {
  106. logger.Info("Deleted webhook")
  107. }
  108. }
  109. w.WriteHeader(200)
  110. }
  111. // Register will create webhooks for the repos specified in Rooms
  112. //
  113. // The hooks made are a delta between the old service and the current configuration. If all webhooks are made,
  114. // Register() succeeds. If any webhook fails to be created, Register() fails. A delta is used to allow clients to incrementally
  115. // build up the service config without recreating the hooks every time a change is made.
  116. //
  117. // Hooks are deleted when this service receives a webhook event from Github for a repo which has no user configurations.
  118. //
  119. // 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
  120. // force NEB to recreate the hook.
  121. func (s *WebhookService) Register(oldService types.Service, client *matrix.Client) error {
  122. if s.RealmID == "" || s.ClientUserID == "" {
  123. return fmt.Errorf("RealmID and ClientUserID is required")
  124. }
  125. realm, err := s.loadRealm()
  126. if err != nil {
  127. return err
  128. }
  129. // In order to register the GH service as a client, you must have authed with GH.
  130. cli := s.githubClientFor(s.ClientUserID, false)
  131. if cli == nil {
  132. return fmt.Errorf(
  133. "User %s does not have a Github auth session with realm %s.", s.ClientUserID, realm.ID())
  134. }
  135. // Fetch the old service list and work out the difference between the two services.
  136. var oldRepos []string
  137. if oldService != nil {
  138. old, ok := oldService.(*WebhookService)
  139. if !ok {
  140. log.WithFields(log.Fields{
  141. "service_id": oldService.ServiceID(),
  142. "service_type": oldService.ServiceType(),
  143. }).Print("Cannot cast old github service to WebhookService")
  144. // non-fatal though, we'll just make the hooks
  145. } else {
  146. oldRepos = old.repoList()
  147. }
  148. }
  149. reposForWebhooks := s.repoList()
  150. // Add hooks for the newly added repos but don't remove hooks for the removed repos: we'll clean those out later
  151. newRepos, removedRepos := util.Difference(reposForWebhooks, oldRepos)
  152. if len(reposForWebhooks) == 0 && len(removedRepos) == 0 {
  153. // The user didn't specify any webhooks. This may be a bug or it may be
  154. // a conscious decision to remove all webhooks for this service. Figure out
  155. // which it is by checking if we'd be removing any webhooks.
  156. return fmt.Errorf("No webhooks specified.")
  157. }
  158. for _, r := range newRepos {
  159. logger := log.WithField("repo", r)
  160. err := s.createHook(cli, r)
  161. if err != nil {
  162. logger.WithError(err).Error("Failed to create webhook")
  163. return err
  164. }
  165. logger.Info("Created webhook")
  166. }
  167. if err := s.joinWebhookRooms(client); err != nil {
  168. return err
  169. }
  170. log.Infof("%+v", s)
  171. return nil
  172. }
  173. // PostRegister cleans up removed repositories from the old service by
  174. // working out the delta between the old and new hooks.
  175. func (s *WebhookService) PostRegister(oldService types.Service) {
  176. // Fetch the old service list
  177. var oldRepos []string
  178. if oldService != nil {
  179. old, ok := oldService.(*WebhookService)
  180. if !ok {
  181. log.WithFields(log.Fields{
  182. "service_id": oldService.ServiceID(),
  183. "service_type": oldService.ServiceType(),
  184. }).Print("Cannot cast old github service to WebhookService")
  185. return
  186. }
  187. oldRepos = old.repoList()
  188. }
  189. newRepos := s.repoList()
  190. // Register() handled adding the new repos, we just want to clean up after ourselves
  191. _, removedRepos := util.Difference(newRepos, oldRepos)
  192. for _, r := range removedRepos {
  193. segs := strings.Split(r, "/")
  194. if err := s.deleteHook(segs[0], segs[1]); err != nil {
  195. log.WithFields(log.Fields{
  196. log.ErrorKey: err,
  197. "repo": r,
  198. }).Warn("Failed to remove webhook")
  199. }
  200. }
  201. // If we are not tracking any repos any more then we are back to square 1 and not doing anything
  202. // so remove ourselves from the database. This is safe because this is still within the critical
  203. // section for this service.
  204. if len(newRepos) == 0 {
  205. logger := log.WithFields(log.Fields{
  206. "service_type": s.ServiceType(),
  207. "service_id": s.ServiceID(),
  208. })
  209. logger.Info("Removing service as no webhooks are registered.")
  210. if err := database.GetServiceDB().DeleteService(s.ServiceID()); err != nil {
  211. logger.WithError(err).Error("Failed to delete service")
  212. }
  213. }
  214. }
  215. func (s *WebhookService) joinWebhookRooms(client *matrix.Client) error {
  216. for roomID := range s.Rooms {
  217. if _, err := client.JoinRoom(roomID, "", ""); err != nil {
  218. // TODO: Leave the rooms we successfully joined?
  219. return err
  220. }
  221. }
  222. return nil
  223. }
  224. // Returns a list of "owner/repos"
  225. func (s *WebhookService) repoList() []string {
  226. var repos []string
  227. if s.Rooms == nil {
  228. return repos
  229. }
  230. for _, roomConfig := range s.Rooms {
  231. for ownerRepo := range roomConfig.Repos {
  232. if strings.Count(ownerRepo, "/") != 1 {
  233. log.WithField("repo", ownerRepo).Error("Bad owner/repo key in config")
  234. continue
  235. }
  236. exists := false
  237. for _, r := range repos {
  238. if r == ownerRepo {
  239. exists = true
  240. break
  241. }
  242. }
  243. if !exists {
  244. repos = append(repos, ownerRepo)
  245. }
  246. }
  247. }
  248. return repos
  249. }
  250. func (s *WebhookService) createHook(cli *github.Client, ownerRepo string) error {
  251. o := strings.Split(ownerRepo, "/")
  252. owner := o[0]
  253. repo := o[1]
  254. // make a hook for all GH events since we'll filter it when we receive webhook requests
  255. name := "web" // https://developer.github.com/v3/repos/hooks/#create-a-hook
  256. cfg := map[string]interface{}{
  257. "content_type": "json",
  258. "url": s.webhookEndpointURL,
  259. }
  260. if s.SecretToken != "" {
  261. cfg["secret"] = s.SecretToken
  262. }
  263. events := []string{"push", "pull_request", "issues", "issue_comment", "pull_request_review_comment"}
  264. _, res, err := cli.Repositories.CreateHook(owner, repo, &github.Hook{
  265. Name: &name,
  266. Config: cfg,
  267. Events: events,
  268. })
  269. if res.StatusCode == 422 {
  270. errResponse, ok := err.(*github.ErrorResponse)
  271. if !ok {
  272. return err
  273. }
  274. for _, ghErr := range errResponse.Errors {
  275. if strings.Contains(ghErr.Message, "already exists") {
  276. log.WithField("repo", ownerRepo).Print("422 : Hook already exists")
  277. return nil
  278. }
  279. }
  280. return err
  281. }
  282. return err
  283. }
  284. func (s *WebhookService) deleteHook(owner, repo string) error {
  285. logger := log.WithFields(log.Fields{
  286. "endpoint": s.webhookEndpointURL,
  287. "repo": owner + "/" + repo,
  288. })
  289. logger.Info("Removing hook")
  290. cli := s.githubClientFor(s.ClientUserID, false)
  291. if cli == nil {
  292. logger.WithField("user_id", s.ClientUserID).Print("Cannot delete webhook: no authenticated client exists for user ID.")
  293. return fmt.Errorf("no authenticated client exists for user ID")
  294. }
  295. // Get a list of webhooks for this owner/repo and find the one which has the
  296. // same endpoint URL which is what github uses to determine equivalence.
  297. hooks, _, err := cli.Repositories.ListHooks(owner, repo, nil)
  298. if err != nil {
  299. return err
  300. }
  301. var hook *github.Hook
  302. for _, h := range hooks {
  303. if h.Config["url"] == nil {
  304. logger.Print("Ignoring nil config.url")
  305. continue
  306. }
  307. hookURL, ok := h.Config["url"].(string)
  308. if !ok {
  309. logger.Print("Ignoring non-string config.url")
  310. continue
  311. }
  312. if hookURL == s.webhookEndpointURL {
  313. hook = h
  314. break
  315. }
  316. }
  317. if hook == nil {
  318. return fmt.Errorf("Failed to find hook with endpoint: %s", s.webhookEndpointURL)
  319. }
  320. _, err = cli.Repositories.DeleteHook(owner, repo, *hook.ID)
  321. return err
  322. }
  323. func sameRepos(a *WebhookService, b *WebhookService) bool {
  324. getRepos := func(s *WebhookService) []string {
  325. r := make(map[string]bool)
  326. for _, roomConfig := range s.Rooms {
  327. for ownerRepo := range roomConfig.Repos {
  328. r[ownerRepo] = true
  329. }
  330. }
  331. var rs []string
  332. for k := range r {
  333. rs = append(rs, k)
  334. }
  335. return rs
  336. }
  337. aRepos := getRepos(a)
  338. bRepos := getRepos(b)
  339. if len(aRepos) != len(bRepos) {
  340. return false
  341. }
  342. sort.Strings(aRepos)
  343. sort.Strings(bRepos)
  344. for i := 0; i < len(aRepos); i++ {
  345. if aRepos[i] != bRepos[i] {
  346. return false
  347. }
  348. }
  349. return true
  350. }
  351. func (s *WebhookService) githubClientFor(userID string, allowUnauth bool) *github.Client {
  352. token, err := getTokenForUser(s.RealmID, userID)
  353. if err != nil {
  354. log.WithFields(log.Fields{
  355. log.ErrorKey: err,
  356. "user_id": userID,
  357. "realm_id": s.RealmID,
  358. }).Print("Failed to get token for user")
  359. }
  360. if token != "" {
  361. return client.New(token)
  362. } else if allowUnauth {
  363. return client.New("")
  364. } else {
  365. return nil
  366. }
  367. }
  368. func (s *WebhookService) loadRealm() (types.AuthRealm, error) {
  369. if s.RealmID == "" {
  370. return nil, fmt.Errorf("Missing RealmID")
  371. }
  372. // check realm exists
  373. realm, err := database.GetServiceDB().LoadAuthRealm(s.RealmID)
  374. if err != nil {
  375. return nil, err
  376. }
  377. // make sure the realm is of the type we expect
  378. if realm.Type() != "github" {
  379. return nil, fmt.Errorf("Realm is of type '%s', not 'github'", realm.Type())
  380. }
  381. return realm, nil
  382. }
  383. func init() {
  384. types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service {
  385. return &WebhookService{
  386. DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
  387. webhookEndpointURL: webhookEndpointURL,
  388. }
  389. })
  390. }