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.

471 lines
14 KiB

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