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.

473 lines
14 KiB

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