From 4ad42a4e3f6913da92a4cf28826524cdd226b415 Mon Sep 17 00:00:00 2001 From: oliverpool Date: Wed, 3 Apr 2024 14:22:36 +0200 Subject: [PATCH 1/2] [REFACTOR] webhook shared code to prevent import cycles (cherry picked from commit 04a398a1af8ab7552f89da4cfb9d34b9698e341c) --- 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 4469eac9e8..eee493e2c2 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 b5ff031f4b..e0540852af 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 be3b9b3c73..314f539648 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 0a0160ac46..ea35442436 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 2efb46f5bb..cb756688c8 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 eba54fa09b..f77c3bbd65 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 454efc6495..c41f58fe8d 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 f616f5e2f3..7dbf64343f 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 322b4d6665..697e33e94c 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 940a6c49aa..3e9959146b 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 f1f3306109..9831a4e008 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 0000000000..2d65ba4e0f --- /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 f87e6e4eec..da7424dc20 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 0b4c4b6645..c835d59984 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 daa986bafb..724c41012f 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 f27bffc29a..75962db605 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 eff5b9b526..0329cff122 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) } From 82b92c3f2c6f61ed8fdded3a6e313cc0ee5e1706 Mon Sep 17 00:00:00 2001 From: oliverpool Date: Wed, 13 Mar 2024 16:49:48 +0100 Subject: [PATCH 2/2] [FEAT] sourcehut webhooks (cherry picked from commit ed9dd0e62a804e866e37e8c130065a7be559c523) --- modules/structs/repo.go | 10 + modules/webhook/type.go | 25 +- options/locale/locale_en-US.ini | 9 + public/assets/img/sourcehut.svg | 7 + services/webhook/shared/payloader.go | 3 + services/webhook/sourcehut/builds.go | 312 +++++++++++++ services/webhook/sourcehut/builds_test.go | 440 ++++++++++++++++++ .../webhook/sourcehut/testdata/repo.git/HEAD | 1 + .../sourcehut/testdata/repo.git/config | 4 + .../sourcehut/testdata/repo.git/description | 1 + .../sourcehut/testdata/repo.git/info/exclude | 6 + .../01/16b5e2279b70e5f5b98240e8896331248f4463 | Bin 0 -> 54 bytes .../3c/3d4b799b3933ba687b263eeef2034300a5315e | Bin 0 -> 83 bytes .../56/f276169b9766f805e5198fe7fb6698153fdb03 | 1 + .../58/771003157b81abc6bf41df0c5db4147a3e3c83 | 2 + .../69/b217caa89166a02b8cd368b64fb83a44720e14 | 1 + .../9e/4b777f81b316a1c75a0797b33add68ee49b0d0 | Bin 0 -> 54 bytes .../aa/3905af404394f576f88f00e7f0919b4b97453f | Bin 0 -> 57 bytes .../bd/d74dba3f34542e480d56c2a91c9e2463180d3c | Bin 0 -> 130 bytes .../c2/30778a03511f546f532e79c8c14917c260e750 | Bin 0 -> 84 bytes .../c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 | Bin 0 -> 56 bytes .../cb/80b2628b69c86b6baea464e5f9fc28405fde4b | 1 + .../d2/e0862c8b8097ba4bdd72946c20479751d307a0 | 4 + .../testdata/repo.git/refs/heads/main | 1 + services/webhook/webhook.go | 2 + templates/webhook/new.tmpl | 2 + templates/webhook/new/sourcehut_builds.tmpl | 33 ++ tests/integration/repo_webhook_test.go | 38 +- 28 files changed, 890 insertions(+), 13 deletions(-) create mode 100644 public/assets/img/sourcehut.svg create mode 100644 services/webhook/sourcehut/builds.go create mode 100644 services/webhook/sourcehut/builds_test.go create mode 100644 services/webhook/sourcehut/testdata/repo.git/HEAD create mode 100644 services/webhook/sourcehut/testdata/repo.git/config create mode 100644 services/webhook/sourcehut/testdata/repo.git/description create mode 100644 services/webhook/sourcehut/testdata/repo.git/info/exclude create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463 create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/56/f276169b9766f805e5198fe7fb6698153fdb03 create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/58/771003157b81abc6bf41df0c5db4147a3e3c83 create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14 create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/bd/d74dba3f34542e480d56c2a91c9e2463180d3c create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/c2/30778a03511f546f532e79c8c14917c260e750 create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b create mode 100644 services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0 create mode 100644 services/webhook/sourcehut/testdata/repo.git/refs/heads/main create mode 100644 templates/webhook/new/sourcehut_builds.tmpl diff --git a/modules/structs/repo.go b/modules/structs/repo.go index a50cddaf7e..f6cc9803a4 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -115,6 +115,16 @@ type Repository struct { RepoTransfer *RepoTransfer `json:"repo_transfer"` } +// GetName implements the gitrepo.Repository interface +func (r Repository) GetName() string { + return r.Name +} + +// GetOwnerName implements the gitrepo.Repository interface +func (r Repository) GetOwnerName() string { + return r.Owner.UserName +} + // CreateRepoOption options when creating repository // swagger:model type CreateRepoOption struct { diff --git a/modules/webhook/type.go b/modules/webhook/type.go index 0d2aef5e15..865f30c926 100644 --- a/modules/webhook/type.go +++ b/modules/webhook/type.go @@ -73,18 +73,19 @@ type HookType = string // Types of webhooks const ( - FORGEJO HookType = "forgejo" - GITEA HookType = "gitea" - GOGS HookType = "gogs" - SLACK HookType = "slack" - DISCORD HookType = "discord" - DINGTALK HookType = "dingtalk" - TELEGRAM HookType = "telegram" - MSTEAMS HookType = "msteams" - FEISHU HookType = "feishu" - MATRIX HookType = "matrix" - WECHATWORK HookType = "wechatwork" - PACKAGIST HookType = "packagist" + FORGEJO HookType = "forgejo" + GITEA HookType = "gitea" + GOGS HookType = "gogs" + SLACK HookType = "slack" + DISCORD HookType = "discord" + DINGTALK HookType = "dingtalk" + TELEGRAM HookType = "telegram" + MSTEAMS HookType = "msteams" + FEISHU HookType = "feishu" + MATRIX HookType = "matrix" + WECHATWORK HookType = "wechatwork" + PACKAGIST HookType = "packagist" + SOURCEHUT_BUILDS HookType = "sourcehut_builds" //nolint:revive ) // HookStatus is the status of a web hook diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bef5610962..c727a9cc12 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -640,6 +640,8 @@ target_branch_not_exist = Target branch does not exist. admin_cannot_delete_self = You cannot delete yourself when you are an admin. Please remove your admin privileges first. +required_prefix = Input must start with "%s" + [user] change_avatar = Change your avatar… joined_on = Joined on %s @@ -2269,6 +2271,7 @@ settings.delete_team_tip = This team has access to all repositories and can't be settings.remove_team_success = The team's access to the repository has been removed. settings.add_webhook = Add webhook settings.add_webhook.invalid_channel_name = Webhook channel name cannot be empty and cannot contain only a # character. +settings.add_webhook.invalid_path = Path must not contain a part that is "." or ".." or the empty string. It cannot start or end with a slash. settings.hooks_desc = Webhooks automatically make HTTP POST requests to a server when certain Forgejo events trigger. Read more in the webhooks guide. settings.webhook_deletion = Remove webhook settings.webhook_deletion_desc = Removing a webhook deletes its settings and delivery history. Continue? @@ -2384,6 +2387,12 @@ settings.web_hook_name_packagist = Packagist settings.packagist_username = Packagist username settings.packagist_api_token = API token settings.packagist_package_url = Packagist package URL +settings.web_hook_name_sourcehut_builds = SourceHut Builds +settings.sourcehut_builds.manifest_path = Build manifest path +settings.sourcehut_builds.graphql_url = GraphQL URL (e.g. https://builds.sr.ht/query) +settings.sourcehut_builds.visibility = Job visibility +settings.sourcehut_builds.secrets = Secrets +settings.sourcehut_builds.secrets_helper = Give the job access to the build secrets (requires the SECRETS:RO grant) settings.deploy_keys = Deploy keys settings.add_deploy_key = Add deploy key settings.deploy_key_desc = Deploy keys have read-only pull access to the repository. diff --git a/public/assets/img/sourcehut.svg b/public/assets/img/sourcehut.svg new file mode 100644 index 0000000000..a2a08d77d0 --- /dev/null +++ b/public/assets/img/sourcehut.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/services/webhook/shared/payloader.go b/services/webhook/shared/payloader.go index da7424dc20..cf0bfa82cb 100644 --- a/services/webhook/shared/payloader.go +++ b/services/webhook/shared/payloader.go @@ -9,6 +9,7 @@ import ( "crypto/sha1" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -19,6 +20,8 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" ) +var ErrPayloadTypeNotSupported = errors.New("unsupported webhook event") + // PayloadConvertor defines the interface to convert system payload to webhook payload type PayloadConvertor[T any] interface { Create(*api.CreatePayload) (T, error) diff --git a/services/webhook/sourcehut/builds.go b/services/webhook/sourcehut/builds.go new file mode 100644 index 0000000000..1561b9e6e6 --- /dev/null +++ b/services/webhook/sourcehut/builds.go @@ -0,0 +1,312 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sourcehut + +import ( + "cmp" + "context" + "fmt" + "html/template" + "io/fs" + "net/http" + "strings" + + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + 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" + "gopkg.in/yaml.v3" +) + +type BuildsHandler struct{} + +func (BuildsHandler) Type() webhook_module.HookType { return webhook_module.SOURCEHUT_BUILDS } +func (BuildsHandler) Metadata(w *webhook_model.Webhook) any { + s := &BuildsMeta{} + if err := json.Unmarshal([]byte(w.Meta), s); err != nil { + log.Error("sourcehut.BuildsHandler.Metadata(%d): %v", w.ID, err) + } + return s +} + +func (BuildsHandler) Icon(size int) template.HTML { + return shared.ImgIcon("sourcehut.svg", size) +} + +type buildsForm struct { + forms.WebhookCoreForm + PayloadURL string `binding:"Required;ValidUrl"` + ManifestPath string `binding:"Required"` + Visibility string `binding:"Required;In(PUBLIC,UNLISTED,PRIVATE)"` + Secrets bool +} + +var _ binding.Validator = &buildsForm{} + +// Validate implements binding.Validator. +func (f *buildsForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := gitea_context.GetWebContext(req) + if !fs.ValidPath(f.ManifestPath) { + errs = append(errs, binding.Error{ + FieldNames: []string{"ManifestPath"}, + Classification: "", + Message: ctx.Locale.TrString("repo.settings.add_webhook.invalid_path"), + }) + } + if !strings.HasPrefix(f.AuthorizationHeader, "Bearer ") { + errs = append(errs, binding.Error{ + FieldNames: []string{"AuthorizationHeader"}, + Classification: "", + Message: ctx.Locale.TrString("form.required_prefix", "Bearer "), + }) + } + return errs +} + +func (BuildsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { + var form buildsForm + bind(&form) + + return forms.WebhookForm{ + WebhookCoreForm: form.WebhookCoreForm, + URL: form.PayloadURL, + ContentType: webhook_model.ContentTypeJSON, + Secret: "", + HTTPMethod: http.MethodPost, + Metadata: &BuildsMeta{ + ManifestPath: form.ManifestPath, + Visibility: form.Visibility, + Secrets: form.Secrets, + }, + } +} + +type ( + graphqlPayload[V any] struct { + Query string `json:"query,omitempty"` + Error string `json:"error,omitempty"` + Variables V `json:"variables,omitempty"` + } + // buildsVariables according to https://man.sr.ht/builds.sr.ht/graphql.md + buildsVariables struct { + Manifest string `json:"manifest"` + Tags []string `json:"tags"` + Note string `json:"note"` + Secrets bool `json:"secrets"` + Execute bool `json:"execute"` + Visibility string `json:"visibility"` + } + + // BuildsMeta contains the metadata for the webhook + BuildsMeta struct { + ManifestPath string `json:"manifest_path"` + Visibility string `json:"visibility"` + Secrets bool `json:"secrets"` + } +) + +type sourcehutConvertor struct { + ctx context.Context + meta BuildsMeta +} + +var _ shared.PayloadConvertor[graphqlPayload[buildsVariables]] = sourcehutConvertor{} + +func (BuildsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + meta := BuildsMeta{} + if err := json.Unmarshal([]byte(w.Meta), &meta); err != nil { + return nil, nil, fmt.Errorf("newSourcehutRequest meta json: %w", err) + } + pc := sourcehutConvertor{ + ctx: ctx, + meta: meta, + } + return shared.NewJSONRequest(pc, w, t, false) +} + +// Create implements PayloadConvertor Create method +func (pc sourcehutConvertor) Create(p *api.CreatePayload) (graphqlPayload[buildsVariables], error) { + return pc.newPayload(p.Repo, p.Sha, p.Ref, p.RefType+" "+git.RefName(p.Ref).ShortName()+" created", true) +} + +// Delete implements PayloadConvertor Delete method +func (pc sourcehutConvertor) Delete(_ *api.DeletePayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// Fork implements PayloadConvertor Fork method +func (pc sourcehutConvertor) Fork(_ *api.ForkPayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// Push implements PayloadConvertor Push method +func (pc sourcehutConvertor) Push(p *api.PushPayload) (graphqlPayload[buildsVariables], error) { + return pc.newPayload(p.Repo, p.HeadCommit.ID, p.Ref, p.HeadCommit.Message, true) +} + +// Issue implements PayloadConvertor Issue method +func (pc sourcehutConvertor) Issue(_ *api.IssuePayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// IssueComment implements PayloadConvertor IssueComment method +func (pc sourcehutConvertor) IssueComment(_ *api.IssueCommentPayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// PullRequest implements PayloadConvertor PullRequest method +func (pc sourcehutConvertor) PullRequest(_ *api.PullRequestPayload) (graphqlPayload[buildsVariables], error) { + // TODO + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// Review implements PayloadConvertor Review method +func (pc sourcehutConvertor) Review(_ *api.PullRequestPayload, _ webhook_module.HookEventType) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// Repository implements PayloadConvertor Repository method +func (pc sourcehutConvertor) Repository(_ *api.RepositoryPayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// Wiki implements PayloadConvertor Wiki method +func (pc sourcehutConvertor) Wiki(_ *api.WikiPayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// Release implements PayloadConvertor Release method +func (pc sourcehutConvertor) Release(_ *api.ReleasePayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +func (pc sourcehutConvertor) Package(_ *api.PackagePayload) (graphqlPayload[buildsVariables], error) { + return graphqlPayload[buildsVariables]{}, shared.ErrPayloadTypeNotSupported +} + +// mustBuildManifest adjusts the manifest to submit to the builds service +// +// in case of an error the Error field will be set, to be visible by the end-user under recent deliveries +func (pc sourcehutConvertor) newPayload(repo *api.Repository, commitID, ref, note string, trusted bool) (graphqlPayload[buildsVariables], error) { + manifest, err := pc.buildManifest(repo, commitID, ref) + if err != nil { + if len(manifest) == 0 { + return graphqlPayload[buildsVariables]{}, err + } + // the manifest contains an error for the user: log the actual error and construct the payload + // the error will be visible under the "recent deliveries" of the webhook settings. + log.Warn("sourcehut.builds: could not construct manifest for %s: %v", repo.FullName, err) + msg := fmt.Sprintf("%s:%s %s", repo.FullName, ref, manifest) + return graphqlPayload[buildsVariables]{ + Error: msg, + }, nil + } + + gitRef := git.RefName(ref) + return graphqlPayload[buildsVariables]{ + Query: `mutation ( + $manifest: String! + $tags: [String!] + $note: String! + $secrets: Boolean! + $execute: Boolean! + $visibility: Visibility! +) { + submit( + manifest: $manifest + tags: $tags + note: $note + secrets: $secrets + execute: $execute + visibility: $visibility + ) { + id + } +}`, Variables: buildsVariables{ + Manifest: string(manifest), + Tags: []string{repo.FullName, gitRef.RefType() + "/" + gitRef.ShortName(), pc.meta.ManifestPath}, + Note: note, + Secrets: pc.meta.Secrets && trusted, + Execute: trusted, + Visibility: cmp.Or(pc.meta.Visibility, "PRIVATE"), + }, + }, nil +} + +// buildManifest adjusts the manifest to submit to the builds service +// in case of an error the []byte might contain an error that can be displayed to the user +func (pc sourcehutConvertor) buildManifest(repo *api.Repository, commitID, gitRef string) ([]byte, error) { + gitRepo, err := gitrepo.OpenRepository(pc.ctx, repo) + if err != nil { + msg := "could not open repository" + return []byte(msg), fmt.Errorf(msg+": %w", err) + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + msg := fmt.Sprintf("could not get commit %q", commitID) + return []byte(msg), fmt.Errorf(msg+": %w", err) + } + entry, err := commit.GetTreeEntryByPath(pc.meta.ManifestPath) + if err != nil { + msg := fmt.Sprintf("could not open manifest %q", pc.meta.ManifestPath) + return []byte(msg), fmt.Errorf(msg+": %w", err) + } + r, err := entry.Blob().DataAsync() + if err != nil { + msg := fmt.Sprintf("could not read manifest %q", pc.meta.ManifestPath) + return []byte(msg), fmt.Errorf(msg+": %w", err) + } + defer r.Close() + var manifest struct { + Image string `yaml:"image"` + Arch string `yaml:"arch,omitempty"` + Packages []string `yaml:"packages,omitempty"` + Repositories map[string]string `yaml:"repositories,omitempty"` + Artifacts []string `yaml:"artifacts,omitempty"` + Shell bool `yaml:"shell,omitempty"` + Sources []string `yaml:"sources"` + Tasks []map[string]string `yaml:"tasks"` + Triggers []string `yaml:"triggers,omitempty"` + Environment map[string]string `yaml:"environment"` + Secrets []string `yaml:"secrets,omitempty"` + Oauth string `yaml:"oauth,omitempty"` + } + if err := yaml.NewDecoder(r).Decode(&manifest); err != nil { + msg := fmt.Sprintf("could not decode manifest %q", pc.meta.ManifestPath) + return []byte(msg), fmt.Errorf(msg+": %w", err) + } + + if manifest.Environment == nil { + manifest.Environment = make(map[string]string) + } + manifest.Environment["BUILD_SUBMITTER"] = "forgejo" + manifest.Environment["BUILD_SUBMITTER_URL"] = setting.AppURL + manifest.Environment["GIT_REF"] = gitRef + + source := repo.CloneURL + "#" + commitID + found := false + for i, s := range manifest.Sources { + if s == repo.CloneURL { + manifest.Sources[i] = source + found = true + break + } + } + if !found { + manifest.Sources = append(manifest.Sources, source) + } + + return yaml.Marshal(manifest) +} diff --git a/services/webhook/sourcehut/builds_test.go b/services/webhook/sourcehut/builds_test.go new file mode 100644 index 0000000000..9ab018df72 --- /dev/null +++ b/services/webhook/sourcehut/builds_test.go @@ -0,0 +1,440 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sourcehut + +import ( + "context" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + webhook_module "code.gitea.io/gitea/modules/webhook" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/services/webhook/shared" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gitInit(t testing.TB) { + if setting.Git.HomePath != "" { + return + } + t.Cleanup(test.MockVariableValue(&setting.Git.HomePath, t.TempDir())) + assert.NoError(t, git.InitSimple(context.Background())) +} + +func TestSourcehutBuildsPayload(t *testing.T) { + gitInit(t) + defer test.MockVariableValue(&setting.RepoRootPath, ".")() + defer test.MockVariableValue(&setting.AppURL, "https://example.forgejo.org/")() + + repo := &api.Repository{ + HTMLURL: "http://localhost:3000/testdata/repo", + Name: "repo", + FullName: "testdata/repo", + Owner: &api.User{ + UserName: "testdata", + }, + CloneURL: "http://localhost:3000/testdata/repo.git", + } + + pc := sourcehutConvertor{ + ctx: git.DefaultContext, + meta: BuildsMeta{ + ManifestPath: "adjust me in each test", + Visibility: "UNLISTED", + Secrets: true, + }, + } + t.Run("Create/branch", func(t *testing.T) { + p := &api.CreatePayload{ + Sha: "58771003157b81abc6bf41df0c5db4147a3e3c83", + Ref: "refs/heads/test", + RefType: "branch", + Repo: repo, + } + + pc.meta.ManifestPath = "simple.yml" + pl, err := pc.Create(p) + require.NoError(t, err) + assert.Equal(t, buildsVariables{ + Manifest: `image: alpine/edge +sources: + - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83 +tasks: + - say-hello: | + echo hello + - say-world: echo world +environment: + BUILD_SUBMITTER: forgejo + BUILD_SUBMITTER_URL: https://example.forgejo.org/ + GIT_REF: refs/heads/test +`, + Note: "branch test created", + Tags: []string{"testdata/repo", "branch/test", "simple.yml"}, + Secrets: true, + Execute: true, + Visibility: "UNLISTED", + }, pl.Variables) + }) + t.Run("Create/tag", func(t *testing.T) { + p := &api.CreatePayload{ + Sha: "58771003157b81abc6bf41df0c5db4147a3e3c83", + Ref: "refs/tags/v1.0.0", + RefType: "tag", + Repo: repo, + } + + pc.meta.ManifestPath = "simple.yml" + pl, err := pc.Create(p) + require.NoError(t, err) + assert.Equal(t, buildsVariables{ + Manifest: `image: alpine/edge +sources: + - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83 +tasks: + - say-hello: | + echo hello + - say-world: echo world +environment: + BUILD_SUBMITTER: forgejo + BUILD_SUBMITTER_URL: https://example.forgejo.org/ + GIT_REF: refs/tags/v1.0.0 +`, + Note: "tag v1.0.0 created", + Tags: []string{"testdata/repo", "tag/v1.0.0", "simple.yml"}, + Secrets: true, + Execute: true, + Visibility: "UNLISTED", + }, pl.Variables) + }) + + t.Run("Delete", func(t *testing.T) { + p := &api.DeletePayload{} + + pl, err := pc.Delete(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("Fork", func(t *testing.T) { + p := &api.ForkPayload{} + + pl, err := pc.Fork(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("Push/simple", func(t *testing.T) { + p := &api.PushPayload{ + Ref: "refs/heads/main", + HeadCommit: &api.PayloadCommit{ + ID: "58771003157b81abc6bf41df0c5db4147a3e3c83", + Message: "add simple", + }, + Repo: repo, + } + + pc.meta.ManifestPath = "simple.yml" + pl, err := pc.Push(p) + require.NoError(t, err) + + assert.Equal(t, buildsVariables{ + Manifest: `image: alpine/edge +sources: + - http://localhost:3000/testdata/repo.git#58771003157b81abc6bf41df0c5db4147a3e3c83 +tasks: + - say-hello: | + echo hello + - say-world: echo world +environment: + BUILD_SUBMITTER: forgejo + BUILD_SUBMITTER_URL: https://example.forgejo.org/ + GIT_REF: refs/heads/main +`, + Note: "add simple", + Tags: []string{"testdata/repo", "branch/main", "simple.yml"}, + Secrets: true, + Execute: true, + Visibility: "UNLISTED", + }, pl.Variables) + }) + t.Run("Push/complex", func(t *testing.T) { + p := &api.PushPayload{ + Ref: "refs/heads/main", + HeadCommit: &api.PayloadCommit{ + ID: "69b217caa89166a02b8cd368b64fb83a44720e14", + Message: "replace simple with complex", + }, + Repo: repo, + } + + pc.meta.ManifestPath = "complex.yaml" + pc.meta.Visibility = "PRIVATE" + pc.meta.Secrets = false + pl, err := pc.Push(p) + require.NoError(t, err) + + assert.Equal(t, buildsVariables{ + Manifest: `image: archlinux +packages: + - nodejs + - npm + - rsync +sources: + - http://localhost:3000/testdata/repo.git#69b217caa89166a02b8cd368b64fb83a44720e14 +tasks: [] +environment: + BUILD_SUBMITTER: forgejo + BUILD_SUBMITTER_URL: https://example.forgejo.org/ + GIT_REF: refs/heads/main + deploy: synapse@synapse-bt.org +secrets: + - 7ebab768-e5e4-4c9d-ba57-ec41a72c5665 +`, + Note: "replace simple with complex", + Tags: []string{"testdata/repo", "branch/main", "complex.yaml"}, + Secrets: false, + Execute: true, + Visibility: "PRIVATE", + }, pl.Variables) + }) + + t.Run("Push/error", func(t *testing.T) { + p := &api.PushPayload{ + Ref: "refs/heads/main", + HeadCommit: &api.PayloadCommit{ + ID: "58771003157b81abc6bf41df0c5db4147a3e3c83", + Message: "add simple", + }, + Repo: repo, + } + + pc.meta.ManifestPath = "non-existing.yml" + pl, err := pc.Push(p) + require.NoError(t, err) + + assert.Equal(t, graphqlPayload[buildsVariables]{ + Error: "testdata/repo:refs/heads/main could not open manifest \"non-existing.yml\"", + }, pl) + }) + + t.Run("Issue", func(t *testing.T) { + p := &api.IssuePayload{} + + p.Action = api.HookIssueOpened + pl, err := pc.Issue(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + + p.Action = api.HookIssueClosed + pl, err = pc.Issue(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("IssueComment", func(t *testing.T) { + p := &api.IssueCommentPayload{} + + pl, err := pc.IssueComment(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("PullRequest", func(t *testing.T) { + p := &api.PullRequestPayload{} + + pl, err := pc.PullRequest(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("PullRequestComment", func(t *testing.T) { + p := &api.IssueCommentPayload{ + IsPull: true, + } + + pl, err := pc.IssueComment(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("Review", func(t *testing.T) { + p := &api.PullRequestPayload{} + p.Action = api.HookIssueReviewed + + pl, err := pc.Review(p, webhook_module.HookEventPullRequestReviewApproved) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("Repository", func(t *testing.T) { + p := &api.RepositoryPayload{} + + pl, err := pc.Repository(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("Package", func(t *testing.T) { + p := &api.PackagePayload{} + + pl, err := pc.Package(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("Wiki", func(t *testing.T) { + p := &api.WikiPayload{} + + p.Action = api.HookWikiCreated + pl, err := pc.Wiki(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + + p.Action = api.HookWikiEdited + pl, err = pc.Wiki(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + + p.Action = api.HookWikiDeleted + pl, err = pc.Wiki(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) + + t.Run("Release", func(t *testing.T) { + p := &api.ReleasePayload{} + + pl, err := pc.Release(p) + require.Equal(t, err, shared.ErrPayloadTypeNotSupported) + require.Equal(t, pl, graphqlPayload[buildsVariables]{}) + }) +} + +func TestSourcehutJSONPayload(t *testing.T) { + gitInit(t) + defer test.MockVariableValue(&setting.RepoRootPath, ".")() + defer test.MockVariableValue(&setting.AppURL, "https://example.forgejo.org/")() + + repo := &api.Repository{ + HTMLURL: "http://localhost:3000/testdata/repo", + Name: "repo", + FullName: "testdata/repo", + Owner: &api.User{ + UserName: "testdata", + }, + CloneURL: "http://localhost:3000/testdata/repo.git", + } + + p := &api.PushPayload{ + Ref: "refs/heads/main", + HeadCommit: &api.PayloadCommit{ + ID: "58771003157b81abc6bf41df0c5db4147a3e3c83", + Message: "json test", + }, + Repo: repo, + } + data, err := p.JSONPayload() + require.NoError(t, err) + + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.MATRIX, + URL: "https://sourcehut.example.com/api/jobs", + Meta: `{"manifest_path":"simple.yml"}`, + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := BuildsHandler{}.NewRequest(context.Background(), hook, task) + require.NoError(t, err) + require.NotNil(t, req) + require.NotNil(t, reqBody) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "/api/jobs", req.URL.Path) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body graphqlPayload[buildsVariables] + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "json test", body.Variables.Note) +} + +func CreateDeclarativeRepo(t *testing.T, owner *user_model.User, name string, enabledUnits, disabledUnits []unit_model.Type, files []*files_service.ChangeRepoFile) (*repo_model.Repository, string) { + t.Helper() + + // Create a new repository + repo, err := repo_service.CreateRepository(db.DefaultContext, owner, owner, repo_service.CreateRepoOptions{ + Name: name, + Description: "Temporary Repo", + AutoInit: true, + Gitignores: "", + License: "WTFPL", + Readme: "Default", + DefaultBranch: "main", + }) + assert.NoError(t, err) + assert.NotEmpty(t, repo) + t.Cleanup(func() { + repo_service.DeleteRepository(db.DefaultContext, owner, repo, false) + }) + + if enabledUnits != nil || disabledUnits != nil { + units := make([]repo_model.RepoUnit, len(enabledUnits)) + for i, unitType := range enabledUnits { + units[i] = repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unitType, + } + } + + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, units, disabledUnits) + assert.NoError(t, err) + } + + var sha string + if len(files) > 0 { + resp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner, &files_service.ChangeRepoFilesOptions{ + Files: files, + Message: "add files", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + Name: owner.Name, + Email: owner.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: owner.Name, + Email: owner.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, resp) + + sha = resp.Commit.SHA + } + + return repo, sha +} diff --git a/services/webhook/sourcehut/testdata/repo.git/HEAD b/services/webhook/sourcehut/testdata/repo.git/HEAD new file mode 100644 index 0000000000..b870d82622 --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/services/webhook/sourcehut/testdata/repo.git/config b/services/webhook/sourcehut/testdata/repo.git/config new file mode 100644 index 0000000000..07d359d07c --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/services/webhook/sourcehut/testdata/repo.git/description b/services/webhook/sourcehut/testdata/repo.git/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/services/webhook/sourcehut/testdata/repo.git/info/exclude b/services/webhook/sourcehut/testdata/repo.git/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463 b/services/webhook/sourcehut/testdata/repo.git/objects/01/16b5e2279b70e5f5b98240e8896331248f4463 new file mode 100644 index 0000000000000000000000000000000000000000..c06eb842befe81109993560ca4832df776981b41 GIT binary patch literal 54 zcmV-60LlM&0V^p=O;s?qU@$Z=Ff%bxD9+3+$Vt_!%*|mqWKiD494H@>AFNk-;-IJa Mp@ioF08HTz`Z}E#b^rhX literal 0 HcmV?d00001 diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e b/services/webhook/sourcehut/testdata/repo.git/objects/3c/3d4b799b3933ba687b263eeef2034300a5315e new file mode 100644 index 0000000000000000000000000000000000000000..f03b45d3f932bd2c208e3f8bc1d089e0b636e525 GIT binary patch literal 83 zcmV-Z0IdIb0ZYosPg1ZnX2{G1eb pjMSW*d@F?-unVX܃絈7\p; \ No newline at end of file diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14 b/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14 new file mode 100644 index 0000000000..1aed81107b --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/objects/69/b217caa89166a02b8cd368b64fb83a44720e14 @@ -0,0 +1 @@ +x=n {)^Z ,EUN}&TAy6aT=ŵĢ5O \m\uFTG׈F;NQ^[֓aQokiW~+ppui ha3J?:7([VK|͙TI7uİӑ>sP =C}ˢO \ No newline at end of file diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 b/services/webhook/sourcehut/testdata/repo.git/objects/9e/4b777f81b316a1c75a0797b33add68ee49b0d0 new file mode 100644 index 0000000000000000000000000000000000000000..081cfcd5ba9b7e3d367d514082670936ee7b7713 GIT binary patch literal 54 zcmV-60LlM&0V^p=O;s?qU@$Z=Ff%bxD9+3+$Vt_!%*|o2vGuN;ZE3tKqgu`G-6v*e MhNXsa07|Y8G-pp3HUIzs literal 0 HcmV?d00001 diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f b/services/webhook/sourcehut/testdata/repo.git/objects/aa/3905af404394f576f88f00e7f0919b4b97453f new file mode 100644 index 0000000000000000000000000000000000000000..cc96171c1c44fada9db8310e6926aa9090f3ef7c GIT binary patch literal 57 zcmV-90LK4#0V^p=O;s>4U@$Z=Ff%bxNY2kK$Vsixt4z$zVYu|5O{cqI`Y!LgMN@JV P+@}X#W?ujRjz>0hNtQ3IZ_@1zqP9y%(fA|A2@q?~%-OG-8ZoB6xg9J%PJdi>f!ZTFU@( zelt2VAdyXmrF0=VWRpz_UTS3TgH?*8_E~GIM0}1*>u_rQ%-06{{2NDp#rnAMZpFiz kB}v99jj%1eb qjMSW*d@F?-unH6BhiZ6XT* literal 0 HcmV?d00001 diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 b/services/webhook/sourcehut/testdata/repo.git/objects/c9/fa8be8c6f230e9529f8e546a3e6a354cbaf313 new file mode 100644 index 0000000000000000000000000000000000000000..291f0a422cd47a15406fcc943090863e297f0ce2 GIT binary patch literal 56 zcmV-80LTA$0V^p=O;s?qWH2-^Ff%bxNY2kK$VsixtH{k^xb&b+r@LYLF7LZVQ*spC Orw3kUUjP7qI}#?9i5IB= literal 0 HcmV?d00001 diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b b/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b new file mode 100644 index 0000000000..891ace4651 --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/objects/cb/80b2628b69c86b6baea464e5f9fc28405fde4b @@ -0,0 +1 @@ +x=Kn0 D)`k@Pd{P2-AQ] YIesmKoD)8p gg44lFQF9˜V,[UΤ`~[iVڕ 4+(0Y)$"ԠlZ-e5wԦʸNY?V4&tC9=a ,P \ No newline at end of file diff --git a/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0 b/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0 new file mode 100644 index 0000000000..f57ab8a70d --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/objects/d2/e0862c8b8097ba4bdd72946c20479751d307a0 @@ -0,0 +1,4 @@ +xENIn0 YD#ȁ ۍ, +"$\f9ئ9~,+L-㒶ɀ=og#&OUo߷jU!,꺮DGP +e>L狡t[ +#?C~ z2!,qCtQZ<.@78\I \ No newline at end of file diff --git a/services/webhook/sourcehut/testdata/repo.git/refs/heads/main b/services/webhook/sourcehut/testdata/repo.git/refs/heads/main new file mode 100644 index 0000000000..4e693a7464 --- /dev/null +++ b/services/webhook/sourcehut/testdata/repo.git/refs/heads/main @@ -0,0 +1 @@ +69b217caa89166a02b8cd368b64fb83a44720e14 diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index 75962db605..dc68cae84d 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.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/sourcehut" "github.com/gobwas/glob" ) @@ -53,6 +54,7 @@ var webhookHandlers = []Handler{ matrixHandler{}, wechatworkHandler{}, packagistHandler{}, + sourcehut.BuildsHandler{}, } // GetWebhookHandler return the handler for a given webhook type (nil if not found) diff --git a/templates/webhook/new.tmpl b/templates/webhook/new.tmpl index 8afdb1fa5d..a3fd89655c 100644 --- a/templates/webhook/new.tmpl +++ b/templates/webhook/new.tmpl @@ -36,6 +36,8 @@ {{template "webhook/new/wechatwork" .}} {{else if eq .HookType "packagist"}} {{template "webhook/new/packagist" .}} + {{else if eq .HookType "sourcehut_builds"}} + {{template "webhook/new/sourcehut_builds" .}} {{end}} {{end}} diff --git a/templates/webhook/new/sourcehut_builds.tmpl b/templates/webhook/new/sourcehut_builds.tmpl new file mode 100644 index 0000000000..1d6333fe79 --- /dev/null +++ b/templates/webhook/new/sourcehut_builds.tmpl @@ -0,0 +1,33 @@ +

{{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://sourcehut.org/" (ctx.Locale.Tr "repo.settings.web_hook_name_sourcehut_builds")}}

+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + +
+
+ + +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.sourcehut_builds.secrets_helper"}} +
+
+ {{template "repo/settings/webhook/settings" .}} +
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 15da511758..3375c0f1ed 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -238,6 +238,34 @@ func TestWebhookForms(t *testing.T) { "branch_filter": "packagist/*", "authorization_header": "Bearer 123456", })) + + t.Run("sourcehut_builds/required", testWebhookForms("sourcehut_builds", session, map[string]string{ + "payload_url": "https://sourcehut_builds.example.com", + "manifest_path": ".build.yml", + "visibility": "PRIVATE", + "authorization_header": "Bearer 123456", + }, map[string]string{ + "authorization_header": "", + }, map[string]string{ + "authorization_header": "token ", + }, map[string]string{ + "manifest_path": "", + }, map[string]string{ + "manifest_path": "/absolute", + }, map[string]string{ + "visibility": "", + }, map[string]string{ + "visibility": "INVALID", + })) + t.Run("sourcehut_builds/optional", testWebhookForms("sourcehut_builds", session, map[string]string{ + "payload_url": "https://sourcehut_builds.example.com", + "manifest_path": ".build.yml", + "visibility": "PRIVATE", + "secrets": "on", + + "branch_filter": "srht/*", + "authorization_header": "Bearer 123456", + })) } func assertInput(t testing.TB, form *goquery.Selection, name string) string { @@ -247,7 +275,15 @@ func assertInput(t testing.TB, form *goquery.Selection, name string) string { t.Log(form.Html()) t.Errorf("field found %d times, expected once", name, input.Length()) } - return input.AttrOr("value", "") + switch input.AttrOr("type", "") { + case "checkbox": + if _, checked := input.Attr("checked"); checked { + return "on" + } + return "" + default: + return input.AttrOr("value", "") + } } func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) {