Merge pull request '[REFACTOR] webhook.Handler interface' (#2758) from oliverpool/forgejo:webhook_2_interface into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2758
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-03-27 13:45:04 +00:00
commit e41e18f87e
35 changed files with 378 additions and 210 deletions

View file

@ -341,4 +341,5 @@ package "code.gitea.io/gitea/services/repository/files"
package "code.gitea.io/gitea/services/webhook"
func NewNotifier
func List

View file

@ -3,6 +3,7 @@
repo_id: 1
url: http://www.example.com/url1
http_method: POST
type: forgejo
content_type: 1 # json
events: '{"push_only":true,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":false}}'
is_active: false # disable to prevent sending hook task during unrelated tests

View file

@ -17,13 +17,17 @@ var ErrInvalidReceiveHook = errors.New("Invalid JSON payload received over webho
// Hook a hook is a web hook when one repository changed
type Hook struct {
ID int64 `json:"id"`
Type string `json:"type"`
BranchFilter string `json:"branch_filter"`
URL string `json:"-"`
ID int64 `json:"id"`
Type string `json:"type"`
BranchFilter string `json:"branch_filter"`
URL string `json:"url"`
// Deprecated: use Metadata instead
Config map[string]string `json:"config"`
Events []string `json:"events"`
AuthorizationHeader string `json:"authorization_header"`
ContentType string `json:"content_type"`
Metadata any `json:"metadata"`
Active bool `json:"active"`
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`

View file

@ -637,17 +637,9 @@ func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) {
}
ctx.Data["HookType"] = w.Type
switch w.Type {
case webhook_module.SLACK:
ctx.Data["SlackHook"] = webhook_service.GetSlackHook(w)
case webhook_module.DISCORD:
ctx.Data["DiscordHook"] = webhook_service.GetDiscordHook(w)
case webhook_module.TELEGRAM:
ctx.Data["TelegramHook"] = webhook_service.GetTelegramHook(w)
case webhook_module.MATRIX:
ctx.Data["MatrixHook"] = webhook_service.GetMatrixHook(w)
case webhook_module.PACKAGIST:
ctx.Data["PackagistHook"] = webhook_service.GetPackagistHook(w)
if handler := webhook_service.GetWebhookHandler(w.Type); handler != nil {
ctx.Data["HookMetadata"] = handler.Metadata(w)
}
ctx.Data["History"], err = w.History(ctx, 1)

136
services/webhook/default.go Normal file
View file

@ -0,0 +1,136 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package webhook
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
webhook_model "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/log"
webhook_module "code.gitea.io/gitea/modules/webhook"
)
var _ Handler = defaultHandler{}
type defaultHandler struct {
forgejo bool
}
func (dh defaultHandler) Type() webhook_module.HookType {
if dh.forgejo {
return webhook_module.FORGEJO
}
return webhook_module.GITEA
}
func (defaultHandler) Metadata(*webhook_model.Webhook) any { return nil }
func (defaultHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (req *http.Request, body []byte, err error) {
switch w.HTTPMethod {
case "":
log.Info("HTTP Method for %s webhook %s [ID: %d] is not set, defaulting to POST", w.Type, w.URL, w.ID)
fallthrough
case http.MethodPost:
switch w.ContentType {
case webhook_model.ContentTypeJSON:
req, err = http.NewRequest("POST", w.URL, strings.NewReader(t.PayloadContent))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/json")
case webhook_model.ContentTypeForm:
forms := url.Values{
"payload": []string{t.PayloadContent},
}
req, err = http.NewRequest("POST", w.URL, strings.NewReader(forms.Encode()))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
default:
return nil, nil, fmt.Errorf("invalid content type: %v", w.ContentType)
}
case http.MethodGet:
u, err := url.Parse(w.URL)
if err != nil {
return nil, nil, fmt.Errorf("invalid URL: %w", err)
}
vals := u.Query()
vals["payload"] = []string{t.PayloadContent}
u.RawQuery = vals.Encode()
req, err = http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, nil, err
}
case http.MethodPut:
switch w.Type {
case webhook_module.MATRIX: // used when t.Version == 1
txnID, err := getMatrixTxnID([]byte(t.PayloadContent))
if err != nil {
return nil, nil, err
}
url := fmt.Sprintf("%s/%s", w.URL, url.PathEscape(txnID))
req, err = http.NewRequest("PUT", url, strings.NewReader(t.PayloadContent))
if err != nil {
return nil, nil, err
}
default:
return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
}
default:
return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
}
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
}

View file

@ -5,11 +5,7 @@ package webhook
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"fmt"
"io"
"net/http"
@ -32,106 +28,6 @@ import (
"github.com/gobwas/glob"
)
func newDefaultRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (req *http.Request, body []byte, err error) {
switch w.HTTPMethod {
case "":
log.Info("HTTP Method for %s webhook %s [ID: %d] is not set, defaulting to POST", w.Type, w.URL, w.ID)
fallthrough
case http.MethodPost:
switch w.ContentType {
case webhook_model.ContentTypeJSON:
req, err = http.NewRequest("POST", w.URL, strings.NewReader(t.PayloadContent))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/json")
case webhook_model.ContentTypeForm:
forms := url.Values{
"payload": []string{t.PayloadContent},
}
req, err = http.NewRequest("POST", w.URL, strings.NewReader(forms.Encode()))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
default:
return nil, nil, fmt.Errorf("invalid content type: %v", w.ContentType)
}
case http.MethodGet:
u, err := url.Parse(w.URL)
if err != nil {
return nil, nil, fmt.Errorf("invalid URL: %w", err)
}
vals := u.Query()
vals["payload"] = []string{t.PayloadContent}
u.RawQuery = vals.Encode()
req, err = http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, nil, err
}
case http.MethodPut:
switch w.Type {
case webhook_module.MATRIX: // used when t.Version == 1
txnID, err := getMatrixTxnID([]byte(t.PayloadContent))
if err != nil {
return nil, nil, err
}
url := fmt.Sprintf("%s/%s", w.URL, url.PathEscape(txnID))
req, err = http.NewRequest("PUT", url, strings.NewReader(t.PayloadContent))
if err != nil {
return nil, nil, err
}
default:
return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
}
default:
return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
}
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
}
// Deliver creates the [http.Request] (depending on the webhook type), sends it
// and records the status and response.
func Deliver(ctx context.Context, t *webhook_model.HookTask) error {
@ -151,12 +47,15 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error {
t.IsDelivered = true
newRequest := webhookRequesters[w.Type]
if t.PayloadVersion == 1 || newRequest == nil {
newRequest = newDefaultRequest
handler := GetWebhookHandler(w.Type)
if handler == nil {
return fmt.Errorf("GetWebhookHandler %q", w.Type)
}
if t.PayloadVersion == 1 {
handler = defaultHandler{true}
}
req, body, err := newRequest(ctx, w, t)
req, body, err := handler.NewRequest(ctx, w, t)
if err != nil {
return fmt.Errorf("cannot create http request for webhook %s[%d %s]: %w", w.Type, w.ID, w.URL, err)
}

View file

@ -19,6 +19,11 @@ import (
dingtalk "gitea.com/lunny/dingtalk_webhook"
)
type dingtalkHandler struct{}
func (dingtalkHandler) Type() webhook_module.HookType { return webhook_module.DINGTALK }
func (dingtalkHandler) Metadata(*webhook_model.Webhook) any { return nil }
type (
// DingtalkPayload represents
DingtalkPayload dingtalk.Payload
@ -190,6 +195,6 @@ type dingtalkConvertor struct{}
var _ payloadConvertor[DingtalkPayload] = dingtalkConvertor{}
func newDingtalkRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (dingtalkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
return newJSONRequest(dingtalkConvertor{}, w, t, true)
}

View file

@ -236,7 +236,7 @@ func TestDingTalkJSONPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newDingtalkRequest(context.Background(), hook, task)
req, reqBody, err := dingtalkHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)

View file

@ -22,6 +22,10 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type discordHandler struct{}
func (discordHandler) Type() webhook_module.HookType { return webhook_module.DISCORD }
type (
// DiscordEmbedFooter for Embed Footer Structure.
DiscordEmbedFooter struct {
@ -69,11 +73,11 @@ type (
}
)
// GetDiscordHook returns discord metadata
func GetDiscordHook(w *webhook_model.Webhook) *DiscordMeta {
// Metadata returns discord metadata
func (discordHandler) Metadata(w *webhook_model.Webhook) any {
s := &DiscordMeta{}
if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
log.Error("webhook.GetDiscordHook(%d): %v", w.ID, err)
log.Error("discordHandler.Metadata(%d): %v", w.ID, err)
}
return s
}
@ -260,10 +264,10 @@ type discordConvertor struct {
var _ payloadConvertor[DiscordPayload] = discordConvertor{}
func newDiscordRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (discordHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
meta := &DiscordMeta{}
if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
return nil, nil, fmt.Errorf("newDiscordRequest meta json: %w", err)
return nil, nil, fmt.Errorf("discordHandler.NewRequest meta json: %w", err)
}
sc := discordConvertor{
Username: meta.Username,

View file

@ -275,7 +275,7 @@ func TestDiscordJSONPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newDiscordRequest(context.Background(), hook, task)
req, reqBody, err := discordHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)

View file

@ -15,6 +15,11 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type feishuHandler struct{}
func (feishuHandler) Type() webhook_module.HookType { return webhook_module.FEISHU }
func (feishuHandler) Metadata(*webhook_model.Webhook) any { return nil }
type (
// FeishuPayload represents
FeishuPayload struct {
@ -168,6 +173,6 @@ type feishuConvertor struct{}
var _ payloadConvertor[FeishuPayload] = feishuConvertor{}
func newFeishuRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (feishuHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
return newJSONRequest(feishuConvertor{}, w, t, true)
}

View file

@ -177,7 +177,7 @@ func TestFeishuJSONPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newFeishuRequest(context.Background(), hook, task)
req, reqBody, err := feishuHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)

View file

@ -314,33 +314,41 @@ func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, w
// ToHook convert models.Webhook to api.Hook
// This function is not part of the convert package to prevent an import cycle
func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
// config is deprecated, but kept for compatibility
config := map[string]string{
"url": w.URL,
"content_type": w.ContentType.Name(),
}
if w.Type == webhook_module.SLACK {
s := GetSlackHook(w)
config["channel"] = s.Channel
config["username"] = s.Username
config["icon_url"] = s.IconURL
config["color"] = s.Color
if s, ok := (slackHandler{}.Metadata(w)).(*SlackMeta); ok {
config["channel"] = s.Channel
config["username"] = s.Username
config["icon_url"] = s.IconURL
config["color"] = s.Color
}
}
authorizationHeader, err := w.HeaderAuthorization()
if err != nil {
return nil, err
}
var metadata any
if handler := GetWebhookHandler(w.Type); handler != nil {
metadata = handler.Metadata(w)
}
return &api.Hook{
ID: w.ID,
Type: w.Type,
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
Active: w.IsActive,
BranchFilter: w.BranchFilter,
URL: w.URL,
Config: config,
Events: w.EventsArray(),
AuthorizationHeader: authorizationHeader,
ContentType: w.ContentType.Name(),
Metadata: metadata,
Active: w.IsActive,
Updated: w.UpdatedUnix.AsTime(),
Created: w.CreatedUnix.AsTime(),
BranchFilter: w.BranchFilter,
}, nil
}

12
services/webhook/gogs.go Normal file
View file

@ -0,0 +1,12 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package webhook
import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type gogsHandler struct{ defaultHandler }
func (gogsHandler) Type() webhook_module.HookType { return webhook_module.GOGS }

View file

@ -24,10 +24,14 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
func newMatrixRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
type matrixHandler struct{}
func (matrixHandler) Type() webhook_module.HookType { return webhook_module.MATRIX }
func (matrixHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
meta := &MatrixMeta{}
if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
return nil, nil, fmt.Errorf("GetMatrixPayload meta json: %w", err)
return nil, nil, fmt.Errorf("matrixHandler.NewRequest meta json: %w", err)
}
mc := matrixConvertor{
MsgType: messageTypeText[meta.MessageType],
@ -69,11 +73,11 @@ var messageTypeText = map[int]string{
2: "m.text",
}
// GetMatrixHook returns Matrix metadata
func GetMatrixHook(w *webhook_model.Webhook) *MatrixMeta {
// Metadata returns Matrix metadata
func (matrixHandler) Metadata(w *webhook_model.Webhook) any {
s := &MatrixMeta{}
if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
log.Error("webhook.GetMatrixHook(%d): %v", w.ID, err)
log.Error("matrixHandler.Metadata(%d): %v", w.ID, err)
}
return s
}

View file

@ -211,7 +211,7 @@ func TestMatrixJSONPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newMatrixRequest(context.Background(), hook, task)
req, reqBody, err := matrixHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)

View file

@ -17,6 +17,11 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type msteamsHandler struct{}
func (msteamsHandler) Type() webhook_module.HookType { return webhook_module.MSTEAMS }
func (msteamsHandler) Metadata(*webhook_model.Webhook) any { return nil }
type (
// MSTeamsFact for Fact Structure
MSTeamsFact struct {
@ -347,6 +352,6 @@ type msteamsConvertor struct{}
var _ payloadConvertor[MSTeamsPayload] = msteamsConvertor{}
func newMSTeamsRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (msteamsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
return newJSONRequest(msteamsConvertor{}, w, t, true)
}

View file

@ -439,7 +439,7 @@ func TestMSTeamsJSONPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newMSTeamsRequest(context.Background(), hook, task)
req, reqBody, err := msteamsHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)

View file

@ -11,8 +11,13 @@ import (
webhook_model "code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type packagistHandler struct{}
func (packagistHandler) Type() webhook_module.HookType { return webhook_module.PACKAGIST }
type (
// PackagistPayload represents a packagist payload
// as expected by https://packagist.org/about
@ -30,20 +35,20 @@ type (
}
)
// GetPackagistHook returns packagist metadata
func GetPackagistHook(w *webhook_model.Webhook) *PackagistMeta {
// Metadata returns packagist metadata
func (packagistHandler) Metadata(w *webhook_model.Webhook) any {
s := &PackagistMeta{}
if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
log.Error("webhook.GetPackagistHook(%d): %v", w.ID, err)
log.Error("packagistHandler.Metadata(%d): %v", w.ID, err)
}
return s
}
// newPackagistRequest creates a request with the [PackagistPayload] for packagist (same payload for all events).
func newPackagistRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (packagistHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
meta := &PackagistMeta{}
if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
return nil, nil, fmt.Errorf("newpackagistRequest meta json: %w", err)
return nil, nil, fmt.Errorf("packagistHandler.NewRequest meta json: %w", err)
}
payload := PackagistPayload{

View file

@ -53,7 +53,7 @@ func TestPackagistPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newPackagistRequest(context.Background(), hook, task)
req, reqBody, err := packagistHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)

View file

@ -19,6 +19,10 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type slackHandler struct{}
func (slackHandler) Type() webhook_module.HookType { return webhook_module.SLACK }
// SlackMeta contains the slack metadata
type SlackMeta struct {
Channel string `json:"channel"`
@ -27,11 +31,11 @@ type SlackMeta struct {
Color string `json:"color"`
}
// GetSlackHook returns slack metadata
func GetSlackHook(w *webhook_model.Webhook) *SlackMeta {
// Metadata returns slack metadata
func (slackHandler) Metadata(w *webhook_model.Webhook) any {
s := &SlackMeta{}
if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
log.Error("webhook.GetSlackHook(%d): %v", w.ID, err)
log.Error("slackHandler.Metadata(%d): %v", w.ID, err)
}
return s
}
@ -283,10 +287,10 @@ type slackConvertor struct {
var _ payloadConvertor[SlackPayload] = slackConvertor{}
func newSlackRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (slackHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
meta := &SlackMeta{}
if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
return nil, nil, fmt.Errorf("newSlackRequest meta json: %w", err)
return nil, nil, fmt.Errorf("slackHandler.NewRequest meta json: %w", err)
}
sc := slackConvertor{
Channel: meta.Channel,

View file

@ -178,7 +178,7 @@ func TestSlackJSONPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newSlackRequest(context.Background(), hook, task)
req, reqBody, err := slackHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)
@ -211,3 +211,54 @@ func TestIsValidSlackChannel(t *testing.T) {
assert.Equal(t, v.expected, IsValidSlackChannel(v.channelName))
}
}
func TestSlackMetadata(t *testing.T) {
w := &webhook_model.Webhook{
Meta: `{"channel": "foo", "username": "username", "color": "blue"}`,
}
slackHook := slackHandler{}.Metadata(w)
assert.Equal(t, *slackHook.(*SlackMeta), SlackMeta{
Channel: "foo",
Username: "username",
Color: "blue",
})
}
func TestSlackToHook(t *testing.T) {
w := &webhook_model.Webhook{
Type: webhook_module.SLACK,
ContentType: webhook_model.ContentTypeJSON,
URL: "https://slack.example.com",
Meta: `{"channel": "foo", "username": "username", "color": "blue"}`,
HookEvent: &webhook_module.HookEvent{
PushOnly: true,
SendEverything: false,
ChooseEvents: false,
HookEvents: webhook_module.HookEvents{
Create: false,
Push: true,
PullRequest: false,
},
},
}
h, err := ToHook("repoLink", w)
assert.NoError(t, err)
assert.Equal(t, h.Config, map[string]string{
"url": "https://slack.example.com",
"content_type": "json",
"channel": "foo",
"color": "blue",
"icon_url": "",
"username": "username",
})
assert.Equal(t, h.URL, "https://slack.example.com")
assert.Equal(t, h.ContentType, "json")
assert.Equal(t, h.Metadata, &SlackMeta{
Channel: "foo",
Username: "username",
IconURL: "",
Color: "blue",
})
}

View file

@ -17,6 +17,10 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type telegramHandler struct{}
func (telegramHandler) Type() webhook_module.HookType { return webhook_module.TELEGRAM }
type (
// TelegramPayload represents
TelegramPayload struct {
@ -33,11 +37,11 @@ type (
}
)
// GetTelegramHook returns telegram metadata
func GetTelegramHook(w *webhook_model.Webhook) *TelegramMeta {
// Metadata returns telegram metadata
func (telegramHandler) Metadata(w *webhook_model.Webhook) any {
s := &TelegramMeta{}
if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
log.Error("webhook.GetTelegramHook(%d): %v", w.ID, err)
log.Error("telegramHandler.Metadata(%d): %v", w.ID, err)
}
return s
}
@ -191,6 +195,6 @@ type telegramConvertor struct{}
var _ payloadConvertor[TelegramPayload] = telegramConvertor{}
func newTelegramRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (telegramHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
return newJSONRequest(telegramConvertor{}, w, t, true)
}

View file

@ -186,7 +186,7 @@ func TestTelegramJSONPayload(t *testing.T) {
PayloadVersion: 2,
}
req, reqBody, err := newTelegramRequest(context.Background(), hook, task)
req, reqBody, err := telegramHandler{}.NewRequest(context.Background(), hook, task)
require.NotNil(t, req)
require.NotNil(t, reqBody)
require.NoError(t, err)

View file

@ -27,25 +27,46 @@ import (
"github.com/gobwas/glob"
)
var webhookRequesters = map[webhook_module.HookType]func(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error){
webhook_module.SLACK: newSlackRequest,
webhook_module.DISCORD: newDiscordRequest,
webhook_module.DINGTALK: newDingtalkRequest,
webhook_module.TELEGRAM: newTelegramRequest,
webhook_module.MSTEAMS: newMSTeamsRequest,
webhook_module.FEISHU: newFeishuRequest,
webhook_module.MATRIX: newMatrixRequest,
webhook_module.WECHATWORK: newWechatworkRequest,
webhook_module.PACKAGIST: newPackagistRequest,
type Handler interface {
Type() webhook_module.HookType
NewRequest(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error)
Metadata(*webhook_model.Webhook) any
}
var webhookHandlers = []Handler{
defaultHandler{true},
defaultHandler{false},
gogsHandler{},
slackHandler{},
discordHandler{},
dingtalkHandler{},
telegramHandler{},
msteamsHandler{},
feishuHandler{},
matrixHandler{},
wechatworkHandler{},
packagistHandler{},
}
// GetWebhookHandler return the handler for a given webhook type (nil if not found)
func GetWebhookHandler(name webhook_module.HookType) Handler {
for _, h := range webhookHandlers {
if h.Type() == name {
return h
}
}
return nil
}
// List provides a list of the supported webhooks
func List() []Handler {
return webhookHandlers
}
// IsValidHookTaskType returns true if a webhook registered
func IsValidHookTaskType(name string) bool {
if name == webhook_module.FORGEJO || name == webhook_module.GITEA || name == webhook_module.GOGS {
return true
}
_, ok := webhookRequesters[name]
return ok
return GetWebhookHandler(name) != nil
}
// hookQueue is a global queue of web hooks

View file

@ -16,18 +16,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestWebhook_GetSlackHook(t *testing.T) {
w := &webhook_model.Webhook{
Meta: `{"channel": "foo", "username": "username", "color": "blue"}`,
}
slackHook := GetSlackHook(w)
assert.Equal(t, *slackHook, SlackMeta{
Channel: "foo",
Username: "username",
Color: "blue",
})
}
func activateWebhook(t *testing.T, hookID int64) {
t.Helper()
updated, err := db.GetEngine(db.DefaultContext).ID(hookID).Cols("is_active").Update(webhook_model.Webhook{IsActive: true})

View file

@ -15,6 +15,11 @@ import (
webhook_module "code.gitea.io/gitea/modules/webhook"
)
type wechatworkHandler struct{}
func (wechatworkHandler) Type() webhook_module.HookType { return webhook_module.WECHATWORK }
func (wechatworkHandler) Metadata(*webhook_model.Webhook) any { return nil }
type (
// WechatworkPayload represents
WechatworkPayload struct {
@ -177,6 +182,6 @@ type wechatworkConvertor struct{}
var _ payloadConvertor[WechatworkPayload] = wechatworkConvertor{}
func newWechatworkRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
func (wechatworkHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
return newJSONRequest(wechatworkConvertor{}, w, t, true)
}

View file

@ -8,11 +8,11 @@
</div>
<div class="field">
<label for="username">{{ctx.Locale.Tr "repo.settings.discord_username"}}</label>
<input id="username" name="username" value="{{.DiscordHook.Username}}" placeholder="Forgejo">
<input id="username" name="username" value="{{.HookMetadata.Username}}" placeholder="Forgejo">
</div>
<div class="field">
<label for="icon_url">{{ctx.Locale.Tr "repo.settings.discord_icon_url"}}</label>
<input id="icon_url" name="icon_url" value="{{.DiscordHook.IconURL}}" placeholder="https://example.com/assets/img/logo.svg">
<input id="icon_url" name="icon_url" value="{{.HookMetadata.IconURL}}" placeholder="https://example.com/assets/img/logo.svg">
</div>
{{template "repo/settings/webhook/settings" .}}
</form>

View file

@ -4,16 +4,16 @@
{{.CsrfTokenHtml}}
<div class="required field {{if .Err_HomeserverURL}}error{{end}}">
<label for="homeserver_url">{{ctx.Locale.Tr "repo.settings.matrix.homeserver_url"}}</label>
<input id="homeserver_url" name="homeserver_url" type="url" value="{{.MatrixHook.HomeserverURL}}" autofocus required>
<input id="homeserver_url" name="homeserver_url" type="url" value="{{.HookMetadata.HomeserverURL}}" autofocus required>
</div>
<div class="required field {{if .Err_Room}}error{{end}}">
<label for="room_id">{{ctx.Locale.Tr "repo.settings.matrix.room_id"}}</label>
<input id="room_id" name="room_id" type="text" value="{{.MatrixHook.Room}}" required>
<input id="room_id" name="room_id" type="text" value="{{.HookMetadata.Room}}" required>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.matrix.message_type"}}</label>
<div class="ui selection dropdown">
<input type="hidden" id="message_type" name="message_type" value="{{if .MatrixHook.MessageType}}{{.MatrixHook.MessageType}}{{else}}1{{end}}">
<input type="hidden" id="message_type" name="message_type" value="{{if .HookMetadata.MessageType}}{{.HookMetadata.MessageType}}{{else}}1{{end}}">
<div class="default text"></div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">

View file

@ -4,15 +4,15 @@
{{.CsrfTokenHtml}}
<div class="required field {{if .Err_Username}}error{{end}}">
<label for="username">{{ctx.Locale.Tr "repo.settings.packagist_username"}}</label>
<input id="username" name="username" value="{{.PackagistHook.Username}}" placeholder="Forgejo" autofocus required>
<input id="username" name="username" value="{{.HookMetadata.Username}}" placeholder="Forgejo" autofocus required>
</div>
<div class="required field {{if .Err_APIToken}}error{{end}}">
<label for="api_token">{{ctx.Locale.Tr "repo.settings.packagist_api_token"}}</label>
<input id="api_token" name="api_token" value="{{.PackagistHook.APIToken}}" placeholder="X5F_tZ-Wj3c1vqaU2Rky" required>
<input id="api_token" name="api_token" value="{{.HookMetadata.APIToken}}" placeholder="X5F_tZ-Wj3c1vqaU2Rky" required>
</div>
<div class="required field {{if .Err_PackageURL}}error{{end}}">
<label for="package_url">{{ctx.Locale.Tr "repo.settings.packagist_package_url"}}</label>
<input id="package_url" name="package_url" value="{{.PackagistHook.PackageURL}}" placeholder="https://packagist.org/packages/laravel/framework" required>
<input id="package_url" name="package_url" value="{{.HookMetadata.PackageURL}}" placeholder="https://packagist.org/packages/laravel/framework" required>
</div>
{{template "repo/settings/webhook/settings" .}}
</form>

View file

@ -8,20 +8,20 @@
</div>
<div class="required field {{if .Err_Channel}}error{{end}}">
<label for="channel">{{ctx.Locale.Tr "repo.settings.slack_channel"}}</label>
<input id="channel" name="channel" value="{{.SlackHook.Channel}}" placeholder="#general" required>
<input id="channel" name="channel" value="{{.HookMetadata.Channel}}" placeholder="#general" required>
</div>
<div class="field">
<label for="username">{{ctx.Locale.Tr "repo.settings.slack_username"}}</label>
<input id="username" name="username" value="{{.SlackHook.Username}}" placeholder="Forgejo">
<input id="username" name="username" value="{{.HookMetadata.Username}}" placeholder="Forgejo">
</div>
<div class="field">
<label for="icon_url">{{ctx.Locale.Tr "repo.settings.slack_icon_url"}}</label>
<input id="icon_url" name="icon_url" value="{{.SlackHook.IconURL}}" placeholder="https://example.com/img/favicon.png">
<input id="icon_url" name="icon_url" value="{{.HookMetadata.IconURL}}" placeholder="https://example.com/img/favicon.png">
</div>
<div class="field">
<label for="color">{{ctx.Locale.Tr "repo.settings.slack_color"}}</label>
<input id="color" name="color" value="{{.SlackHook.Color}}" placeholder="#dd4b39, good, warning, danger">
<input id="color" name="color" value="{{.HookMetadata.Color}}" placeholder="#dd4b39, good, warning, danger">
</div>
{{template "repo/settings/webhook/settings" .}}
</form>

View file

@ -4,15 +4,15 @@
{{.CsrfTokenHtml}}
<div class="required field {{if .Err_BotToken}}error{{end}}">
<label for="bot_token">{{ctx.Locale.Tr "repo.settings.bot_token"}}</label>
<input id="bot_token" name="bot_token" type="text" value="{{.TelegramHook.BotToken}}" autofocus required>
<input id="bot_token" name="bot_token" type="text" value="{{.HookMetadata.BotToken}}" autofocus required>
</div>
<div class="required field {{if .Err_ChatID}}error{{end}}">
<label for="chat_id">{{ctx.Locale.Tr "repo.settings.chat_id"}}</label>
<input id="chat_id" name="chat_id" type="text" value="{{.TelegramHook.ChatID}}" required>
<input id="chat_id" name="chat_id" type="text" value="{{.HookMetadata.ChatID}}" required>
</div>
<div class="field {{if .Err_ThreadID}}error{{end}}">
<label for="thread_id">{{ctx.Locale.Tr "repo.settings.thread_id"}}</label>
<input id="thread_id" name="thread_id" type="text" value="{{.TelegramHook.ThreadID}}">
<input id="thread_id" name="thread_id" type="text" value="{{.HookMetadata.ThreadID}}">
</div>
{{template "repo/settings/webhook/settings" .}}
</form>

View file

@ -20952,12 +20952,17 @@
"x-go-name": "BranchFilter"
},
"config": {
"description": "Deprecated: use Metadata instead",
"type": "object",
"additionalProperties": {
"type": "string"
},
"x-go-name": "Config"
},
"content_type": {
"type": "string",
"x-go-name": "ContentType"
},
"created_at": {
"type": "string",
"format": "date-time",
@ -20975,6 +20980,9 @@
"format": "int64",
"x-go-name": "ID"
},
"metadata": {
"x-go-name": "Metadata"
},
"type": {
"type": "string",
"x-go-name": "Type"
@ -20983,6 +20991,10 @@
"type": "string",
"format": "date-time",
"x-go-name": "Updated"
},
"url": {
"type": "string",
"x-go-name": "URL"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"

View file

@ -40,5 +40,6 @@ func TestAPICreateHook(t *testing.T) {
var apiHook *api.Hook
DecodeJSON(t, resp, &apiHook)
assert.Equal(t, "http://example.com/", apiHook.Config["url"])
assert.Equal(t, "http://example.com/", apiHook.URL)
assert.Equal(t, "Bearer s3cr3t", apiHook.AuthorizationHeader)
}

View file

@ -11,6 +11,7 @@ import (
"testing"
gitea_context "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/webhook"
"code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery"
@ -21,7 +22,7 @@ func TestNewWebHookLink(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
webhooksLen := 12
webhooksLen := len(webhook.List())
baseurl := "/user2/repo1/settings/hooks"
tests := []string{
// webhook list page