From 04a398a1af8ab7552f89da4cfb9d34b9698e341c Mon Sep 17 00:00:00 2001 From: oliverpool Date: Wed, 3 Apr 2024 14:22:36 +0200 Subject: [PATCH] [REFACTOR] webhook shared code to prevent import cycles --- routers/web/repo/setting/webhook.go | 24 ++++---- services/forms/repo_form.go | 21 +++++-- services/webhook/default.go | 65 ++++------------------ services/webhook/dingtalk.go | 25 +++++---- services/webhook/discord.go | 23 ++++---- services/webhook/feishu.go | 25 +++++---- services/webhook/general.go | 8 --- services/webhook/gogs.go | 21 +++---- services/webhook/matrix.go | 25 +++++---- services/webhook/msteams.go | 25 +++++---- services/webhook/packagist.go | 21 +++---- services/webhook/shared/img.go | 15 +++++ services/webhook/{ => shared}/payloader.go | 61 +++++++++++++++++--- services/webhook/slack.go | 23 ++++---- services/webhook/telegram.go | 23 ++++---- services/webhook/webhook.go | 13 +---- services/webhook/wechatwork.go | 25 +++++---- 17 files changed, 232 insertions(+), 211 deletions(-) create mode 100644 services/webhook/shared/img.go rename services/webhook/{ => shared}/payloader.go (65%) diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 4469eac9e..eee493e2c 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -148,7 +148,7 @@ func WebhookNew(ctx *context.Context) { } // ParseHookEvent convert web form content to webhook.HookEvent -func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent { +func ParseHookEvent(form forms.WebhookCoreForm) *webhook_module.HookEvent { return &webhook_module.HookEvent{ PushOnly: form.PushOnly(), SendEverything: form.SendEverything(), @@ -188,7 +188,7 @@ func WebhookCreate(ctx *context.Context) { return } - fields := handler.FormFields(func(form any) { + fields := handler.UnmarshalForm(func(form any) { errs := binding.Bind(ctx.Req, form) middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError }) @@ -215,10 +215,10 @@ func WebhookCreate(ctx *context.Context) { w.URL = fields.URL w.ContentType = fields.ContentType w.Secret = fields.Secret - w.HookEvent = ParseHookEvent(fields.WebhookForm) - w.IsActive = fields.WebhookForm.Active + w.HookEvent = ParseHookEvent(fields.WebhookCoreForm) + w.IsActive = fields.Active w.HTTPMethod = fields.HTTPMethod - err := w.SetHeaderAuthorization(fields.WebhookForm.AuthorizationHeader) + err := w.SetHeaderAuthorization(fields.AuthorizationHeader) if err != nil { ctx.ServerError("SetHeaderAuthorization", err) return @@ -245,14 +245,14 @@ func WebhookCreate(ctx *context.Context) { HTTPMethod: fields.HTTPMethod, ContentType: fields.ContentType, Secret: fields.Secret, - HookEvent: ParseHookEvent(fields.WebhookForm), - IsActive: fields.WebhookForm.Active, + HookEvent: ParseHookEvent(fields.WebhookCoreForm), + IsActive: fields.Active, Type: hookType, Meta: string(meta), OwnerID: orCtx.OwnerID, IsSystemWebhook: orCtx.IsSystemWebhook, } - err = w.SetHeaderAuthorization(fields.WebhookForm.AuthorizationHeader) + err = w.SetHeaderAuthorization(fields.AuthorizationHeader) if err != nil { ctx.ServerError("SetHeaderAuthorization", err) return @@ -286,7 +286,7 @@ func WebhookUpdate(ctx *context.Context) { return } - fields := handler.FormFields(func(form any) { + fields := handler.UnmarshalForm(func(form any) { errs := binding.Bind(ctx.Req, form) middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError }) @@ -295,11 +295,11 @@ func WebhookUpdate(ctx *context.Context) { w.URL = fields.URL w.ContentType = fields.ContentType w.Secret = fields.Secret - w.HookEvent = ParseHookEvent(fields.WebhookForm) - w.IsActive = fields.WebhookForm.Active + w.HookEvent = ParseHookEvent(fields.WebhookCoreForm) + w.IsActive = fields.Active w.HTTPMethod = fields.HTTPMethod - err := w.SetHeaderAuthorization(fields.WebhookForm.AuthorizationHeader) + err := w.SetHeaderAuthorization(fields.AuthorizationHeader) if err != nil { ctx.ServerError("SetHeaderAuthorization", err) return diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index b5ff031f4..e0540852a 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" + webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" @@ -235,8 +236,8 @@ func (f *ProtectBranchForm) Validate(req *http.Request, errs binding.Errors) bin // \__/\ / \___ >___ /___| /\____/ \____/|__|_ \ // \/ \/ \/ \/ \/ -// WebhookForm form for changing web hook -type WebhookForm struct { +// WebhookCoreForm form for changing web hook (common to all webhook types) +type WebhookCoreForm struct { Events string Create bool Delete bool @@ -265,20 +266,30 @@ type WebhookForm struct { } // PushOnly if the hook will be triggered when push -func (f WebhookForm) PushOnly() bool { +func (f WebhookCoreForm) PushOnly() bool { return f.Events == "push_only" } // SendEverything if the hook will be triggered any event -func (f WebhookForm) SendEverything() bool { +func (f WebhookCoreForm) SendEverything() bool { return f.Events == "send_everything" } // ChooseEvents if the hook will be triggered choose events -func (f WebhookForm) ChooseEvents() bool { +func (f WebhookCoreForm) ChooseEvents() bool { return f.Events == "choose_events" } +// WebhookForm form for changing web hook (specific handling depending on the webhook type) +type WebhookForm struct { + WebhookCoreForm + URL string + ContentType webhook_model.HookContentType + Secret string + HTTPMethod string + Metadata any +} + // .___ // | | ______ ________ __ ____ // | |/ ___// ___/ | \_/ __ \ diff --git a/services/webhook/default.go b/services/webhook/default.go index be3b9b3c7..314f53964 100644 --- a/services/webhook/default.go +++ b/services/webhook/default.go @@ -5,13 +5,8 @@ package webhook import ( "context" - "crypto/hmac" - "crypto/sha1" - "crypto/sha256" - "encoding/hex" "fmt" "html/template" - "io" "net/http" "net/url" "strings" @@ -21,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/svg" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) var _ Handler = defaultHandler{} @@ -39,16 +35,16 @@ func (dh defaultHandler) Type() webhook_module.HookType { func (dh defaultHandler) Icon(size int) template.HTML { if dh.forgejo { // forgejo.svg is not in web_src/svg/, so svg.RenderHTML does not work - return imgIcon("forgejo.svg", size) + return shared.ImgIcon("forgejo.svg", size) } return svg.RenderHTML("gitea-gitea", size, "img") } func (defaultHandler) Metadata(*webhook_model.Webhook) any { return nil } -func (defaultHandler) FormFields(bind func(any)) FormFields { +func (defaultHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` HTTPMethod string `binding:"Required;In(POST,GET)"` ContentType int `binding:"Required"` @@ -60,13 +56,13 @@ func (defaultHandler) FormFields(bind func(any)) FormFields { if webhook_model.HookContentType(form.ContentType) == webhook_model.ContentTypeForm { contentType = webhook_model.ContentTypeForm } - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: contentType, - Secret: form.Secret, - HTTPMethod: form.HTTPMethod, - Metadata: nil, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: contentType, + Secret: form.Secret, + HTTPMethod: form.HTTPMethod, + Metadata: nil, } } @@ -130,42 +126,5 @@ func (defaultHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, } body = []byte(t.PayloadContent) - return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body) -} - -func addDefaultHeaders(req *http.Request, secret []byte, t *webhook_model.HookTask, payloadContent []byte) error { - var signatureSHA1 string - var signatureSHA256 string - if len(secret) > 0 { - sig1 := hmac.New(sha1.New, secret) - sig256 := hmac.New(sha256.New, secret) - _, err := io.MultiWriter(sig1, sig256).Write(payloadContent) - if err != nil { - // this error should never happen, since the hashes are writing to []byte and always return a nil error. - return fmt.Errorf("prepareWebhooks.sigWrite: %w", err) - } - signatureSHA1 = hex.EncodeToString(sig1.Sum(nil)) - signatureSHA256 = hex.EncodeToString(sig256.Sum(nil)) - } - - event := t.EventType.Event() - eventType := string(t.EventType) - req.Header.Add("X-Forgejo-Delivery", t.UUID) - req.Header.Add("X-Forgejo-Event", event) - req.Header.Add("X-Forgejo-Event-Type", eventType) - req.Header.Add("X-Forgejo-Signature", signatureSHA256) - req.Header.Add("X-Gitea-Delivery", t.UUID) - req.Header.Add("X-Gitea-Event", event) - req.Header.Add("X-Gitea-Event-Type", eventType) - req.Header.Add("X-Gitea-Signature", signatureSHA256) - req.Header.Add("X-Gogs-Delivery", t.UUID) - req.Header.Add("X-Gogs-Event", event) - req.Header.Add("X-Gogs-Event-Type", eventType) - req.Header.Add("X-Gogs-Signature", signatureSHA256) - req.Header.Add("X-Hub-Signature", "sha1="+signatureSHA1) - req.Header.Add("X-Hub-Signature-256", "sha256="+signatureSHA256) - req.Header["X-GitHub-Delivery"] = []string{t.UUID} - req.Header["X-GitHub-Event"] = []string{event} - req.Header["X-GitHub-Event-Type"] = []string{eventType} - return nil + return req, body, shared.AddDefaultHeaders(req, []byte(w.Secret), t, body) } diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 0a0160ac4..ea3544243 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -17,28 +17,29 @@ import ( "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type dingtalkHandler struct{} func (dingtalkHandler) Type() webhook_module.HookType { return webhook_module.DINGTALK } func (dingtalkHandler) Metadata(*webhook_model.Webhook) any { return nil } -func (dingtalkHandler) Icon(size int) template.HTML { return imgIcon("dingtalk.ico", size) } +func (dingtalkHandler) Icon(size int) template.HTML { return shared.ImgIcon("dingtalk.ico", size) } -func (dingtalkHandler) FormFields(bind func(any)) FormFields { +func (dingtalkHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, - Metadata: nil, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, + Metadata: nil, } } @@ -225,8 +226,8 @@ func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkP type dingtalkConvertor struct{} -var _ payloadConvertor[DingtalkPayload] = dingtalkConvertor{} +var _ shared.PayloadConvertor[DingtalkPayload] = dingtalkConvertor{} func (dingtalkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { - return newJSONRequest(dingtalkConvertor{}, w, t, true) + return shared.NewJSONRequest(dingtalkConvertor{}, w, t, true) } diff --git a/services/webhook/discord.go b/services/webhook/discord.go index 2efb46f5b..cb756688c 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -22,28 +22,29 @@ import ( "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type discordHandler struct{} func (discordHandler) Type() webhook_module.HookType { return webhook_module.DISCORD } -func (discordHandler) Icon(size int) template.HTML { return imgIcon("discord.png", size) } +func (discordHandler) Icon(size int) template.HTML { return shared.ImgIcon("discord.png", size) } -func (discordHandler) FormFields(bind func(any)) FormFields { +func (discordHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` Username string IconURL string } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, Metadata: &DiscordMeta{ Username: form.Username, IconURL: form.IconURL, @@ -287,7 +288,7 @@ type discordConvertor struct { AvatarURL string } -var _ payloadConvertor[DiscordPayload] = discordConvertor{} +var _ shared.PayloadConvertor[DiscordPayload] = discordConvertor{} func (discordHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { meta := &DiscordMeta{} @@ -298,7 +299,7 @@ func (discordHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, Username: meta.Username, AvatarURL: meta.IconURL, } - return newJSONRequest(sc, w, t, true) + return shared.NewJSONRequest(sc, w, t, true) } func parseHookPullRequestEventType(event webhook_module.HookEventType) (string, error) { diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index eba54fa09..f77c3bbd6 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -15,27 +15,28 @@ import ( api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type feishuHandler struct{} func (feishuHandler) Type() webhook_module.HookType { return webhook_module.FEISHU } -func (feishuHandler) Icon(size int) template.HTML { return imgIcon("feishu.png", size) } +func (feishuHandler) Icon(size int) template.HTML { return shared.ImgIcon("feishu.png", size) } -func (feishuHandler) FormFields(bind func(any)) FormFields { +func (feishuHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, - Metadata: nil, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, + Metadata: nil, } } @@ -192,8 +193,8 @@ func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error) type feishuConvertor struct{} -var _ payloadConvertor[FeishuPayload] = feishuConvertor{} +var _ shared.PayloadConvertor[FeishuPayload] = feishuConvertor{} func (feishuHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { - return newJSONRequest(feishuConvertor{}, w, t, true) + return shared.NewJSONRequest(feishuConvertor{}, w, t, true) } diff --git a/services/webhook/general.go b/services/webhook/general.go index 454efc649..c41f58fe8 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -6,9 +6,7 @@ package webhook import ( "fmt" "html" - "html/template" "net/url" - "strconv" "strings" webhook_model "code.gitea.io/gitea/models/webhook" @@ -354,9 +352,3 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) { Created: w.CreatedUnix.AsTime(), }, nil } - -func imgIcon(name string, size int) template.HTML { - s := strconv.Itoa(size) - src := html.EscapeString(setting.StaticURLPrefix + "/assets/img/" + name) - return template.HTML(``) -} diff --git a/services/webhook/gogs.go b/services/webhook/gogs.go index f616f5e2f..7dbf64343 100644 --- a/services/webhook/gogs.go +++ b/services/webhook/gogs.go @@ -10,16 +10,17 @@ import ( webhook_model "code.gitea.io/gitea/models/webhook" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type gogsHandler struct{ defaultHandler } func (gogsHandler) Type() webhook_module.HookType { return webhook_module.GOGS } -func (gogsHandler) Icon(size int) template.HTML { return imgIcon("gogs.ico", size) } +func (gogsHandler) Icon(size int) template.HTML { return shared.ImgIcon("gogs.ico", size) } -func (gogsHandler) FormFields(bind func(any)) FormFields { +func (gogsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` ContentType int `binding:"Required"` Secret string @@ -30,12 +31,12 @@ func (gogsHandler) FormFields(bind func(any)) FormFields { if webhook_model.HookContentType(form.ContentType) == webhook_model.ContentTypeForm { contentType = webhook_model.ContentTypeForm } - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: contentType, - Secret: form.Secret, - HTTPMethod: http.MethodPost, - Metadata: nil, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: contentType, + Secret: form.Secret, + HTTPMethod: http.MethodPost, + Metadata: nil, } } diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index 322b4d666..697e33e94 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type matrixHandler struct{} @@ -35,25 +36,25 @@ func (matrixHandler) Icon(size int) template.HTML { return svg.RenderHTML("gitea-matrix", size, "img") } -func (matrixHandler) FormFields(bind func(any)) FormFields { +func (matrixHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm HomeserverURL string `binding:"Required;ValidUrl"` RoomID string `binding:"Required"` MessageType int // enforce requirement of authorization_header - // (value will still be set in the embedded WebhookForm) + // (value will still be set in the embedded WebhookCoreForm) AuthorizationHeader string `binding:"Required"` } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)), - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPut, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)), + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPut, Metadata: &MatrixMeta{ HomeserverURL: form.HomeserverURL, Room: form.RoomID, @@ -70,7 +71,7 @@ func (matrixHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t mc := matrixConvertor{ MsgType: messageTypeText[meta.MessageType], } - payload, err := newPayload(mc, []byte(t.PayloadContent), t.EventType) + payload, err := shared.NewPayload(mc, []byte(t.PayloadContent), t.EventType) if err != nil { return nil, nil, err } @@ -90,7 +91,7 @@ func (matrixHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t } req.Header.Set("Content-Type", "application/json") - return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body) // likely useless, but has always been sent historially + return req, body, shared.AddDefaultHeaders(req, []byte(w.Secret), t, body) // likely useless, but has always been sent historially } const matrixPayloadSizeLimit = 1024 * 64 @@ -125,7 +126,7 @@ type MatrixPayload struct { Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"` } -var _ payloadConvertor[MatrixPayload] = matrixConvertor{} +var _ shared.PayloadConvertor[MatrixPayload] = matrixConvertor{} type matrixConvertor struct { MsgType string diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go index 940a6c49a..3e9959146 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -17,28 +17,29 @@ import ( "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type msteamsHandler struct{} func (msteamsHandler) Type() webhook_module.HookType { return webhook_module.MSTEAMS } func (msteamsHandler) Metadata(*webhook_model.Webhook) any { return nil } -func (msteamsHandler) Icon(size int) template.HTML { return imgIcon("msteams.png", size) } +func (msteamsHandler) Icon(size int) template.HTML { return shared.ImgIcon("msteams.png", size) } -func (msteamsHandler) FormFields(bind func(any)) FormFields { +func (msteamsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, - Metadata: nil, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, + Metadata: nil, } } @@ -370,8 +371,8 @@ func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTar type msteamsConvertor struct{} -var _ payloadConvertor[MSTeamsPayload] = msteamsConvertor{} +var _ shared.PayloadConvertor[MSTeamsPayload] = msteamsConvertor{} func (msteamsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { - return newJSONRequest(msteamsConvertor{}, w, t, true) + return shared.NewJSONRequest(msteamsConvertor{}, w, t, true) } diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index f1f330610..9831a4e00 100644 --- a/services/webhook/packagist.go +++ b/services/webhook/packagist.go @@ -15,28 +15,29 @@ import ( "code.gitea.io/gitea/modules/log" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type packagistHandler struct{} func (packagistHandler) Type() webhook_module.HookType { return webhook_module.PACKAGIST } -func (packagistHandler) Icon(size int) template.HTML { return imgIcon("packagist.png", size) } +func (packagistHandler) Icon(size int) template.HTML { return shared.ImgIcon("packagist.png", size) } -func (packagistHandler) FormFields(bind func(any)) FormFields { +func (packagistHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm Username string `binding:"Required"` APIToken string `binding:"Required"` PackageURL string `binding:"Required;ValidUrl"` } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: fmt.Sprintf("https://packagist.org/api/update-package?username=%s&apiToken=%s", url.QueryEscape(form.Username), url.QueryEscape(form.APIToken)), - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: fmt.Sprintf("https://packagist.org/api/update-package?username=%s&apiToken=%s", url.QueryEscape(form.Username), url.QueryEscape(form.APIToken)), + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, Metadata: &PackagistMeta{ Username: form.Username, APIToken: form.APIToken, @@ -85,5 +86,5 @@ func (packagistHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook URL: meta.PackageURL, }, } - return newJSONRequestWithPayload(payload, w, t, false) + return shared.NewJSONRequestWithPayload(payload, w, t, false) } diff --git a/services/webhook/shared/img.go b/services/webhook/shared/img.go new file mode 100644 index 000000000..2d65ba4e0 --- /dev/null +++ b/services/webhook/shared/img.go @@ -0,0 +1,15 @@ +package shared + +import ( + "html" + "html/template" + "strconv" + + "code.gitea.io/gitea/modules/setting" +) + +func ImgIcon(name string, size int) template.HTML { + s := strconv.Itoa(size) + src := html.EscapeString(setting.StaticURLPrefix + "/assets/img/" + name) + return template.HTML(``) +} diff --git a/services/webhook/payloader.go b/services/webhook/shared/payloader.go similarity index 65% rename from services/webhook/payloader.go rename to services/webhook/shared/payloader.go index f87e6e4ee..da7424dc2 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/shared/payloader.go @@ -1,11 +1,16 @@ // Copyright 2020 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package webhook +package shared import ( "bytes" + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" "fmt" + "io" "net/http" webhook_model "code.gitea.io/gitea/models/webhook" @@ -14,8 +19,8 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" ) -// payloadConvertor defines the interface to convert system payload to webhook payload -type payloadConvertor[T any] interface { +// PayloadConvertor defines the interface to convert system payload to webhook payload +type PayloadConvertor[T any] interface { Create(*api.CreatePayload) (T, error) Delete(*api.DeletePayload) (T, error) Fork(*api.ForkPayload) (T, error) @@ -39,7 +44,7 @@ func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) return convert(p) } -func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module.HookEventType) (T, error) { +func NewPayload[T any](rc PayloadConvertor[T], data []byte, event webhook_module.HookEventType) (T, error) { switch event { case webhook_module.HookEventCreate: return convertUnmarshalledJSON(rc.Create, data) @@ -83,15 +88,15 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module return t, fmt.Errorf("newPayload unsupported event: %s", event) } -func newJSONRequest[T any](pc payloadConvertor[T], w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { - payload, err := newPayload(pc, []byte(t.PayloadContent), t.EventType) +func NewJSONRequest[T any](pc PayloadConvertor[T], w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { + payload, err := NewPayload(pc, []byte(t.PayloadContent), t.EventType) if err != nil { return nil, nil, err } - return newJSONRequestWithPayload(payload, w, t, withDefaultHeaders) + return NewJSONRequestWithPayload(payload, w, t, withDefaultHeaders) } -func newJSONRequestWithPayload(payload any, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { +func NewJSONRequestWithPayload(payload any, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { body, err := json.MarshalIndent(payload, "", " ") if err != nil { return nil, nil, err @@ -109,7 +114,45 @@ func newJSONRequestWithPayload(payload any, w *webhook_model.Webhook, t *webhook req.Header.Set("Content-Type", "application/json") if withDefaultHeaders { - return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body) + return req, body, AddDefaultHeaders(req, []byte(w.Secret), t, body) } return req, body, nil } + +// AddDefaultHeaders adds the X-Forgejo, X-Gitea, X-Gogs, X-Hub, X-GitHub headers to the given request +func AddDefaultHeaders(req *http.Request, secret []byte, t *webhook_model.HookTask, payloadContent []byte) error { + var signatureSHA1 string + var signatureSHA256 string + if len(secret) > 0 { + sig1 := hmac.New(sha1.New, secret) + sig256 := hmac.New(sha256.New, secret) + _, err := io.MultiWriter(sig1, sig256).Write(payloadContent) + if err != nil { + // this error should never happen, since the hashes are writing to []byte and always return a nil error. + return fmt.Errorf("prepareWebhooks.sigWrite: %w", err) + } + signatureSHA1 = hex.EncodeToString(sig1.Sum(nil)) + signatureSHA256 = hex.EncodeToString(sig256.Sum(nil)) + } + + event := t.EventType.Event() + eventType := string(t.EventType) + req.Header.Add("X-Forgejo-Delivery", t.UUID) + req.Header.Add("X-Forgejo-Event", event) + req.Header.Add("X-Forgejo-Event-Type", eventType) + req.Header.Add("X-Forgejo-Signature", signatureSHA256) + req.Header.Add("X-Gitea-Delivery", t.UUID) + req.Header.Add("X-Gitea-Event", event) + req.Header.Add("X-Gitea-Event-Type", eventType) + req.Header.Add("X-Gitea-Signature", signatureSHA256) + req.Header.Add("X-Gogs-Delivery", t.UUID) + req.Header.Add("X-Gogs-Event", event) + req.Header.Add("X-Gogs-Event-Type", eventType) + req.Header.Add("X-Gogs-Signature", signatureSHA256) + req.Header.Add("X-Hub-Signature", "sha1="+signatureSHA1) + req.Header.Add("X-Hub-Signature-256", "sha256="+signatureSHA256) + req.Header["X-GitHub-Delivery"] = []string{t.UUID} + req.Header["X-GitHub-Event"] = []string{event} + req.Header["X-GitHub-Event-Type"] = []string{eventType} + return nil +} diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 0b4c4b664..c835d5998 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -20,6 +20,7 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" gitea_context "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" "gitea.com/go-chi/binding" ) @@ -27,10 +28,10 @@ import ( type slackHandler struct{} func (slackHandler) Type() webhook_module.HookType { return webhook_module.SLACK } -func (slackHandler) Icon(size int) template.HTML { return imgIcon("slack.png", size) } +func (slackHandler) Icon(size int) template.HTML { return shared.ImgIcon("slack.png", size) } type slackForm struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` Channel string `binding:"Required"` Username string @@ -53,16 +54,16 @@ func (s *slackForm) Validate(req *http.Request, errs binding.Errors) binding.Err return errs } -func (slackHandler) FormFields(bind func(any)) FormFields { +func (slackHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form slackForm bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, Metadata: &SlackMeta{ Channel: strings.TrimSpace(form.Channel), Username: form.Username, @@ -334,7 +335,7 @@ type slackConvertor struct { Color string } -var _ payloadConvertor[SlackPayload] = slackConvertor{} +var _ shared.PayloadConvertor[SlackPayload] = slackConvertor{} func (slackHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { meta := &SlackMeta{} @@ -347,7 +348,7 @@ func (slackHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t IconURL: meta.IconURL, Color: meta.Color, } - return newJSONRequest(sc, w, t, true) + return shared.NewJSONRequest(sc, w, t, true) } var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`) diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index daa986baf..724c41012 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -18,28 +18,29 @@ import ( api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type telegramHandler struct{} func (telegramHandler) Type() webhook_module.HookType { return webhook_module.TELEGRAM } -func (telegramHandler) Icon(size int) template.HTML { return imgIcon("telegram.png", size) } +func (telegramHandler) Icon(size int) template.HTML { return shared.ImgIcon("telegram.png", size) } -func (telegramHandler) FormFields(bind func(any)) FormFields { +func (telegramHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm BotToken string `binding:"Required"` ChatID string `binding:"Required"` ThreadID string } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&message_thread_id=%s", url.PathEscape(form.BotToken), url.QueryEscape(form.ChatID), url.QueryEscape(form.ThreadID)), - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&message_thread_id=%s", url.PathEscape(form.BotToken), url.QueryEscape(form.ChatID), url.QueryEscape(form.ThreadID)), + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, Metadata: &TelegramMeta{ BotToken: form.BotToken, ChatID: form.ChatID, @@ -220,8 +221,8 @@ func createTelegramPayload(message string) TelegramPayload { type telegramConvertor struct{} -var _ payloadConvertor[TelegramPayload] = telegramConvertor{} +var _ shared.PayloadConvertor[TelegramPayload] = telegramConvertor{} func (telegramHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { - return newJSONRequest(telegramConvertor{}, w, t, true) + return shared.NewJSONRequest(telegramConvertor{}, w, t, true) } diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index f27bffc29..75962db60 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.go @@ -32,22 +32,13 @@ import ( type Handler interface { Type() webhook_module.HookType Metadata(*webhook_model.Webhook) any - // FormFields provides a function to bind the request to the form. + // UnmarshalForm provides a function to bind the request to the form. // If form implements the [binding.Validator] interface, the Validate method will be called - FormFields(bind func(form any)) FormFields + UnmarshalForm(bind func(form any)) forms.WebhookForm NewRequest(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error) Icon(size int) template.HTML } -type FormFields struct { - forms.WebhookForm - URL string - ContentType webhook_model.HookContentType - Secret string - HTTPMethod string - Metadata any -} - var webhookHandlers = []Handler{ defaultHandler{true}, defaultHandler{false}, diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index eff5b9b52..0329cff12 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -15,6 +15,7 @@ import ( api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/webhook/shared" ) type wechatworkHandler struct{} @@ -23,23 +24,23 @@ func (wechatworkHandler) Type() webhook_module.HookType { return webhook_m func (wechatworkHandler) Metadata(*webhook_model.Webhook) any { return nil } func (wechatworkHandler) Icon(size int) template.HTML { - return imgIcon("wechatwork.png", size) + return shared.ImgIcon("wechatwork.png", size) } -func (wechatworkHandler) FormFields(bind func(any)) FormFields { +func (wechatworkHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { var form struct { - forms.WebhookForm + forms.WebhookCoreForm PayloadURL string `binding:"Required;ValidUrl"` } bind(&form) - return FormFields{ - WebhookForm: form.WebhookForm, - URL: form.PayloadURL, - ContentType: webhook_model.ContentTypeJSON, - Secret: "", - HTTPMethod: http.MethodPost, - Metadata: nil, + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, + Metadata: nil, } } @@ -203,8 +204,8 @@ func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload, type wechatworkConvertor struct{} -var _ payloadConvertor[WechatworkPayload] = wechatworkConvertor{} +var _ shared.PayloadConvertor[WechatworkPayload] = wechatworkConvertor{} func (wechatworkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { - return newJSONRequest(wechatworkConvertor{}, w, t, true) + return shared.NewJSONRequest(wechatworkConvertor{}, w, t, true) }