From dd136858f1ea40ad3c94191d647487fa4f31926c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.0. Signed-off-by: Daniel Baumann --- models/webhook/hooktask.go | 262 ++++++++++++++++++++ models/webhook/main_test.go | 19 ++ models/webhook/webhook.go | 516 +++++++++++++++++++++++++++++++++++++++ models/webhook/webhook_system.go | 83 +++++++ models/webhook/webhook_test.go | 350 ++++++++++++++++++++++++++ 5 files changed, 1230 insertions(+) create mode 100644 models/webhook/hooktask.go create mode 100644 models/webhook/main_test.go create mode 100644 models/webhook/webhook.go create mode 100644 models/webhook/webhook_system.go create mode 100644 models/webhook/webhook_test.go (limited to 'models/webhook') diff --git a/models/webhook/hooktask.go b/models/webhook/hooktask.go new file mode 100644 index 0000000..8734feb --- /dev/null +++ b/models/webhook/hooktask.go @@ -0,0 +1,262 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webhook + +import ( + "context" + "errors" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + webhook_module "code.gitea.io/gitea/modules/webhook" + + gouuid "github.com/google/uuid" + "xorm.io/builder" +) + +// ___ ___ __ ___________ __ +// / | \ ____ ____ | | _\__ ___/____ _____| | __ +// / ~ \/ _ \ / _ \| |/ / | | \__ \ / ___/ |/ / +// \ Y ( <_> | <_> ) < | | / __ \_\___ \| < +// \___|_ / \____/ \____/|__|_ \ |____| (____ /____ >__|_ \ +// \/ \/ \/ \/ \/ + +// HookRequest represents hook task request information. +type HookRequest struct { + URL string `json:"url"` + HTTPMethod string `json:"http_method"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` +} + +// HookResponse represents hook task response information. +type HookResponse struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` +} + +// HookTask represents a hook task. +type HookTask struct { + ID int64 `xorm:"pk autoincr"` + HookID int64 `xorm:"index"` + UUID string `xorm:"unique"` + PayloadContent string `xorm:"LONGTEXT"` + // PayloadVersion number to allow for smooth version upgrades: + // - PayloadVersion 1: PayloadContent contains the JSON as sent to the URL + // - PayloadVersion 2: PayloadContent contains the original event + PayloadVersion int `xorm:"DEFAULT 1"` + + EventType webhook_module.HookEventType + IsDelivered bool + Delivered timeutil.TimeStampNano + + // History info. + IsSucceed bool + RequestContent string `xorm:"LONGTEXT"` + RequestInfo *HookRequest `xorm:"-"` + ResponseContent string `xorm:"LONGTEXT"` + ResponseInfo *HookResponse `xorm:"-"` +} + +func init() { + db.RegisterModel(new(HookTask)) +} + +// BeforeUpdate will be invoked by XORM before updating a record +// representing this object +func (t *HookTask) BeforeUpdate() { + if t.RequestInfo != nil { + t.RequestContent = t.simpleMarshalJSON(t.RequestInfo) + } + if t.ResponseInfo != nil { + t.ResponseContent = t.simpleMarshalJSON(t.ResponseInfo) + } +} + +// AfterLoad updates the webhook object upon setting a column +func (t *HookTask) AfterLoad() { + if len(t.RequestContent) == 0 { + return + } + + t.RequestInfo = &HookRequest{} + if err := json.Unmarshal([]byte(t.RequestContent), t.RequestInfo); err != nil { + log.Error("Unmarshal RequestContent[%d]: %v", t.ID, err) + } + + if len(t.ResponseContent) > 0 { + t.ResponseInfo = &HookResponse{} + if err := json.Unmarshal([]byte(t.ResponseContent), t.ResponseInfo); err != nil { + log.Error("Unmarshal ResponseContent[%d]: %v", t.ID, err) + } + } +} + +func (t *HookTask) simpleMarshalJSON(v any) string { + p, err := json.Marshal(v) + if err != nil { + log.Error("Marshal [%d]: %v", t.ID, err) + } + return string(p) +} + +// HookTasks returns a list of hook tasks by given conditions, order by ID desc. +func HookTasks(ctx context.Context, hookID int64, page int) ([]*HookTask, error) { + tasks := make([]*HookTask, 0, setting.Webhook.PagingNum) + return tasks, db.GetEngine(ctx). + Limit(setting.Webhook.PagingNum, (page-1)*setting.Webhook.PagingNum). + Where("hook_id=?", hookID). + Desc("id"). + Find(&tasks) +} + +// CreateHookTask creates a new hook task, +// it handles conversion from Payload to PayloadContent. +func CreateHookTask(ctx context.Context, t *HookTask) (*HookTask, error) { + t.UUID = gouuid.New().String() + if t.Delivered == 0 { + t.Delivered = timeutil.TimeStampNanoNow() + } + if t.PayloadVersion == 0 { + return nil, errors.New("missing HookTask.PayloadVersion") + } + return t, db.Insert(ctx, t) +} + +func GetHookTaskByID(ctx context.Context, id int64) (*HookTask, error) { + t := &HookTask{} + + has, err := db.GetEngine(ctx).ID(id).Get(t) + if err != nil { + return nil, err + } + if !has { + return nil, ErrHookTaskNotExist{ + TaskID: id, + } + } + return t, nil +} + +// UpdateHookTask updates information of hook task. +func UpdateHookTask(ctx context.Context, t *HookTask) error { + _, err := db.GetEngine(ctx).ID(t.ID).AllCols().Update(t) + return err +} + +// ReplayHookTask copies a hook task to get re-delivered +func ReplayHookTask(ctx context.Context, hookID int64, uuid string) (*HookTask, error) { + task, exist, err := db.Get[HookTask](ctx, builder.Eq{"hook_id": hookID, "uuid": uuid}) + if err != nil { + return nil, err + } else if !exist { + return nil, ErrHookTaskNotExist{ + HookID: hookID, + UUID: uuid, + } + } + + return CreateHookTask(ctx, &HookTask{ + HookID: task.HookID, + PayloadContent: task.PayloadContent, + EventType: task.EventType, + PayloadVersion: task.PayloadVersion, + }) +} + +// FindUndeliveredHookTaskIDs will find the next 100 undelivered hook tasks with ID greater than the provided lowerID +func FindUndeliveredHookTaskIDs(ctx context.Context, lowerID int64) ([]int64, error) { + const batchSize = 100 + + tasks := make([]int64, 0, batchSize) + return tasks, db.GetEngine(ctx). + Select("id"). + Table(new(HookTask)). + Where("is_delivered=?", false). + And("id > ?", lowerID). + Asc("id"). + Limit(batchSize). + Find(&tasks) +} + +func MarkTaskDelivered(ctx context.Context, task *HookTask) (bool, error) { + count, err := db.GetEngine(ctx).ID(task.ID).Where("is_delivered = ?", false).Cols("is_delivered").Update(&HookTask{ + ID: task.ID, + IsDelivered: true, + }) + + return count != 0, err +} + +// CleanupHookTaskTable deletes rows from hook_task as needed. +func CleanupHookTaskTable(ctx context.Context, cleanupType HookTaskCleanupType, olderThan time.Duration, numberToKeep int) error { + log.Trace("Doing: CleanupHookTaskTable") + + if cleanupType == OlderThan { + deleteOlderThan := time.Now().Add(-olderThan).UnixNano() + deletes, err := db.GetEngine(ctx). + Where("is_delivered = ? and delivered < ?", true, deleteOlderThan). + Delete(new(HookTask)) + if err != nil { + return err + } + log.Trace("Deleted %d rows from hook_task", deletes) + } else if cleanupType == PerWebhook { + hookIDs := make([]int64, 0, 10) + err := db.GetEngine(ctx). + Table("webhook"). + Where("id > 0"). + Cols("id"). + Find(&hookIDs) + if err != nil { + return err + } + for _, hookID := range hookIDs { + select { + case <-ctx.Done(): + return db.ErrCancelledf("Before deleting hook_task records for hook id %d", hookID) + default: + } + if err = deleteDeliveredHookTasksByWebhook(ctx, hookID, numberToKeep); err != nil { + return err + } + } + } + log.Trace("Finished: CleanupHookTaskTable") + return nil +} + +func deleteDeliveredHookTasksByWebhook(ctx context.Context, hookID int64, numberDeliveriesToKeep int) error { + log.Trace("Deleting hook_task rows for webhook %d, keeping the most recent %d deliveries", hookID, numberDeliveriesToKeep) + deliveryDates := make([]int64, 0, 10) + err := db.GetEngine(ctx).Table("hook_task"). + Where("hook_task.hook_id = ? AND hook_task.is_delivered = ? AND hook_task.delivered is not null", hookID, true). + Cols("hook_task.delivered"). + Join("INNER", "webhook", "hook_task.hook_id = webhook.id"). + OrderBy("hook_task.delivered desc"). + Limit(1, numberDeliveriesToKeep). + Find(&deliveryDates) + if err != nil { + return err + } + + if len(deliveryDates) > 0 { + deletes, err := db.GetEngine(ctx). + Where("hook_id = ? and is_delivered = ? and delivered <= ?", hookID, true, deliveryDates[0]). + Delete(new(HookTask)) + if err != nil { + return err + } + log.Trace("Deleted %d hook_task rows for webhook %d", deletes, hookID) + } else { + log.Trace("No hook_task rows to delete for webhook %d", hookID) + } + + return nil +} diff --git a/models/webhook/main_test.go b/models/webhook/main_test.go new file mode 100644 index 0000000..f19465d --- /dev/null +++ b/models/webhook/main_test.go @@ -0,0 +1,19 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webhook + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + FixtureFiles: []string{ + "webhook.yml", + "hook_task.yml", + }, + }) +} diff --git a/models/webhook/webhook.go b/models/webhook/webhook.go new file mode 100644 index 0000000..f3370f3 --- /dev/null +++ b/models/webhook/webhook.go @@ -0,0 +1,516 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webhook + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + webhook_module "code.gitea.io/gitea/modules/webhook" + + "xorm.io/builder" +) + +// ErrWebhookNotExist represents a "WebhookNotExist" kind of error. +type ErrWebhookNotExist struct { + ID int64 +} + +// IsErrWebhookNotExist checks if an error is a ErrWebhookNotExist. +func IsErrWebhookNotExist(err error) bool { + _, ok := err.(ErrWebhookNotExist) + return ok +} + +func (err ErrWebhookNotExist) Error() string { + return fmt.Sprintf("webhook does not exist [id: %d]", err.ID) +} + +func (err ErrWebhookNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrHookTaskNotExist represents a "HookTaskNotExist" kind of error. +type ErrHookTaskNotExist struct { + TaskID int64 + HookID int64 + UUID string +} + +// IsErrHookTaskNotExist checks if an error is a ErrHookTaskNotExist. +func IsErrHookTaskNotExist(err error) bool { + _, ok := err.(ErrHookTaskNotExist) + return ok +} + +func (err ErrHookTaskNotExist) Error() string { + return fmt.Sprintf("hook task does not exist [task: %d, hook: %d, uuid: %s]", err.TaskID, err.HookID, err.UUID) +} + +func (err ErrHookTaskNotExist) Unwrap() error { + return util.ErrNotExist +} + +// HookContentType is the content type of a web hook +type HookContentType int + +const ( + // ContentTypeJSON is a JSON payload for web hooks + ContentTypeJSON HookContentType = iota + 1 + // ContentTypeForm is an url-encoded form payload for web hook + ContentTypeForm +) + +var hookContentTypes = map[string]HookContentType{ + "json": ContentTypeJSON, + "form": ContentTypeForm, +} + +// ToHookContentType returns HookContentType by given name. +func ToHookContentType(name string) HookContentType { + return hookContentTypes[name] +} + +// HookTaskCleanupType is the type of cleanup to perform on hook_task +type HookTaskCleanupType int + +const ( + // OlderThan hook_task rows will be cleaned up by the age of the row + OlderThan HookTaskCleanupType = iota + // PerWebhook hook_task rows will be cleaned up by leaving the most recent deliveries for each webhook + PerWebhook +) + +var hookTaskCleanupTypes = map[string]HookTaskCleanupType{ + "OlderThan": OlderThan, + "PerWebhook": PerWebhook, +} + +// ToHookTaskCleanupType returns HookTaskCleanupType by given name. +func ToHookTaskCleanupType(name string) HookTaskCleanupType { + return hookTaskCleanupTypes[name] +} + +// Name returns the name of a given web hook's content type +func (t HookContentType) Name() string { + switch t { + case ContentTypeJSON: + return "json" + case ContentTypeForm: + return "form" + } + return "" +} + +// IsValidHookContentType returns true if given name is a valid hook content type. +func IsValidHookContentType(name string) bool { + _, ok := hookContentTypes[name] + return ok +} + +// 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 + OwnerID 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"` + *webhook_module.HookEvent `xorm:"-"` + IsActive bool `xorm:"INDEX"` + Type webhook_module.HookType `xorm:"VARCHAR(16) 'type'"` + Meta string `xorm:"TEXT"` // store hook-specific attributes + LastStatus webhook_module.HookStatus // Last delivery status + + // HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization() + HeaderAuthorizationEncrypted string `xorm:"TEXT"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +func init() { + db.RegisterModel(new(Webhook)) +} + +// AfterLoad updates the webhook object upon setting a column +func (w *Webhook) AfterLoad() { + w.HookEvent = &webhook_module.HookEvent{} + if err := json.Unmarshal([]byte(w.Events), w.HookEvent); err != nil { + log.Error("Unmarshal[%d]: %v", w.ID, err) + } +} + +// History returns history of webhook by given conditions. +func (w *Webhook) History(ctx context.Context, page int) ([]*HookTask, error) { + return HookTasks(ctx, w.ID, page) +} + +// UpdateEvent handles conversion from HookEvent to Events. +func (w *Webhook) UpdateEvent() error { + data, err := json.Marshal(w.HookEvent) + w.Events = string(data) + return err +} + +// HasCreateEvent returns true if hook enabled create event. +func (w *Webhook) HasCreateEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Create) +} + +// HasDeleteEvent returns true if hook enabled delete event. +func (w *Webhook) HasDeleteEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Delete) +} + +// HasForkEvent returns true if hook enabled fork event. +func (w *Webhook) HasForkEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Fork) +} + +// HasIssuesEvent returns true if hook enabled issues event. +func (w *Webhook) HasIssuesEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Issues) +} + +// HasIssuesAssignEvent returns true if hook enabled issues assign event. +func (w *Webhook) HasIssuesAssignEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.IssueAssign) +} + +// HasIssuesLabelEvent returns true if hook enabled issues label event. +func (w *Webhook) HasIssuesLabelEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.IssueLabel) +} + +// HasIssuesMilestoneEvent returns true if hook enabled issues milestone event. +func (w *Webhook) HasIssuesMilestoneEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.IssueMilestone) +} + +// HasIssueCommentEvent returns true if hook enabled issue_comment event. +func (w *Webhook) HasIssueCommentEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.IssueComment) +} + +// HasPushEvent returns true if hook enabled push event. +func (w *Webhook) HasPushEvent() bool { + return w.PushOnly || w.SendEverything || + (w.ChooseEvents && w.HookEvents.Push) +} + +// HasPullRequestEvent returns true if hook enabled pull request event. +func (w *Webhook) HasPullRequestEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequest) +} + +// HasPullRequestAssignEvent returns true if hook enabled pull request assign event. +func (w *Webhook) HasPullRequestAssignEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestAssign) +} + +// HasPullRequestLabelEvent returns true if hook enabled pull request label event. +func (w *Webhook) HasPullRequestLabelEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestLabel) +} + +// HasPullRequestMilestoneEvent returns true if hook enabled pull request milestone event. +func (w *Webhook) HasPullRequestMilestoneEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestMilestone) +} + +// HasPullRequestCommentEvent returns true if hook enabled pull_request_comment event. +func (w *Webhook) HasPullRequestCommentEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestComment) +} + +// HasPullRequestApprovedEvent returns true if hook enabled pull request review event. +func (w *Webhook) HasPullRequestApprovedEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestReview) +} + +// HasPullRequestRejectedEvent returns true if hook enabled pull request review event. +func (w *Webhook) HasPullRequestRejectedEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestReview) +} + +// HasPullRequestReviewCommentEvent returns true if hook enabled pull request review event. +func (w *Webhook) HasPullRequestReviewCommentEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestReview) +} + +// HasPullRequestSyncEvent returns true if hook enabled pull request sync event. +func (w *Webhook) HasPullRequestSyncEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestSync) +} + +// HasWikiEvent returns true if hook enabled wiki event. +func (w *Webhook) HasWikiEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvent.Wiki) +} + +// HasReleaseEvent returns if hook enabled release event. +func (w *Webhook) HasReleaseEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Release) +} + +// HasRepositoryEvent returns if hook enabled repository event. +func (w *Webhook) HasRepositoryEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Repository) +} + +// HasPackageEvent returns if hook enabled package event. +func (w *Webhook) HasPackageEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Package) +} + +// HasPullRequestReviewRequestEvent returns true if hook enabled pull request review request event. +func (w *Webhook) HasPullRequestReviewRequestEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequestReviewRequest) +} + +// EventCheckers returns event checkers +func (w *Webhook) EventCheckers() []struct { + Has func() bool + Type webhook_module.HookEventType +} { + return []struct { + Has func() bool + Type webhook_module.HookEventType + }{ + {w.HasCreateEvent, webhook_module.HookEventCreate}, + {w.HasDeleteEvent, webhook_module.HookEventDelete}, + {w.HasForkEvent, webhook_module.HookEventFork}, + {w.HasPushEvent, webhook_module.HookEventPush}, + {w.HasIssuesEvent, webhook_module.HookEventIssues}, + {w.HasIssuesAssignEvent, webhook_module.HookEventIssueAssign}, + {w.HasIssuesLabelEvent, webhook_module.HookEventIssueLabel}, + {w.HasIssuesMilestoneEvent, webhook_module.HookEventIssueMilestone}, + {w.HasIssueCommentEvent, webhook_module.HookEventIssueComment}, + {w.HasPullRequestEvent, webhook_module.HookEventPullRequest}, + {w.HasPullRequestAssignEvent, webhook_module.HookEventPullRequestAssign}, + {w.HasPullRequestLabelEvent, webhook_module.HookEventPullRequestLabel}, + {w.HasPullRequestMilestoneEvent, webhook_module.HookEventPullRequestMilestone}, + {w.HasPullRequestCommentEvent, webhook_module.HookEventPullRequestComment}, + {w.HasPullRequestApprovedEvent, webhook_module.HookEventPullRequestReviewApproved}, + {w.HasPullRequestRejectedEvent, webhook_module.HookEventPullRequestReviewRejected}, + {w.HasPullRequestCommentEvent, webhook_module.HookEventPullRequestReviewComment}, + {w.HasPullRequestSyncEvent, webhook_module.HookEventPullRequestSync}, + {w.HasWikiEvent, webhook_module.HookEventWiki}, + {w.HasRepositoryEvent, webhook_module.HookEventRepository}, + {w.HasReleaseEvent, webhook_module.HookEventRelease}, + {w.HasPackageEvent, webhook_module.HookEventPackage}, + {w.HasPullRequestReviewRequestEvent, webhook_module.HookEventPullRequestReviewRequest}, + } +} + +// EventsArray returns an array of hook events +func (w *Webhook) EventsArray() []string { + events := make([]string, 0, 7) + + for _, c := range w.EventCheckers() { + if c.Has() { + events = append(events, string(c.Type)) + } + } + return events +} + +// HeaderAuthorization returns the decrypted Authorization header. +// Not on the reference (*w), to be accessible on WebhooksNew. +func (w Webhook) HeaderAuthorization() (string, error) { + if w.HeaderAuthorizationEncrypted == "" { + return "", nil + } + return secret.DecryptSecret(setting.SecretKey, w.HeaderAuthorizationEncrypted) +} + +// HeaderAuthorizationTrimPrefix returns the decrypted Authorization with a specified prefix trimmed. +func (w Webhook) HeaderAuthorizationTrimPrefix(prefix string) (string, error) { + s, err := w.HeaderAuthorization() + if err != nil { + return "", err + } + return strings.TrimPrefix(s, prefix), nil +} + +// SetHeaderAuthorization encrypts and sets the Authorization header. +func (w *Webhook) SetHeaderAuthorization(cleartext string) error { + if cleartext == "" { + w.HeaderAuthorizationEncrypted = "" + return nil + } + ciphertext, err := secret.EncryptSecret(setting.SecretKey, cleartext) + if err != nil { + return err + } + w.HeaderAuthorizationEncrypted = ciphertext + return nil +} + +// CreateWebhook creates a new web hook. +func CreateWebhook(ctx context.Context, w *Webhook) error { + w.Type = strings.TrimSpace(w.Type) + return db.Insert(ctx, w) +} + +// CreateWebhooks creates multiple web hooks +func CreateWebhooks(ctx context.Context, ws []*Webhook) error { + // xorm returns err "no element on slice when insert" for empty slices. + if len(ws) == 0 { + return nil + } + for i := 0; i < len(ws); i++ { + ws[i].Type = strings.TrimSpace(ws[i].Type) + } + return db.Insert(ctx, ws) +} + +// GetWebhookByID returns webhook of repository by given ID. +func GetWebhookByID(ctx context.Context, id int64) (*Webhook, error) { + bean := new(Webhook) + has, err := db.GetEngine(ctx).ID(id).Get(bean) + if err != nil { + return nil, err + } else if !has { + return nil, ErrWebhookNotExist{ID: id} + } + return bean, nil +} + +// GetWebhookByRepoID returns webhook of repository by given ID. +func GetWebhookByRepoID(ctx context.Context, repoID, id int64) (*Webhook, error) { + webhook := new(Webhook) + has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", id, repoID).Get(webhook) + if err != nil { + return nil, err + } else if !has { + return nil, ErrWebhookNotExist{ID: id} + } + return webhook, nil +} + +// GetWebhookByOwnerID returns webhook of a user or organization by given ID. +func GetWebhookByOwnerID(ctx context.Context, ownerID, id int64) (*Webhook, error) { + webhook := new(Webhook) + has, err := db.GetEngine(ctx).Where("id=? AND owner_id=?", id, ownerID).Get(webhook) + if err != nil { + return nil, err + } else if !has { + return nil, ErrWebhookNotExist{ID: id} + } + return webhook, nil +} + +// ListWebhookOptions are options to filter webhooks on ListWebhooksByOpts +type ListWebhookOptions struct { + db.ListOptions + RepoID int64 + OwnerID int64 + IsActive optional.Option[bool] +} + +func (opts ListWebhookOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID != 0 { + cond = cond.And(builder.Eq{"webhook.repo_id": opts.RepoID}) + } + if opts.OwnerID != 0 { + cond = cond.And(builder.Eq{"webhook.owner_id": opts.OwnerID}) + } + if opts.IsActive.Has() { + cond = cond.And(builder.Eq{"webhook.is_active": opts.IsActive.Value()}) + } + return cond +} + +var _ db.FindOptionsOrder = ListWebhookOptions{} + +// ToOrders implements db.FindOptionsOrder, to sort the webhooks by id asc +func (opts ListWebhookOptions) ToOrders() string { + return "webhook.id" +} + +// UpdateWebhook updates information of webhook. +func UpdateWebhook(ctx context.Context, w *Webhook) error { + _, err := db.GetEngine(ctx).ID(w.ID).AllCols().Update(w) + return err +} + +// UpdateWebhookLastStatus updates last status of webhook. +func UpdateWebhookLastStatus(ctx context.Context, w *Webhook) error { + _, err := db.GetEngine(ctx).ID(w.ID).Cols("last_status").Update(w) + return err +} + +// DeleteWebhookByID uses argument bean as query condition, +// ID must be specified and do not assign unnecessary fields. +func DeleteWebhookByID(ctx context.Context, id int64) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if count, err := db.DeleteByID[Webhook](ctx, id); err != nil { + return err + } else if count == 0 { + return ErrWebhookNotExist{ID: id} + } else if _, err = db.DeleteByBean(ctx, &HookTask{HookID: id}); err != nil { + return err + } + + return committer.Commit() +} + +// DeleteWebhookByRepoID deletes webhook of repository by given ID. +func DeleteWebhookByRepoID(ctx context.Context, repoID, id int64) error { + if _, err := GetWebhookByRepoID(ctx, repoID, id); err != nil { + return err + } + return DeleteWebhookByID(ctx, id) +} + +// DeleteWebhookByOwnerID deletes webhook of a user or organization by given ID. +func DeleteWebhookByOwnerID(ctx context.Context, ownerID, id int64) error { + if _, err := GetWebhookByOwnerID(ctx, ownerID, id); err != nil { + return err + } + return DeleteWebhookByID(ctx, id) +} diff --git a/models/webhook/webhook_system.go b/models/webhook/webhook_system.go new file mode 100644 index 0000000..62e8286 --- /dev/null +++ b/models/webhook/webhook_system.go @@ -0,0 +1,83 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webhook + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" +) + +// GetDefaultWebhooks returns all admin-default webhooks. +func GetDefaultWebhooks(ctx context.Context) ([]*Webhook, error) { + return getAdminWebhooks(ctx, false) +} + +// GetSystemOrDefaultWebhook returns admin system or default webhook by given ID. +func GetSystemOrDefaultWebhook(ctx context.Context, id int64) (*Webhook, error) { + webhook := &Webhook{ID: id} + has, err := db.GetEngine(ctx). + Where("repo_id=? AND owner_id=?", 0, 0). + Get(webhook) + if err != nil { + return nil, err + } else if !has { + return nil, ErrWebhookNotExist{ID: id} + } + return webhook, nil +} + +// GetSystemWebhooks returns all admin system webhooks. +func GetSystemWebhooks(ctx context.Context, onlyActive bool) ([]*Webhook, error) { + return getAdminWebhooks(ctx, true, onlyActive) +} + +func getAdminWebhooks(ctx context.Context, systemWebhooks bool, onlyActive ...bool) ([]*Webhook, error) { + webhooks := make([]*Webhook, 0, 5) + if len(onlyActive) > 0 && onlyActive[0] { + return webhooks, db.GetEngine(ctx). + Where("repo_id=? AND owner_id=? AND is_system_webhook=? AND is_active = ?", 0, 0, systemWebhooks, true). + OrderBy("id"). + Find(&webhooks) + } + return webhooks, db.GetEngine(ctx). + Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, systemWebhooks). + OrderBy("id"). + Find(&webhooks) +} + +// DeleteDefaultSystemWebhook deletes an admin-configured default or system webhook (where Org and Repo ID both 0) +func DeleteDefaultSystemWebhook(ctx context.Context, id int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + count, err := db.GetEngine(ctx). + Where("repo_id=? AND owner_id=?", 0, 0). + Delete(&Webhook{ID: id}) + if err != nil { + return err + } else if count == 0 { + return ErrWebhookNotExist{ID: id} + } + + _, err = db.DeleteByBean(ctx, &HookTask{HookID: id}) + return err + }) +} + +// CopyDefaultWebhooksToRepo creates copies of the default webhooks in a new repo +func CopyDefaultWebhooksToRepo(ctx context.Context, repoID int64) error { + ws, err := GetDefaultWebhooks(ctx) + if err != nil { + return fmt.Errorf("GetDefaultWebhooks: %v", err) + } + + for _, w := range ws { + w.ID = 0 + w.RepoID = repoID + if err := CreateWebhook(ctx, w); err != nil { + return fmt.Errorf("CreateWebhook: %v", err) + } + } + return nil +} diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go new file mode 100644 index 0000000..848440b --- /dev/null +++ b/models/webhook/webhook_test.go @@ -0,0 +1,350 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webhook + +import ( + "context" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/timeutil" + webhook_module "code.gitea.io/gitea/modules/webhook" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHookContentType_Name(t *testing.T) { + assert.Equal(t, "json", ContentTypeJSON.Name()) + assert.Equal(t, "form", ContentTypeForm.Name()) +} + +func TestIsValidHookContentType(t *testing.T) { + assert.True(t, IsValidHookContentType("json")) + assert.True(t, IsValidHookContentType("form")) + assert.False(t, IsValidHookContentType("invalid")) +} + +func TestWebhook_History(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + webhook := unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 1}) + tasks, err := webhook.History(db.DefaultContext, 0) + require.NoError(t, err) + if assert.Len(t, tasks, 3) { + assert.Equal(t, int64(3), tasks[0].ID) + assert.Equal(t, int64(2), tasks[1].ID) + assert.Equal(t, int64(1), tasks[2].ID) + } + + webhook = unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 2}) + tasks, err = webhook.History(db.DefaultContext, 0) + require.NoError(t, err) + assert.Empty(t, tasks) +} + +func TestWebhook_UpdateEvent(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + webhook := unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 1}) + hookEvent := &webhook_module.HookEvent{ + PushOnly: true, + SendEverything: false, + ChooseEvents: false, + HookEvents: webhook_module.HookEvents{ + Create: false, + Push: true, + PullRequest: false, + }, + } + webhook.HookEvent = hookEvent + require.NoError(t, webhook.UpdateEvent()) + assert.NotEmpty(t, webhook.Events) + actualHookEvent := &webhook_module.HookEvent{} + require.NoError(t, json.Unmarshal([]byte(webhook.Events), actualHookEvent)) + assert.Equal(t, *hookEvent, *actualHookEvent) +} + +func TestWebhook_EventsArray(t *testing.T) { + assert.Equal(t, []string{ + "create", "delete", "fork", "push", + "issues", "issue_assign", "issue_label", "issue_milestone", "issue_comment", + "pull_request", "pull_request_assign", "pull_request_label", "pull_request_milestone", + "pull_request_comment", "pull_request_review_approved", "pull_request_review_rejected", + "pull_request_review_comment", "pull_request_sync", "wiki", "repository", "release", + "package", "pull_request_review_request", + }, + (&Webhook{ + HookEvent: &webhook_module.HookEvent{SendEverything: true}, + }).EventsArray(), + ) + + assert.Equal(t, []string{"push"}, + (&Webhook{ + HookEvent: &webhook_module.HookEvent{PushOnly: true}, + }).EventsArray(), + ) +} + +func TestCreateWebhook(t *testing.T) { + hook := &Webhook{ + RepoID: 3, + URL: "www.example.com/unit_test", + ContentType: ContentTypeJSON, + Events: `{"push_only":false,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":true}}`, + } + unittest.AssertNotExistsBean(t, hook) + require.NoError(t, CreateWebhook(db.DefaultContext, hook)) + unittest.AssertExistsAndLoadBean(t, hook) +} + +func TestGetWebhookByRepoID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hook, err := GetWebhookByRepoID(db.DefaultContext, 1, 1) + require.NoError(t, err) + assert.Equal(t, int64(1), hook.ID) + + _, err = GetWebhookByRepoID(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID) + require.Error(t, err) + assert.True(t, IsErrWebhookNotExist(err)) +} + +func TestGetWebhookByOwnerID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hook, err := GetWebhookByOwnerID(db.DefaultContext, 3, 3) + require.NoError(t, err) + assert.Equal(t, int64(3), hook.ID) + + _, err = GetWebhookByOwnerID(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID) + require.Error(t, err) + assert.True(t, IsErrWebhookNotExist(err)) +} + +func TestGetActiveWebhooksByRepoID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + activateWebhook(t, 1) + + hooks, err := db.Find[Webhook](db.DefaultContext, ListWebhookOptions{RepoID: 1, IsActive: optional.Some(true)}) + require.NoError(t, err) + if assert.Len(t, hooks, 1) { + assert.Equal(t, int64(1), hooks[0].ID) + assert.True(t, hooks[0].IsActive) + } +} + +func TestGetWebhooksByRepoID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hooks, err := db.Find[Webhook](db.DefaultContext, ListWebhookOptions{RepoID: 1}) + require.NoError(t, err) + if assert.Len(t, hooks, 2) { + assert.Equal(t, int64(1), hooks[0].ID) + assert.Equal(t, int64(2), hooks[1].ID) + } +} + +func TestGetActiveWebhooksByOwnerID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + activateWebhook(t, 3) + + hooks, err := db.Find[Webhook](db.DefaultContext, ListWebhookOptions{OwnerID: 3, IsActive: optional.Some(true)}) + require.NoError(t, err) + if assert.Len(t, hooks, 1) { + assert.Equal(t, int64(3), hooks[0].ID) + assert.True(t, hooks[0].IsActive) + } +} + +func activateWebhook(t *testing.T, hookID int64) { + t.Helper() + updated, err := db.GetEngine(db.DefaultContext).ID(hookID).Cols("is_active").Update(Webhook{IsActive: true}) + assert.Equal(t, int64(1), updated) + require.NoError(t, err) +} + +func TestGetWebhooksByOwnerID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + activateWebhook(t, 3) + + hooks, err := db.Find[Webhook](db.DefaultContext, ListWebhookOptions{OwnerID: 3}) + require.NoError(t, err) + if assert.Len(t, hooks, 1) { + assert.Equal(t, int64(3), hooks[0].ID) + assert.True(t, hooks[0].IsActive) + } +} + +func TestUpdateWebhook(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 2}) + hook.IsActive = true + hook.ContentType = ContentTypeForm + unittest.AssertNotExistsBean(t, hook) + require.NoError(t, UpdateWebhook(db.DefaultContext, hook)) + unittest.AssertExistsAndLoadBean(t, hook) +} + +func TestDeleteWebhookByRepoID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 2, RepoID: 1}) + require.NoError(t, DeleteWebhookByRepoID(db.DefaultContext, 1, 2)) + unittest.AssertNotExistsBean(t, &Webhook{ID: 2, RepoID: 1}) + + err := DeleteWebhookByRepoID(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID) + require.Error(t, err) + assert.True(t, IsErrWebhookNotExist(err)) +} + +func TestDeleteWebhookByOwnerID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 3, OwnerID: 3}) + require.NoError(t, DeleteWebhookByOwnerID(db.DefaultContext, 3, 3)) + unittest.AssertNotExistsBean(t, &Webhook{ID: 3, OwnerID: 3}) + + err := DeleteWebhookByOwnerID(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID) + require.Error(t, err) + assert.True(t, IsErrWebhookNotExist(err)) +} + +func TestHookTasks(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTasks, err := HookTasks(db.DefaultContext, 1, 1) + require.NoError(t, err) + if assert.Len(t, hookTasks, 3) { + assert.Equal(t, int64(3), hookTasks[0].ID) + assert.Equal(t, int64(2), hookTasks[1].ID) + assert.Equal(t, int64(1), hookTasks[2].ID) + } + + hookTasks, err = HookTasks(db.DefaultContext, unittest.NonexistentID, 1) + require.NoError(t, err) + assert.Empty(t, hookTasks) +} + +func TestCreateHookTask(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTask := &HookTask{ + HookID: 3, + PayloadVersion: 2, + } + unittest.AssertNotExistsBean(t, hookTask) + _, err := CreateHookTask(db.DefaultContext, hookTask) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, hookTask) +} + +func TestUpdateHookTask(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + hook := unittest.AssertExistsAndLoadBean(t, &HookTask{ID: 1}) + hook.PayloadContent = "new payload content" + hook.IsDelivered = true + unittest.AssertNotExistsBean(t, hook) + require.NoError(t, UpdateHookTask(db.DefaultContext, hook)) + unittest.AssertExistsAndLoadBean(t, hook) +} + +func TestCleanupHookTaskTable_PerWebhook_DeletesDelivered(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTask := &HookTask{ + HookID: 3, + IsDelivered: true, + Delivered: timeutil.TimeStampNanoNow(), + PayloadVersion: 2, + } + unittest.AssertNotExistsBean(t, hookTask) + _, err := CreateHookTask(db.DefaultContext, hookTask) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, hookTask) + + require.NoError(t, CleanupHookTaskTable(context.Background(), PerWebhook, 168*time.Hour, 0)) + unittest.AssertNotExistsBean(t, hookTask) +} + +func TestCleanupHookTaskTable_PerWebhook_LeavesUndelivered(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTask := &HookTask{ + HookID: 4, + IsDelivered: false, + PayloadVersion: 2, + } + unittest.AssertNotExistsBean(t, hookTask) + _, err := CreateHookTask(db.DefaultContext, hookTask) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, hookTask) + + require.NoError(t, CleanupHookTaskTable(context.Background(), PerWebhook, 168*time.Hour, 0)) + unittest.AssertExistsAndLoadBean(t, hookTask) +} + +func TestCleanupHookTaskTable_PerWebhook_LeavesMostRecentTask(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTask := &HookTask{ + HookID: 4, + IsDelivered: true, + Delivered: timeutil.TimeStampNanoNow(), + PayloadVersion: 2, + } + unittest.AssertNotExistsBean(t, hookTask) + _, err := CreateHookTask(db.DefaultContext, hookTask) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, hookTask) + + require.NoError(t, CleanupHookTaskTable(context.Background(), PerWebhook, 168*time.Hour, 1)) + unittest.AssertExistsAndLoadBean(t, hookTask) +} + +func TestCleanupHookTaskTable_OlderThan_DeletesDelivered(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTask := &HookTask{ + HookID: 3, + IsDelivered: true, + Delivered: timeutil.TimeStampNano(time.Now().AddDate(0, 0, -8).UnixNano()), + PayloadVersion: 2, + } + unittest.AssertNotExistsBean(t, hookTask) + _, err := CreateHookTask(db.DefaultContext, hookTask) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, hookTask) + + require.NoError(t, CleanupHookTaskTable(context.Background(), OlderThan, 168*time.Hour, 0)) + unittest.AssertNotExistsBean(t, hookTask) +} + +func TestCleanupHookTaskTable_OlderThan_LeavesUndelivered(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTask := &HookTask{ + HookID: 4, + IsDelivered: false, + PayloadVersion: 2, + } + unittest.AssertNotExistsBean(t, hookTask) + _, err := CreateHookTask(db.DefaultContext, hookTask) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, hookTask) + + require.NoError(t, CleanupHookTaskTable(context.Background(), OlderThan, 168*time.Hour, 0)) + unittest.AssertExistsAndLoadBean(t, hookTask) +} + +func TestCleanupHookTaskTable_OlderThan_LeavesTaskEarlierThanAgeToDelete(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + hookTask := &HookTask{ + HookID: 4, + IsDelivered: true, + Delivered: timeutil.TimeStampNano(time.Now().AddDate(0, 0, -6).UnixNano()), + PayloadVersion: 2, + } + unittest.AssertNotExistsBean(t, hookTask) + _, err := CreateHookTask(db.DefaultContext, hookTask) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, hookTask) + + require.NoError(t, CleanupHookTaskTable(context.Background(), OlderThan, 168*time.Hour, 0)) + unittest.AssertExistsAndLoadBean(t, hookTask) +} -- cgit v1.2.3