diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2019-11-02 06:51:22 +0800 |
---|---|---|
committer | zeripath <art27@cantab.net> | 2019-11-01 22:51:22 +0000 |
commit | 0e7f7df3cf176640c66ddf286ec052c7c13beb8a (patch) | |
tree | 16afa02dd0b3df428aa7d9daadd4796eef907332 /modules/webhook | |
parent | ba336f6f456835f1f327ee967991079dd220266d (diff) | |
download | gitea-0e7f7df3cf176640c66ddf286ec052c7c13beb8a.tar.gz gitea-0e7f7df3cf176640c66ddf286ec052c7c13beb8a.zip |
Move webhook to a standalone package under modules (#8747)
* Move webhook to a standalone package under modules
* fix test
* fix comments
Diffstat (limited to 'modules/webhook')
-rw-r--r-- | modules/webhook/deliver.go | 208 | ||||
-rw-r--r-- | modules/webhook/main_test.go | 16 | ||||
-rw-r--r-- | modules/webhook/webhook.go | 179 | ||||
-rw-r--r-- | modules/webhook/webhook_test.go | 67 |
4 files changed, 470 insertions, 0 deletions
diff --git a/modules/webhook/deliver.go b/modules/webhook/deliver.go new file mode 100644 index 0000000000..1b3da25d5e --- /dev/null +++ b/modules/webhook/deliver.go @@ -0,0 +1,208 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package webhook + +import ( + "crypto/tls" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "github.com/unknwon/com" +) + +// Deliver deliver hook task +func Deliver(t *models.HookTask) error { + t.IsDelivered = true + + var req *http.Request + var err error + + switch t.HTTPMethod { + case "": + log.Info("HTTP Method for webhook %d empty, setting to POST as default", t.ID) + fallthrough + case http.MethodPost: + switch t.ContentType { + case models.ContentTypeJSON: + req, err = http.NewRequest("POST", t.URL, strings.NewReader(t.PayloadContent)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + case models.ContentTypeForm: + var forms = url.Values{ + "payload": []string{t.PayloadContent}, + } + + req, err = http.NewRequest("POST", t.URL, strings.NewReader(forms.Encode())) + if err != nil { + + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + case http.MethodGet: + u, err := url.Parse(t.URL) + if err != nil { + return err + } + vals := u.Query() + vals["payload"] = []string{t.PayloadContent} + u.RawQuery = vals.Encode() + req, err = http.NewRequest("GET", u.String(), nil) + if err != nil { + return err + } + default: + return fmt.Errorf("Invalid http method for webhook: [%d] %v", t.ID, t.HTTPMethod) + } + + req.Header.Add("X-Gitea-Delivery", t.UUID) + req.Header.Add("X-Gitea-Event", string(t.EventType)) + req.Header.Add("X-Gitea-Signature", t.Signature) + req.Header.Add("X-Gogs-Delivery", t.UUID) + req.Header.Add("X-Gogs-Event", string(t.EventType)) + req.Header.Add("X-Gogs-Signature", t.Signature) + req.Header["X-GitHub-Delivery"] = []string{t.UUID} + req.Header["X-GitHub-Event"] = []string{string(t.EventType)} + + // Record delivery information. + t.RequestInfo = &models.HookRequest{ + Headers: map[string]string{}, + } + for k, vals := range req.Header { + t.RequestInfo.Headers[k] = strings.Join(vals, ",") + } + + t.ResponseInfo = &models.HookResponse{ + Headers: map[string]string{}, + } + + defer func() { + t.Delivered = time.Now().UnixNano() + if t.IsSucceed { + log.Trace("Hook delivered: %s", t.UUID) + } else { + log.Trace("Hook delivery failed: %s", t.UUID) + } + + if err := models.UpdateHookTask(t); err != nil { + log.Error("UpdateHookTask [%d]: %v", t.ID, err) + } + + // Update webhook last delivery status. + w, err := models.GetWebhookByID(t.HookID) + if err != nil { + log.Error("GetWebhookByID: %v", err) + return + } + if t.IsSucceed { + w.LastStatus = models.HookStatusSucceed + } else { + w.LastStatus = models.HookStatusFail + } + if err = models.UpdateWebhookLastStatus(w); err != nil { + log.Error("UpdateWebhookLastStatus: %v", err) + return + } + }() + + resp, err := webhookHTTPClient.Do(req) + if err != nil { + t.ResponseInfo.Body = fmt.Sprintf("Delivery: %v", err) + return err + } + defer resp.Body.Close() + + // Status code is 20x can be seen as succeed. + t.IsSucceed = resp.StatusCode/100 == 2 + t.ResponseInfo.Status = resp.StatusCode + for k, vals := range resp.Header { + t.ResponseInfo.Headers[k] = strings.Join(vals, ",") + } + + p, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.ResponseInfo.Body = fmt.Sprintf("read body: %s", err) + return err + } + t.ResponseInfo.Body = string(p) + return nil +} + +// DeliverHooks checks and delivers undelivered hooks. +// TODO: shoot more hooks at same time. +func DeliverHooks() { + tasks, err := models.FindUndeliveredHookTasks() + if err != nil { + log.Error("DeliverHooks: %v", err) + return + } + + // Update hook task status. + for _, t := range tasks { + if err = Deliver(t); err != nil { + log.Error("deliver: %v", err) + } + } + + // Start listening on new hook requests. + for repoIDStr := range HookQueue.Queue() { + log.Trace("DeliverHooks [repo_id: %v]", repoIDStr) + HookQueue.Remove(repoIDStr) + + repoID, err := com.StrTo(repoIDStr).Int64() + if err != nil { + log.Error("Invalid repo ID: %s", repoIDStr) + continue + } + + tasks, err := models.FindRepoUndeliveredHookTasks(repoID) + if err != nil { + log.Error("Get repository [%d] hook tasks: %v", repoID, err) + continue + } + for _, t := range tasks { + if err = Deliver(t); err != nil { + log.Error("deliver: %v", err) + } + } + } +} + +var webhookHTTPClient *http.Client + +// InitDeliverHooks starts the hooks delivery thread +func InitDeliverHooks() { + timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second + + webhookHTTPClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify}, + Proxy: http.ProxyFromEnvironment, + Dial: func(netw, addr string) (net.Conn, error) { + conn, err := net.DialTimeout(netw, addr, timeout) + if err != nil { + return nil, err + } + + return conn, conn.SetDeadline(time.Now().Add(timeout)) + + }, + }, + } + + go DeliverHooks() +} diff --git a/modules/webhook/main_test.go b/modules/webhook/main_test.go new file mode 100644 index 0000000000..6cb0cffe49 --- /dev/null +++ b/modules/webhook/main_test.go @@ -0,0 +1,16 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package webhook + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..")) +} diff --git a/modules/webhook/webhook.go b/modules/webhook/webhook.go new file mode 100644 index 0000000000..da88c58c88 --- /dev/null +++ b/modules/webhook/webhook.go @@ -0,0 +1,179 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/sync" + "github.com/gobwas/glob" +) + +// HookQueue is a global queue of web hooks +var HookQueue = sync.NewUniqueQueue(setting.Webhook.QueueLength) + +// getPayloadBranch returns branch for hook event, if applicable. +func getPayloadBranch(p api.Payloader) string { + switch pp := p.(type) { + case *api.CreatePayload: + if pp.RefType == "branch" { + return pp.Ref + } + case *api.DeletePayload: + if pp.RefType == "branch" { + return pp.Ref + } + case *api.PushPayload: + if strings.HasPrefix(pp.Ref, git.BranchPrefix) { + return pp.Ref[len(git.BranchPrefix):] + } + } + return "" +} + +// PrepareWebhook adds special webhook to task queue for given payload. +func PrepareWebhook(w *models.Webhook, repo *models.Repository, event models.HookEventType, p api.Payloader) error { + return prepareWebhook(w, repo, event, p) +} + +func checkBranch(w *models.Webhook, branch string) bool { + if w.BranchFilter == "" || w.BranchFilter == "*" { + return true + } + + g, err := glob.Compile(w.BranchFilter) + if err != nil { + // should not really happen as BranchFilter is validated + log.Error("CheckBranch failed: %s", err) + return false + } + + return g.Match(branch) +} + +func prepareWebhook(w *models.Webhook, repo *models.Repository, event models.HookEventType, p api.Payloader) error { + for _, e := range w.EventCheckers() { + if event == e.Type { + if !e.Has() { + return nil + } + } + } + + // If payload has no associated branch (e.g. it's a new tag, issue, etc.), + // branch filter has no effect. + if branch := getPayloadBranch(p); branch != "" { + if !checkBranch(w, branch) { + log.Info("Branch %q doesn't match branch filter %q, skipping", branch, w.BranchFilter) + return nil + } + } + + var payloader api.Payloader + var err error + // Use separate objects so modifications won't be made on payload on non-Gogs/Gitea type hooks. + switch w.HookTaskType { + case models.SLACK: + payloader, err = models.GetSlackPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetSlackPayload: %v", err) + } + case models.DISCORD: + payloader, err = models.GetDiscordPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetDiscordPayload: %v", err) + } + case models.DINGTALK: + payloader, err = models.GetDingtalkPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetDingtalkPayload: %v", err) + } + case models.TELEGRAM: + payloader, err = models.GetTelegramPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetTelegramPayload: %v", err) + } + case models.MSTEAMS: + payloader, err = models.GetMSTeamsPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetMSTeamsPayload: %v", err) + } + default: + p.SetSecret(w.Secret) + payloader = p + } + + var signature string + if len(w.Secret) > 0 { + data, err := payloader.JSONPayload() + if err != nil { + log.Error("prepareWebhooks.JSONPayload: %v", err) + } + sig := hmac.New(sha256.New, []byte(w.Secret)) + _, err = sig.Write(data) + if err != nil { + log.Error("prepareWebhooks.sigWrite: %v", err) + } + signature = hex.EncodeToString(sig.Sum(nil)) + } + + if err = models.CreateHookTask(&models.HookTask{ + RepoID: repo.ID, + HookID: w.ID, + Type: w.HookTaskType, + URL: w.URL, + Signature: signature, + Payloader: payloader, + HTTPMethod: w.HTTPMethod, + ContentType: w.ContentType, + EventType: event, + IsSSL: w.IsSSL, + }); err != nil { + return fmt.Errorf("CreateHookTask: %v", err) + } + return nil +} + +// PrepareWebhooks adds new webhooks to task queue for given payload. +func PrepareWebhooks(repo *models.Repository, event models.HookEventType, p api.Payloader) error { + return prepareWebhooks(repo, event, p) +} + +func prepareWebhooks(repo *models.Repository, event models.HookEventType, p api.Payloader) error { + ws, err := models.GetActiveWebhooksByRepoID(repo.ID) + if err != nil { + return fmt.Errorf("GetActiveWebhooksByRepoID: %v", err) + } + + // check if repo belongs to org and append additional webhooks + if repo.MustOwner().IsOrganization() { + // get hooks for org + orgHooks, err := models.GetActiveWebhooksByOrgID(repo.OwnerID) + if err != nil { + return fmt.Errorf("GetActiveWebhooksByOrgID: %v", err) + } + ws = append(ws, orgHooks...) + } + + if len(ws) == 0 { + return nil + } + + for _, w := range ws { + if err = prepareWebhook(w, repo, event, p); err != nil { + return err + } + } + return nil +} diff --git a/modules/webhook/webhook_test.go b/modules/webhook/webhook_test.go new file mode 100644 index 0000000000..c944bc477d --- /dev/null +++ b/modules/webhook/webhook_test.go @@ -0,0 +1,67 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package webhook + +import ( + "testing" + + "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" + "github.com/stretchr/testify/assert" +) + +func TestPrepareWebhooks(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + hookTasks := []*models.HookTask{ + {RepoID: repo.ID, HookID: 1, EventType: models.HookEventPush}, + } + for _, hookTask := range hookTasks { + models.AssertNotExistsBean(t, hookTask) + } + assert.NoError(t, PrepareWebhooks(repo, models.HookEventPush, &api.PushPayload{})) + for _, hookTask := range hookTasks { + models.AssertExistsAndLoadBean(t, hookTask) + } +} + +func TestPrepareWebhooksBranchFilterMatch(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 2}).(*models.Repository) + hookTasks := []*models.HookTask{ + {RepoID: repo.ID, HookID: 4, EventType: models.HookEventPush}, + } + for _, hookTask := range hookTasks { + models.AssertNotExistsBean(t, hookTask) + } + // this test also ensures that * doesn't handle / in any special way (like shell would) + assert.NoError(t, PrepareWebhooks(repo, models.HookEventPush, &api.PushPayload{Ref: "refs/heads/feature/7791"})) + for _, hookTask := range hookTasks { + models.AssertExistsAndLoadBean(t, hookTask) + } +} + +func TestPrepareWebhooksBranchFilterNoMatch(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 2}).(*models.Repository) + hookTasks := []*models.HookTask{ + {RepoID: repo.ID, HookID: 4, EventType: models.HookEventPush}, + } + for _, hookTask := range hookTasks { + models.AssertNotExistsBean(t, hookTask) + } + assert.NoError(t, PrepareWebhooks(repo, models.HookEventPush, &api.PushPayload{Ref: "refs/heads/fix_weird_bug"})) + + for _, hookTask := range hookTasks { + models.AssertNotExistsBean(t, hookTask) + } +} + +// TODO TestHookTask_deliver + +// TODO TestDeliverHooks |