diff --git a/cmd/web.go b/cmd/web.go index 8c7c02617274..8983eb4f2561 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "os" + "path/filepath" "strings" _ "net/http/pprof" // Used for debugging if enabled and a web server is running @@ -18,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers/install" @@ -157,6 +159,10 @@ func runWeb(ctx *cli.Context) error { setting.LoadFromExisting() routers.GlobalInitInstalled(graceful.GetManager().HammerContext()) + if err := webhook.Init(filepath.Join(setting.CustomPath, "webhooks")); err != nil { + log.Fatal("Could not load custom webhooks: %v", err) + } + // We check that AppDataPath exists here (it should have been created during installation) // We can't check it in `GlobalInitInstalled`, because some integration tests // use cmd -> GlobalInitInstalled, but the AppDataPath doesn't exist during those tests. diff --git a/go.mod b/go.mod index 5ef996769d57..fc943184925b 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 github.com/golang-jwt/jwt/v4 v4.4.1 github.com/google/go-github/v39 v39.2.0 + github.com/google/go-jsonnet v0.18.0 github.com/google/pprof v0.0.0-20220509035851-59ca7ad80af3 github.com/google/uuid v1.3.0 github.com/gorilla/feeds v1.1.1 diff --git a/go.sum b/go.sum index ae6b0ad83a27..d5bec55eb34f 100644 --- a/go.sum +++ b/go.sum @@ -426,6 +426,7 @@ github.com/ethantkoenig/rupture v1.0.1 h1:6aAXghmvtnngMgQzy7SMGdicMvkV86V4n9fT0m github.com/ethantkoenig/rupture v1.0.1/go.mod h1:Sjqo/nbffZp1pVVXNGhpugIjsWmuS9KiIB4GtpEBur4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= @@ -745,6 +746,8 @@ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8 github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= +github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg= +github.com/google/go-jsonnet v0.18.0/go.mod h1:C3fTzyVJDslXdiTqw/bTFk7vSGyCtH3MGRbDfvEwGd0= github.com/google/go-licenses v0.0.0-20210329231322-ce1d9163b77d/go.mod h1:+TYOmkVoJOpwnS0wfdsJCV9CoD5nJYsHoFk/0CrTK4M= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -1120,6 +1123,7 @@ github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcncea github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -2317,6 +2321,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 5a2297ac0d3c..c011f7f3501b 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -387,6 +387,8 @@ var migrations = []Migration{ NewMigration("Add auto merge table", addAutoMergeTable), // v215 -> v216 NewMigration("allow to view files in PRs", addReviewViewedFiles), + // v216 -> v217 + NewMigration("Add custom webhooks", addCustomWebhooks), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v217.go b/models/migrations/v217.go new file mode 100644 index 000000000000..0f27851e234c --- /dev/null +++ b/models/migrations/v217.go @@ -0,0 +1,77 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func addCustomWebhooks(x *xorm.Engine) error { + type HookContentType int + + type HookEvents struct { + Create bool `json:"create"` + Delete bool `json:"delete"` + Fork bool `json:"fork"` + Issues bool `json:"issues"` + IssueAssign bool `json:"issue_assign"` + IssueLabel bool `json:"issue_label"` + IssueMilestone bool `json:"issue_milestone"` + IssueComment bool `json:"issue_comment"` + Push bool `json:"push"` + PullRequest bool `json:"pull_request"` + PullRequestAssign bool `json:"pull_request_assign"` + PullRequestLabel bool `json:"pull_request_label"` + PullRequestMilestone bool `json:"pull_request_milestone"` + PullRequestComment bool `json:"pull_request_comment"` + PullRequestReview bool `json:"pull_request_review"` + PullRequestSync bool `json:"pull_request_sync"` + Repository bool `json:"repository"` + Release bool `json:"release"` + } + + type HookEvent struct { + PushOnly bool `json:"push_only"` + SendEverything bool `json:"send_everything"` + ChooseEvents bool `json:"choose_events"` + BranchFilter string `json:"branch_filter"` + + HookEvents `json:"events"` + } + + type HookType = string + + type HookStatus int + + type Webhook struct { + ID int64 `xorm:"pk autoincr"` + CustomID string `xorm:"VARCHAR(20) 'custom_id'"` + RepoID int64 `xorm:"INDEX"` // An ID of 0 indicates either a default or system webhook + OrgID int64 `xorm:"INDEX"` + IsSystemWebhook bool + URL string `xorm:"url TEXT"` + HTTPMethod string `xorm:"http_method"` + ContentType HookContentType + Secret string `xorm:"TEXT"` + Events string `xorm:"TEXT"` + *HookEvent `xorm:"-"` + IsActive bool `xorm:"INDEX"` + Type HookType `xorm:"VARCHAR(16) 'type'"` + Meta string `xorm:"TEXT"` // store hook-specific attributes + LastStatus HookStatus // Last delivery status + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + if err := x.Sync2(new(Webhook)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + return nil +} diff --git a/models/webhook/webhook.go b/models/webhook/webhook.go index 941a3f15c782..2dfc8d75998c 100644 --- a/models/webhook/webhook.go +++ b/models/webhook/webhook.go @@ -163,6 +163,7 @@ const ( MATRIX HookType = "matrix" WECHATWORK HookType = "wechatwork" PACKAGIST HookType = "packagist" + CUSTOM HookType = "custom" ) // HookStatus is the status of a web hook @@ -177,9 +178,10 @@ const ( // Webhook represents a web hook object. type Webhook struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX"` // An ID of 0 indicates either a default or system webhook - OrgID int64 `xorm:"INDEX"` + ID int64 `xorm:"pk autoincr"` + CustomID string `xorm:"VARCHAR(20) 'custom_id'"` + RepoID int64 `xorm:"INDEX"` // An ID of 0 indicates either a default or system webhook + OrgID int64 `xorm:"INDEX"` IsSystemWebhook bool URL string `xorm:"url TEXT"` HTTPMethod string `xorm:"http_method"` diff --git a/modules/webhook/schema.json b/modules/webhook/schema.json new file mode 100644 index 000000000000..83f7f27eee12 --- /dev/null +++ b/modules/webhook/schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gitea.com/gitea/gitea/modules/webhook/schema.json", + "title": "Custom webhook configuration", + "description": "A custom webhook for Gitea", + "type": "object", + "required": ["id", "label", "docs", "form"], + "oneOf": [ + { + "required": ["exec"] + }, + { + "required": ["http"] + } + ], + "additionalProperties": false, + "properties": { + "id": { + "description": "Webhook ID", + "type": "string" + }, + "label": { + "description": "Webhook Label", + "type": "string" + }, + "docs": { + "description": "Webhook Docs URL", + "type": "string" + }, + "http": { + "description": "HTTP Endpoint", + "type": "string" + }, + "exec": { + "description": "Command to execute", + "type": "array", + "items": { + "type": "string" + } + }, + "form": { + "description": "Webhook form", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "type"], + "properties": { + "id": { + "description": "Form ID", + "type": "string" + }, + "label": { + "description": "Form label", + "type": "string" + }, + "type": { + "description": "Form type", + "type": "string", + "enum": ["text", "number", "bool", "secret"] + }, + "required": { + "description": "Input required", + "type": "boolean" + }, + "default": { + "description": "Input default", + "type": "string" + }, + "pattern": { + "description": "Input pattern", + "type": "string" + } + } + } + } + } +} diff --git a/modules/webhook/webhook.go b/modules/webhook/webhook.go new file mode 100644 index 000000000000..e8be38c606c5 --- /dev/null +++ b/modules/webhook/webhook.go @@ -0,0 +1,147 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package webhook + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +var Webhooks = make(map[string]*Webhook) + +// Webhook is a custom webhook +type Webhook struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + Docs string `yaml:"docs"` + HTTP string `yaml:"http"` + Form []Form `yaml:"form"` + Path string `yaml:"-"` +} + +// Image returns a custom webhook image if it exists, else the default image +// Image needs to be CLOSED +func (w *Webhook) Image() (io.ReadCloser, error) { + img, err := os.Open(filepath.Join(w.Path, "image.png")) + if err != nil { + return nil, fmt.Errorf("could not open custom webhook image: %w", err) + } + + return img, nil +} + +// Form is a webhook form +type Form struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + Type string `yaml:"type"` + Required bool `yaml:"required"` + Default string `yaml:"default"` + Pattern string `yaml:"pattern"` +} + +// InputType returns the HTML input type of a Form.Type +func (f Form) InputType() string { + switch f.Type { + case "text": + return "text" + case "secret": + return "password" + case "number": + return "number" + case "bool": + return "checkbox" + default: + return "text" + } +} + +func (w *Webhook) validate() error { + if w.ID == "" { + return errors.New("webhook id is required") + } + if w.HTTP == "" { + return errors.New("webhook http is required") + } + for _, form := range w.Form { + if form.ID == "" { + return errors.New("form id is required") + } + if form.Label == "" { + return errors.New("form label is required") + } + if form.Type == "" { + return errors.New("form type is required") + } + switch form.Type { + case "text", "secret", "bool", "number": + default: + return errors.New("form type is invalid; must be one of text, secret, bool, or number") + } + } + return nil +} + +// Parse parses a Webhook from an io.Reader +func Parse(r io.Reader) (*Webhook, error) { + b, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + var w Webhook + if err := yaml.Unmarshal(b, &w); err != nil { + return nil, err + } + + if err := w.validate(); err != nil { + return nil, err + } + + return &w, nil +} + +// Init initializes any custom webhooks found in path +func Init(path string) error { + dir, err := os.ReadDir(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("could not read dir %q: %w", path, err) + } + + for _, d := range dir { + if !d.IsDir() { + continue + } + + hookPath := filepath.Join(path, d.Name()) + cfg, err := os.Open(filepath.Join(hookPath, "config.yml")) + if err != nil { + return fmt.Errorf("could not open custom webhook config: %w", err) + } + + hook, err := Parse(cfg) + if err != nil { + return fmt.Errorf("could not parse custom webhook config: %w", err) + } + hook.Path = hookPath + + Webhooks[hook.ID] = hook + + if err := cfg.Close(); err != nil { + return fmt.Errorf("could not close custom webhook config: %w", err) + } + } + + return nil +} diff --git a/modules/webhook/webhook_test.go b/modules/webhook/webhook_test.go new file mode 100644 index 000000000000..9c7c044264ce --- /dev/null +++ b/modules/webhook/webhook_test.go @@ -0,0 +1,50 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package webhook + +import ( + "bytes" + "testing" + + "code.gitea.io/gitea/testdata" + + "github.com/stretchr/testify/assert" +) + +func TestWebhook(t *testing.T) { + tt := []struct { + Name string + File string + Err bool + }{ + { + Name: "Executable", + File: "executable.yml", + }, + { + Name: "HTTP", + File: "http.yml", + }, + { + Name: "Bad", + File: "bad.yml", + Err: true, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + contents, err := testdata.Webhook.ReadFile("webhook/" + tc.File) + assert.NoError(t, err, "expected to read file") + + _, err = Parse(bytes.NewReader(contents)) + if tc.Err { + assert.Error(t, err, "expected to get an error") + } else { + assert.NoError(t, err, "expected to not get an error") + } + }) + } +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ba619b413cbc..077b0de5cf12 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1921,6 +1921,7 @@ settings.webhook.payload = Content settings.webhook.body = Body settings.webhook.replay.description = Replay this webhook. settings.webhook.delivery.success = An event has been added to the delivery queue. It may take few seconds before it shows up in the delivery history. +settings.webhook.display_name = Display Name settings.githooks_desc = "Git Hooks are powered by Git itself. You can edit hook files below to set up custom operations." settings.githook_edit_desc = If the hook is inactive, sample content will be presented. Leaving content to an empty value will disable this hook. settings.githook_name = Hook Name diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go index 1483d0959dbe..9b026549756f 100644 --- a/routers/web/admin/hooks.go +++ b/routers/web/admin/hooks.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + cwebhook "code.gitea.io/gitea/modules/webhook" ) const ( @@ -25,6 +26,7 @@ func DefaultOrSystemWebhooks(ctx *context.Context) { ctx.Data["PageIsAdminSystemHooks"] = true ctx.Data["PageIsAdminDefaultHooks"] = true + ctx.Data["CustomWebhooks"] = cwebhook.Webhooks def := make(map[string]interface{}, len(ctx.Data)) sys := make(map[string]interface{}, len(ctx.Data)) diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 5cd245ef09a3..e0fce5905b6f 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -21,6 +21,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + cwebhook "code.gitea.io/gitea/modules/webhook" user_setting "code.gitea.io/gitea/routers/web/user/setting" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/org" @@ -206,6 +207,7 @@ func Webhooks(ctx *context.Context) { ctx.Data["BaseLink"] = ctx.Org.OrgLink + "/settings/hooks" ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks" ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc") + ctx.Data["CustomWebhooks"] = cwebhook.Webhooks ws, err := webhook.ListWebhooksByOpts(&webhook.ListWebhookOptions{OrgID: ctx.Org.Organization.ID}) if err != nil { diff --git a/routers/web/repo/webhook.go b/routers/web/repo/webhook.go index d2e246118923..44799feb845c 100644 --- a/routers/web/repo/webhook.go +++ b/routers/web/repo/webhook.go @@ -25,6 +25,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + cwebhook "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/forms" webhook_service "code.gitea.io/gitea/services/webhook" ) @@ -50,6 +51,7 @@ func Webhooks(ctx *context.Context) { return } ctx.Data["Webhooks"] = ws + ctx.Data["CustomWebhooks"] = cwebhook.Webhooks ctx.HTML(http.StatusOK, tplHooks) } @@ -108,19 +110,21 @@ func getOrgRepoCtx(ctx *context.Context) (*orgRepoCtx, error) { return nil, errors.New("unable to set OrgRepo context") } -func checkHookType(ctx *context.Context) string { +func checkHookType(ctx *context.Context) (string, bool) { hookType := strings.ToLower(ctx.Params(":type")) - if !util.IsStringInSlice(hookType, setting.Webhook.Types, true) { + _, isCustom := cwebhook.Webhooks[hookType] + if !util.IsStringInSlice(hookType, setting.Webhook.Types, true) && !isCustom { ctx.NotFound("checkHookType", nil) - return "" + return "", false } - return hookType + return hookType, isCustom } // WebhooksNew render creating webhook page func WebhooksNew(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook.HookEvent{}} + ctx.Data["CustomWebhooks"] = cwebhook.Webhooks orCtx, err := getOrgRepoCtx(ctx) if err != nil { @@ -139,7 +143,7 @@ func WebhooksNew(ctx *context.Context) { ctx.Data["PageIsSettingsHooksNew"] = true } - hookType := checkHookType(ctx) + hookType, isCustom := checkHookType(ctx) ctx.Data["HookType"] = hookType if ctx.Written() { return @@ -149,6 +153,9 @@ func WebhooksNew(ctx *context.Context) { "Username": "Gitea", } } + if isCustom { + ctx.Data["CustomHook"] = cwebhook.Webhooks[hookType] + } ctx.Data["BaseLink"] = orCtx.LinkNew ctx.HTML(http.StatusOK, orCtx.NewTemplate) @@ -771,6 +778,13 @@ func checkWebhook(ctx *context.Context) (*orgRepoCtx, *webhook.Webhook) { ctx.Data["MatrixHook"] = webhook_service.GetMatrixHook(w) case webhook.PACKAGIST: ctx.Data["PackagistHook"] = webhook_service.GetPackagistHook(w) + case webhook.CUSTOM: + ctx.Data["CustomHook"] = cwebhook.Webhooks[w.CustomID] + hook := webhook_service.GetCustomHook(w) + ctx.Data["Webhook"] = hook + for key, val := range hook.Form { + ctx.Data["CustomHook_"+key] = val + } } ctx.Data["History"], err = w.History(1) diff --git a/routers/web/repo/webhook_custom.go b/routers/web/repo/webhook_custom.go new file mode 100644 index 000000000000..03620a9bb274 --- /dev/null +++ b/routers/web/repo/webhook_custom.go @@ -0,0 +1,150 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "fmt" + "io" + "net/http" + + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/web" + cwebhook "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/forms" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +// CustomHooksNewPost response for creating custom hook +func CustomHooksNewPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewCustomWebhookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook.HookEvent{}} + ctx.Data["HookType"] = webhook.CUSTOM + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.ServerError("getOrgRepoCtx", err) + return + } + ctx.Data["BaseLink"] = orCtx.LinkNew + + hookType, isCustom := checkHookType(ctx) + if !isCustom { + ctx.NotFound("checkHookType", nil) + return + } + hook := cwebhook.Webhooks[hookType] + if isCustom { + ctx.Data["CustomHook"] = hook + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + meta, err := json.Marshal(&webhook_service.CustomMeta{ + DisplayName: form.DisplayName, + Form: form.Form, + Secret: form.Secret, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w := &webhook.Webhook{ + URL: form.DisplayName, + Secret: form.Secret, + RepoID: orCtx.RepoID, + ContentType: webhook.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + Type: webhook.CUSTOM, + CustomID: hook.ID, + Meta: string(meta), + OrgID: orCtx.OrgID, + IsSystemWebhook: orCtx.IsSystemWebhook, + } + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := webhook.CreateWebhook(ctx, w); err != nil { + ctx.ServerError("CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(orCtx.Link) +} + +// CustomHooksEditPost response for editing custom hook +func CustomHooksEditPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewCustomWebhookForm) + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(http.StatusOK, orCtx.NewTemplate) + return + } + + meta, err := json.Marshal(&webhook_service.CustomMeta{ + DisplayName: form.DisplayName, + Form: form.Form, + Secret: form.Secret, + }) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + w.Meta = string(meta) + w.URL = form.DisplayName + w.Secret = form.Secret + w.HookEvent = ParseHookEvent(form.WebhookForm) + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.ServerError("UpdateEvent", err) + return + } else if err := webhook.UpdateWebhook(w); err != nil { + ctx.ServerError("UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) +} + +// CustomWebhookImage gets the image for a custom webhook +func CustomWebhookImage(ctx *context.Context) { + id := ctx.Params("custom_id") + hook, ok := cwebhook.Webhooks[id] + if !ok { + ctx.NotFound("no webhook found", nil) + return + } + img, err := hook.Image() + if err != nil { + ctx.ServerError(fmt.Sprintf("webhook image not found for %q", id), err) + return + } + defer img.Close() + if _, err := io.Copy(ctx.Resp, img); err != nil { + ctx.ServerError(fmt.Sprintf("could not stream webhook image for %q", id), err) + return + } +} diff --git a/routers/web/web.go b/routers/web/web.go index 97ea1e90353f..20749986e643 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -529,6 +529,7 @@ func RegisterRoutes(m *web.Route) { m.Post("/feishu/{id}", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) m.Post("/wechatwork/{id}", bindIgnErr(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost) m.Post("/packagist/{id}", bindIgnErr(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost) + m.Post("/{type}/{id}", bindIgnErr(forms.NewCustomWebhookForm{}), repo.CustomHooksEditPost) }, webhooksEnabled) m.Group("/{configType:default-hooks|system-hooks}", func() { @@ -544,6 +545,9 @@ func RegisterRoutes(m *web.Route) { m.Post("/feishu/new", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) m.Post("/wechatwork/new", bindIgnErr(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost) m.Post("/packagist/new", bindIgnErr(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost) + m.Post("/{type}/new", bindIgnErr(forms.NewCustomWebhookForm{}), repo.CustomHooksNewPost) + + m.Get("/{custom_id}/image", repo.CustomWebhookImage) }) m.Group("/auths", func() { @@ -662,6 +666,7 @@ func RegisterRoutes(m *web.Route) { m.Post("/msteams/new", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) m.Post("/feishu/new", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) m.Post("/wechatwork/new", bindIgnErr(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost) + m.Post("/{type}/{id}", bindIgnErr(forms.NewCustomWebhookForm{}), repo.CustomHooksEditPost) m.Group("/{id}", func() { m.Get("", repo.WebHooksEdit) m.Post("/replay/{uuid}", repo.ReplayWebhook) @@ -676,6 +681,9 @@ func RegisterRoutes(m *web.Route) { m.Post("/msteams/{id}", bindIgnErr(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) m.Post("/feishu/{id}", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) m.Post("/wechatwork/{id}", bindIgnErr(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost) + m.Post("/{type}/new", bindIgnErr(forms.NewCustomWebhookForm{}), repo.CustomHooksNewPost) + + m.Get("/{custom_id}/image", repo.CustomWebhookImage) }, webhooksEnabled) m.Group("/labels", func() { @@ -781,6 +789,7 @@ func RegisterRoutes(m *web.Route) { m.Post("/feishu/new", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) m.Post("/wechatwork/new", bindIgnErr(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost) m.Post("/packagist/new", bindIgnErr(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost) + m.Post("/{type}/{id}", bindIgnErr(forms.NewCustomWebhookForm{}), repo.CustomHooksEditPost) m.Group("/{id}", func() { m.Get("", repo.WebHooksEdit) m.Post("/test", repo.TestWebhook) @@ -797,6 +806,9 @@ func RegisterRoutes(m *web.Route) { m.Post("/feishu/{id}", bindIgnErr(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) m.Post("/wechatwork/{id}", bindIgnErr(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost) m.Post("/packagist/{id}", bindIgnErr(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost) + m.Post("/{type}/new", bindIgnErr(forms.NewCustomWebhookForm{}), repo.CustomHooksNewPost) + + m.Get("/{custom_id}/image", repo.CustomWebhookImage) }, webhooksEnabled) m.Group("/keys", func() { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 18cbac751cd7..be7a4524bfa8 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -8,6 +8,7 @@ package forms import ( "net/http" "net/url" + "strconv" "strings" "code.gitea.io/gitea/models" @@ -16,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/routers/utils" "gitea.com/go-chi/binding" @@ -414,6 +416,56 @@ func (f *NewPackagistHookForm) Validate(req *http.Request, errs binding.Errors) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// NewCustomWebhookForm form for creating custom web hook +type NewCustomWebhookForm struct { + DisplayName string `binding:"Required"` + Form map[string]interface{} + Secret string + WebhookForm +} + +// Validate validates the fields +func (f *NewCustomWebhookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetContext(req) + hookType := ctx.Params("type") + hook, ok := webhook.Webhooks[hookType] + if !ok { + return errs + } + f.Form = make(map[string]interface{}) + for _, form := range hook.Form { + value := ctx.FormString(form.ID) + if form.Required && value == "" { + ctx.Data["Err_"+form.ID] = true + ctx.Data["HasError"] = true + ctx.Data["ErrorMsg"] = form.Label + ctx.Tr("form.require_error") + errs.Add([]string{form.ID}, binding.ERR_REQUIRED, "Required") + continue + } + if value == "" && form.Default != "" { + value = form.Default + } + switch form.Type { + case "number": + n, _ := strconv.Atoi(value) + f.Form[form.ID] = n + ctx.Data["CustomHook_"+form.ID] = n + case "bool": + b, _ := strconv.ParseBool(value) + f.Form[form.ID] = b + if b { + ctx.Data["CustomHook_"+form.ID] = b + } + case "text", "secret": + fallthrough + default: + f.Form[form.ID] = value + ctx.Data["CustomHook_"+form.ID] = value + } + } + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // .___ // | | ______ ________ __ ____ // | |/ ___// ___/ | \_/ __ \ diff --git a/services/webhook/custom.go b/services/webhook/custom.go new file mode 100644 index 000000000000..eabcd735bbae --- /dev/null +++ b/services/webhook/custom.go @@ -0,0 +1,93 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package webhook + +import ( + "fmt" + "os" + "path/filepath" + + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + cwebhook "code.gitea.io/gitea/modules/webhook" + + "github.com/google/go-jsonnet" +) + +type ( + // CustomPayload is a payload for a custom webhook + CustomPayload struct { + Form map[string]interface{} `json:"form"` + Payload api.Payloader `json:"payload"` + } + + // CustomMeta is the meta information for a custom webhook + CustomMeta struct { + DisplayName string + Form map[string]interface{} + Secret string + } +) + +// GetCustomHook returns custom metadata +func GetCustomHook(w *webhook_model.Webhook) *CustomMeta { + s := &CustomMeta{} + if err := json.Unmarshal([]byte(w.Meta), s); err != nil { + log.Error("webhook.GetCustomHook(%d): %v", w.ID, err) + } + return s +} + +func (c CustomPayload) JSONPayload() ([]byte, error) { + return json.Marshal(c) +} + +// GetCustomPayload converts a custom webhook into a CustomPayload +func GetCustomPayload(p api.Payloader, event webhook_model.HookEventType, w *webhook_model.Webhook) (api.Payloader, error) { + s := new(CustomPayload) + + var custom CustomMeta + if err := json.Unmarshal([]byte(w.Meta), &custom); err != nil { + return s, fmt.Errorf("GetCustomPayload meta json: %v", err) + } + s.Form = custom.Form + s.Payload = p + + payload, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("GetCustomPayload marshal json: %v", err) + } + + webhook, ok := cwebhook.Webhooks[w.CustomID] + if !ok { + return nil, fmt.Errorf("GetCustomPayload no custom webhook %q", w.CustomID) + } + + vm := jsonnet.MakeVM() + vm.Importer(&jsonnet.MemoryImporter{ + Data: map[string]jsonnet.Contents{ + fmt.Sprintf("%s.libsonnet", event): jsonnet.MakeContents(string(payload)), + }, + }) + + filename := fmt.Sprintf("%s.jsonnet", event) + snippet, err := os.ReadFile(filepath.Join(webhook.Path, filename)) + if err != nil { + return nil, fmt.Errorf("GetCustomPayload read jsonnet: %v", err) + } + + out, err := vm.EvaluateAnonymousSnippet(filename, string(snippet)) + return stringPayloader{out}, err +} + +type stringPayloader struct { + payload string +} + +func (s stringPayloader) JSONPayload() ([]byte, error) { + return []byte(s.payload), nil +} diff --git a/services/webhook/deliver.go b/services/webhook/deliver.go index 77744473f1ce..f95658d413f0 100644 --- a/services/webhook/deliver.go +++ b/services/webhook/deliver.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" + cwebhook "code.gitea.io/gitea/modules/webhook" "github.com/gobwas/glob" ) @@ -50,6 +51,14 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { t.IsDelivered = true var req *http.Request + var custom *cwebhook.Webhook + if w.CustomID != "" { + var ok bool + custom, ok = cwebhook.Webhooks[w.CustomID] + if !ok { + return fmt.Errorf("could not get custom webhook for %q", w.CustomID) + } + } switch w.HTTPMethod { case "": @@ -58,7 +67,11 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { case http.MethodPost: switch w.ContentType { case webhook_model.ContentTypeJSON: - req, err = http.NewRequest("POST", w.URL, strings.NewReader(t.PayloadContent)) + u := w.URL + if custom != nil && custom.HTTP != "" { + u = custom.HTTP + } + req, err = http.NewRequest("POST", u, strings.NewReader(t.PayloadContent)) if err != nil { return err } @@ -199,6 +212,7 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { return err } t.ResponseInfo.Body = string(p) + return nil } diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 642cf6f2fda1..054561a327ae 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -183,6 +183,6 @@ func createDingtalkPayload(title, text, singleTitle, singleURL string) *Dingtalk } // GetDingtalkPayload converts a ding talk webhook into a DingtalkPayload -func GetDingtalkPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetDingtalkPayload(p api.Payloader, event webhook_model.HookEventType, _ *webhook_model.Webhook) (api.Payloader, error) { return convertPayloader(new(DingtalkPayload), p, event) } diff --git a/services/webhook/discord.go b/services/webhook/discord.go index ae5460b9a7a1..946c088a34d2 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -243,11 +243,11 @@ func (d *DiscordPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { } // GetDiscordPayload converts a discord webhook into a DiscordPayload -func GetDiscordPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetDiscordPayload(p api.Payloader, event webhook_model.HookEventType, w *webhook_model.Webhook) (api.Payloader, error) { s := new(DiscordPayload) discord := &DiscordMeta{} - if err := json.Unmarshal([]byte(meta), &discord); err != nil { + if err := json.Unmarshal([]byte(w.Meta), &discord); err != nil { return s, errors.New("GetDiscordPayload meta json:" + err.Error()) } s.Username = discord.Username diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 5b20c7dda7e5..2f7482c552a4 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -153,6 +153,6 @@ func (f *FeishuPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { } // GetFeishuPayload converts a ding talk webhook into a FeishuPayload -func GetFeishuPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetFeishuPayload(p api.Payloader, event webhook_model.HookEventType, _ *webhook_model.Webhook) (api.Payloader, error) { return convertPayloader(new(FeishuPayload), p, event) } diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index a42ab2a93e06..bc59b27cdc3a 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -222,11 +222,11 @@ func (m *MatrixPayloadUnsafe) Repository(p *api.RepositoryPayload) (api.Payloade } // GetMatrixPayload converts a Matrix webhook into a MatrixPayloadUnsafe -func GetMatrixPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetMatrixPayload(p api.Payloader, event webhook_model.HookEventType, w *webhook_model.Webhook) (api.Payloader, error) { s := new(MatrixPayloadUnsafe) matrix := &MatrixMeta{} - if err := json.Unmarshal([]byte(meta), &matrix); err != nil { + if err := json.Unmarshal([]byte(w.Meta), &matrix); err != nil { return s, errors.New("GetMatrixPayload meta json:" + err.Error()) } diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go index 59e2e9349398..ab1520a42a6d 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -282,7 +282,7 @@ func (m *MSTeamsPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { } // GetMSTeamsPayload converts a MSTeams webhook into a MSTeamsPayload -func GetMSTeamsPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetMSTeamsPayload(p api.Payloader, event webhook_model.HookEventType, _ *webhook_model.Webhook) (api.Payloader, error) { return convertPayloader(new(MSTeamsPayload), p, event) } diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index ace93b13ff05..1910f89facc1 100644 --- a/services/webhook/packagist.go +++ b/services/webhook/packagist.go @@ -100,11 +100,11 @@ func (f *PackagistPayload) Release(p *api.ReleasePayload) (api.Payloader, error) } // GetPackagistPayload converts a packagist webhook into a PackagistPayload -func GetPackagistPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetPackagistPayload(p api.Payloader, event webhook_model.HookEventType, w *webhook_model.Webhook) (api.Payloader, error) { s := new(PackagistPayload) packagist := &PackagistMeta{} - if err := json.Unmarshal([]byte(meta), &packagist); err != nil { + if err := json.Unmarshal([]byte(w.Meta), &packagist); err != nil { return s, errors.New("GetPackagistPayload meta json:" + err.Error()) } s.PackagistRepository.URL = packagist.PackageURL diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 11e1d3c081c2..26fd738f6478 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -271,11 +271,11 @@ func (s *SlackPayload) createPayload(text string, attachments []SlackAttachment) } // GetSlackPayload converts a slack webhook into a SlackPayload -func GetSlackPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetSlackPayload(p api.Payloader, event webhook_model.HookEventType, w *webhook_model.Webhook) (api.Payloader, error) { s := new(SlackPayload) slack := &SlackMeta{} - if err := json.Unmarshal([]byte(meta), &slack); err != nil { + if err := json.Unmarshal([]byte(w.Meta), &slack); err != nil { return s, errors.New("GetSlackPayload meta json:" + err.Error()) } diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index 64211493ec62..ece41a6bb828 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -179,7 +179,7 @@ func (t *TelegramPayload) Release(p *api.ReleasePayload) (api.Payloader, error) } // GetTelegramPayload converts a telegram webhook into a TelegramPayload -func GetTelegramPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetTelegramPayload(p api.Payloader, event webhook_model.HookEventType, _ *webhook_model.Webhook) (api.Payloader, error) { return convertPayloader(new(TelegramPayload), p, event) } diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index b15b8173f51f..77e192fe3147 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.go @@ -24,7 +24,7 @@ import ( type webhook struct { name webhook_model.HookType - payloadCreator func(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) + payloadCreator func(p api.Payloader, event webhook_model.HookEventType, w *webhook_model.Webhook) (api.Payloader, error) } var webhooks = map[webhook_model.HookType]*webhook{ @@ -64,6 +64,10 @@ var webhooks = map[webhook_model.HookType]*webhook{ name: webhook_model.PACKAGIST, payloadCreator: GetPackagistPayload, }, + webhook_model.CUSTOM: { + name: webhook_model.CUSTOM, + payloadCreator: GetCustomPayload, + }, } // RegisterWebhook registers a webhook @@ -197,7 +201,7 @@ func prepareWebhook(w *webhook_model.Webhook, repo *repo_model.Repository, event var err error webhook, ok := webhooks[w.Type] if ok { - payloader, err = webhook.payloadCreator(p, event, w.Meta) + payloader, err = webhook.payloadCreator(p, event, w) if err != nil { return fmt.Errorf("create payload for %s[%s]: %v", w.Type, event, err) } diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index de8b77706657..4c79df2f2094 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -174,6 +174,6 @@ func (f *WechatworkPayload) Release(p *api.ReleasePayload) (api.Payloader, error } // GetWechatworkPayload GetWechatworkPayload converts a ding talk webhook into a WechatworkPayload -func GetWechatworkPayload(p api.Payloader, event webhook_model.HookEventType, meta string) (api.Payloader, error) { +func GetWechatworkPayload(p api.Payloader, event webhook_model.HookEventType, _ *webhook_model.Webhook) (api.Payloader, error) { return convertPayloader(new(WechatworkPayload), p, event) } diff --git a/templates/repo/settings/webhook/base_list.tmpl b/templates/repo/settings/webhook/base_list.tmpl index b1a3771bdba1..f52ab57984da 100644 --- a/templates/repo/settings/webhook/base_list.tmpl +++ b/templates/repo/settings/webhook/base_list.tmpl @@ -37,6 +37,11 @@ {{.i18n.Tr "repo.settings.web_hook_name_packagist"}} + {{range .CustomWebhooks}} + + {{.Label}} + + {{end}} diff --git a/templates/repo/settings/webhook/custom.tmpl b/templates/repo/settings/webhook/custom.tmpl new file mode 100644 index 000000000000..00c8fad9c28d --- /dev/null +++ b/templates/repo/settings/webhook/custom.tmpl @@ -0,0 +1,31 @@ +{{if .CustomHook}} +
{{.i18n.Tr "repo.settings.add_web_hook_desc" .CustomHook.Docs .CustomHook.Label | Str2html}}
+ +{{end}} diff --git a/templates/repo/settings/webhook/discord.tmpl b/templates/repo/settings/webhook/discord.tmpl index 1b58cfb6f02e..1c32c0d0b506 100644 --- a/templates/repo/settings/webhook/discord.tmpl +++ b/templates/repo/settings/webhook/discord.tmpl @@ -1,4 +1,4 @@ -{{if eq .HookType "discord"}} + {{if eq .HookType "discord"}}{{.i18n.Tr "repo.settings.add_web_hook_desc" "https://discord.com" (.i18n.Tr "repo.settings.web_hook_name_discord") | Str2html}}