From ad7d485eb5ec7f7556cad7a045431d015bd83947 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 13 Sep 2023 21:24:06 +0200 Subject: [PATCH 01/28] feat(webhooks): add basic crud actions for webhooks --- pkg/migration/20230913202615.go | 50 ++++++++++++++++++ pkg/models/webhooks.go | 94 +++++++++++++++++++++++++++++++++ pkg/models/webhooks_rights.go | 42 +++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 pkg/migration/20230913202615.go create mode 100644 pkg/models/webhooks.go create mode 100644 pkg/models/webhooks_rights.go diff --git a/pkg/migration/20230913202615.go b/pkg/migration/20230913202615.go new file mode 100644 index 00000000000..1df44f1e91b --- /dev/null +++ b/pkg/migration/20230913202615.go @@ -0,0 +1,50 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "time" + "xorm.io/xorm" +) + +type webhooks20230913202615 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"` + TargetURL string `xorm:"not null" valid:"minstringlength(1)" minLength:"1" json:"target_url"` + Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"event"` + ProjectID int64 `xorm:"not null" json:"project_id" param:"project"` + CreatedByID int64 `xorm:"bigint not null" json:"-"` + Created time.Time `xorm:"created not null" json:"created"` + Updated time.Time `xorm:"updated not null" json:"updated"` +} + +func (webhooks20230913202615) TableName() string { + return "webhooks" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20230913202615", + Description: "", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(webhooks20230913202615{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go new file mode 100644 index 00000000000..561b7a3611e --- /dev/null +++ b/pkg/models/webhooks.go @@ -0,0 +1,94 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/web" + "time" + "xorm.io/xorm" +) + +type Webhook struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"` + TargetURL string `xorm:"not null" valid:"minstringlength(1)" minLength:"1" json:"target_url"` + Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"event"` + ProjectID int64 `xorm:"not null" json:"project_id" param:"project"` + + // The user who initially created the webhook target. + CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"` + CreatedByID int64 `xorm:"bigint not null" json:"-"` + + // A timestamp when this webhook target was created. You cannot change this value. + Created time.Time `xorm:"created not null" json:"created"` + // A timestamp when this webhook target was last updated. You cannot change this value. + Updated time.Time `xorm:"updated not null" json:"updated"` + + web.CRUDable `xorm:"-" json:"-"` + web.Rights `xorm:"-" json:"-"` +} + +func (w *Webhook) TableName() string { + return "webhooks" +} + +func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { + // TODO: check valid webhook events + w.CreatedByID = a.GetID() + _, err = s.Insert(w) + return +} + +func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { + p := &Project{ID: w.ProjectID} + can, _, err := p.CanRead(s, a) + if err != nil { + return nil, 0, 0, err + } + if !can { + return nil, 0, 0, ErrGenericForbidden{} + } + + ws := []*Webhook{} + err = s.Where("project_id = ?", w.ProjectID). + Limit(getLimitFromPageIndex(page, perPage)). + Find(&ws) + if err != nil { + return + } + + total, err := s.Where("project_id = ?", w.ProjectID). + Count(&Webhook{}) + if err != nil { + return + } + + return ws, len(ws), total, err +} + +func (w *Webhook) Update(s *xorm.Session, a web.Auth) (err error) { + // TODO validate webhook events + _, err = s.Where("id = ?", w.ID). + Cols("events"). + Update(w) + return +} + +func (w *Webhook) Delete(s *xorm.Session, a web.Auth) (err error) { + _, err = s.Where("id = ?", w.ID).Delete(&Webhook{}) + return +} diff --git a/pkg/models/webhooks_rights.go b/pkg/models/webhooks_rights.go new file mode 100644 index 00000000000..fed8483be20 --- /dev/null +++ b/pkg/models/webhooks_rights.go @@ -0,0 +1,42 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/web" + "xorm.io/xorm" +) + +func (w *Webhook) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + p := &Project{ID: w.ProjectID} + return p.CanRead(s, a) +} + +func (w *Webhook) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + p := &Project{ID: w.ProjectID} + return p.CanUpdate(s, a) +} + +func (w *Webhook) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { + p := &Project{ID: w.ProjectID} + return p.CanUpdate(s, a) +} + +func (w *Webhook) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + p := &Project{ID: w.ProjectID} + return p.CanUpdate(s, a) +} From e5b8d8bd2d629e363af5b069c2628d3df504ca21 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 13 Sep 2023 21:25:05 +0200 Subject: [PATCH 02/28] feat(webhooks): register task and project events as webhook --- pkg/models/events.go | 72 ++++++++++++++++++++++++++++++++++++++--- pkg/models/listeners.go | 36 +++++++++++++++++++++ pkg/models/webhooks.go | 22 +++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/pkg/models/events.go b/pkg/models/events.go index fb75be8143b..467f8cf3ff5 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -36,6 +36,10 @@ func (t *TaskCreatedEvent) Name() string { return "task.created" } +func (t *TaskCreatedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskUpdatedEvent represents an event where a task has been updated type TaskUpdatedEvent struct { Task *Task @@ -47,6 +51,10 @@ func (t *TaskUpdatedEvent) Name() string { return "task.updated" } +func (t *TaskUpdatedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskDeletedEvent represents a TaskDeletedEvent event type TaskDeletedEvent struct { Task *Task @@ -58,6 +66,10 @@ func (t *TaskDeletedEvent) Name() string { return "task.deleted" } +func (t *TaskDeletedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskAssigneeCreatedEvent represents an event where a task has been assigned to a user type TaskAssigneeCreatedEvent struct { Task *Task @@ -70,6 +82,10 @@ func (t *TaskAssigneeCreatedEvent) Name() string { return "task.assignee.created" } +func (t *TaskAssigneeCreatedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskAssigneeDeletedEvent represents a TaskAssigneeDeletedEvent event type TaskAssigneeDeletedEvent struct { Task *Task @@ -82,6 +98,10 @@ func (t *TaskAssigneeDeletedEvent) Name() string { return "task.assignee.deleted" } +func (t *TaskAssigneeDeletedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskCommentCreatedEvent represents an event where a task comment has been created type TaskCommentCreatedEvent struct { Task *Task @@ -94,6 +114,10 @@ func (t *TaskCommentCreatedEvent) Name() string { return "task.comment.created" } +func (t *TaskCommentCreatedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskCommentUpdatedEvent represents a TaskCommentUpdatedEvent event type TaskCommentUpdatedEvent struct { Task *Task @@ -106,6 +130,10 @@ func (t *TaskCommentUpdatedEvent) Name() string { return "task.comment.edited" } +func (t *TaskCommentUpdatedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskCommentDeletedEvent represents a TaskCommentDeletedEvent event type TaskCommentDeletedEvent struct { Task *Task @@ -118,6 +146,10 @@ func (t *TaskCommentDeletedEvent) Name() string { return "task.comment.deleted" } +func (t *TaskCommentDeletedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskAttachmentCreatedEvent represents a TaskAttachmentCreatedEvent event type TaskAttachmentCreatedEvent struct { Task *Task @@ -130,6 +162,10 @@ func (t *TaskAttachmentCreatedEvent) Name() string { return "task.attachment.created" } +func (t *TaskAttachmentCreatedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskAttachmentDeletedEvent represents a TaskAttachmentDeletedEvent event type TaskAttachmentDeletedEvent struct { Task *Task @@ -142,6 +178,10 @@ func (t *TaskAttachmentDeletedEvent) Name() string { return "task.attachment.deleted" } +func (t *TaskAttachmentDeletedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskRelationCreatedEvent represents a TaskRelationCreatedEvent event type TaskRelationCreatedEvent struct { Task *Task @@ -154,6 +194,10 @@ func (t *TaskRelationCreatedEvent) Name() string { return "task.relation.created" } +func (t *TaskRelationCreatedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + // TaskRelationDeletedEvent represents a TaskRelationDeletedEvent event type TaskRelationDeletedEvent struct { Task *Task @@ -166,6 +210,10 @@ func (t *TaskRelationDeletedEvent) Name() string { return "task.relation.deleted" } +func (t *TaskRelationDeletedEvent) ProjectID() int64 { + return t.Task.ProjectID +} + //////////////////// // Project Events // //////////////////// @@ -188,10 +236,14 @@ type ProjectUpdatedEvent struct { } // Name defines the name for ProjectUpdatedEvent -func (l *ProjectUpdatedEvent) Name() string { +func (p *ProjectUpdatedEvent) Name() string { return "project.updated" } +func (p *ProjectUpdatedEvent) ProjectID() int64 { + return p.Project.ID +} + // ProjectDeletedEvent represents an event where a project has been deleted type ProjectDeletedEvent struct { Project *Project @@ -199,10 +251,14 @@ type ProjectDeletedEvent struct { } // Name defines the name for ProjectDeletedEvent -func (t *ProjectDeletedEvent) Name() string { +func (p *ProjectDeletedEvent) Name() string { return "project.deleted" } +func (p *ProjectDeletedEvent) ProjectID() int64 { + return p.Project.ID +} + //////////////////// // Sharing Events // //////////////////// @@ -215,10 +271,14 @@ type ProjectSharedWithUserEvent struct { } // Name defines the name for ProjectSharedWithUserEvent -func (l *ProjectSharedWithUserEvent) Name() string { +func (p *ProjectSharedWithUserEvent) Name() string { return "project.shared.user" } +func (p *ProjectSharedWithUserEvent) ProjectID() int64 { + return p.Project.ID +} + // ProjectSharedWithTeamEvent represents an event where a project has been shared with a team type ProjectSharedWithTeamEvent struct { Project *Project @@ -227,10 +287,14 @@ type ProjectSharedWithTeamEvent struct { } // Name defines the name for ProjectSharedWithTeamEvent -func (l *ProjectSharedWithTeamEvent) Name() string { +func (p *ProjectSharedWithTeamEvent) Name() string { return "project.shared.team" } +func (p *ProjectSharedWithTeamEvent) ProjectID() int64 { + return p.Project.ID +} + ///////////////// // Team Events // ///////////////// diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index fcc23179ee1..f0d41f703f1 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -65,6 +65,22 @@ func RegisterListeners() { events.RegisterListener((&TaskDeletedEvent{}).Name(), &RemoveTaskFromTypesense{}) events.RegisterListener((&TaskCreatedEvent{}).Name(), &AddTaskToTypesense{}) } + RegisterEventForWebhook(&TaskCreatedEvent{}) + RegisterEventForWebhook(&TaskUpdatedEvent{}) + RegisterEventForWebhook(&TaskDeletedEvent{}) + RegisterEventForWebhook(&TaskAssigneeCreatedEvent{}) + RegisterEventForWebhook(&TaskAssigneeDeletedEvent{}) + RegisterEventForWebhook(&TaskCommentCreatedEvent{}) + RegisterEventForWebhook(&TaskCommentUpdatedEvent{}) + RegisterEventForWebhook(&TaskCommentDeletedEvent{}) + RegisterEventForWebhook(&TaskAttachmentCreatedEvent{}) + RegisterEventForWebhook(&TaskAttachmentDeletedEvent{}) + RegisterEventForWebhook(&TaskRelationCreatedEvent{}) + RegisterEventForWebhook(&TaskRelationDeletedEvent{}) + RegisterEventForWebhook(&ProjectUpdatedEvent{}) + RegisterEventForWebhook(&ProjectDeletedEvent{}) + RegisterEventForWebhook(&ProjectSharedWithUserEvent{}) + RegisterEventForWebhook(&ProjectSharedWithTeamEvent{}) } ////// @@ -609,6 +625,26 @@ func (s *SendProjectCreatedNotification) Handle(msg *message.Message) (err error return nil } +// WebhookListener represents a listener +type WebhookListener struct { +} + +// Name defines the name for the WebhookListener listener +func (s *WebhookListener) Name() string { + return "webhook.listener" +} + +// Handle is executed when the event WebhookListener listens on is fired +func (s *WebhookListener) Handle(msg *message.Message) (err error) { + event := &ProjectUpdatedEvent{} + err = json.Unmarshal(msg.Payload, event) + if err != nil { + return err + } + + return nil +} + /////// // Team Events diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 561b7a3611e..38db349a51c 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -17,8 +17,10 @@ package models import ( + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" + "sync" "time" "xorm.io/xorm" ) @@ -46,6 +48,26 @@ func (w *Webhook) TableName() string { return "webhooks" } +type WebhookEvent interface { + events.Event + ProjectID() int64 +} + +var availableWebhookEvents map[string]bool +var availableWebhookEventsLock *sync.Mutex + +func init() { + availableWebhookEvents = make(map[string]bool) + availableWebhookEventsLock = &sync.Mutex{} +} + +func RegisterEventForWebhook(event WebhookEvent) { + availableWebhookEventsLock.Lock() + defer availableWebhookEventsLock.Unlock() + + availableWebhookEvents[event.Name()] = true +} + func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { // TODO: check valid webhook events w.CreatedByID = a.GetID() From c5de41f183122d106fb56134fddc6f8a6b7fdda4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 13 Sep 2023 21:36:00 +0200 Subject: [PATCH 03/28] feat(webhooks): add event listener to send webhook payload --- pkg/models/listeners.go | 52 +++++++++++++++++++++++++++++++++++++---- pkg/models/webhooks.go | 3 +++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index f0d41f703f1..b443b22a098 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -17,8 +17,11 @@ package models import ( + "bytes" "encoding/json" + "net/http" "strconv" + "time" "code.vikunja.io/api/pkg/config" @@ -627,22 +630,63 @@ func (s *SendProjectCreatedNotification) Handle(msg *message.Message) (err error // WebhookListener represents a listener type WebhookListener struct { + EventName string } // Name defines the name for the WebhookListener listener -func (s *WebhookListener) Name() string { +func (wl *WebhookListener) Name() string { return "webhook.listener" } +type WebhookPayload struct { + EventName string `json:"event_name"` + Time time.Time `json:"time"` + Data WebhookEvent `json:"data"` +} + // Handle is executed when the event WebhookListener listens on is fired -func (s *WebhookListener) Handle(msg *message.Message) (err error) { - event := &ProjectUpdatedEvent{} +func (wl *WebhookListener) Handle(msg *message.Message) (err error) { + var event WebhookEvent err = json.Unmarshal(msg.Payload, event) if err != nil { return err } - return nil + s := db.NewSession() + defer s.Close() + + ws := []*Webhook{} + err = s.Where("project_id = ?", event.ProjectID()). + Find(&ws) + if err != nil { + return err + } + + var webhook *Webhook + for _, w := range ws { + for _, e := range w.Events { + if e == wl.EventName { + webhook = w + break + } + } + + } + + if webhook == nil { + return nil + } + + payload, err := json.Marshal(WebhookPayload{ + EventName: wl.EventName, + Time: time.Now(), + Data: event, + }) + if err != nil { + return err + } + _, err = http.NewRequest(http.MethodPost, webhook.TargetURL, bytes.NewReader(payload)) + return } /////// diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 38db349a51c..f3a99e4c7b4 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -66,6 +66,9 @@ func RegisterEventForWebhook(event WebhookEvent) { defer availableWebhookEventsLock.Unlock() availableWebhookEvents[event.Name()] = true + events.RegisterListener(event.Name(), &WebhookListener{ + EventName: event.Name(), + }) } func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { From 7f3c300240f50984acb3496331da8d2e3a3ff278 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 13 Sep 2023 21:38:26 +0200 Subject: [PATCH 04/28] feat(webhooks): add routes --- pkg/routes/routes.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index c3428c47b32..0905d7bde8e 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -574,6 +574,17 @@ func registerAPIRoutes(a *echo.Group) { a.GET("/tokens", apiTokenProvider.ReadAllWeb) a.PUT("/tokens", apiTokenProvider.CreateWeb) a.DELETE("/tokens/:token", apiTokenProvider.DeleteWeb) + + // Webhooks + webhookProvider := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.Webhook{} + }, + } + a.GET("/project/:project/webhooks", webhookProvider.ReadAllWeb) + a.PUT("/project/:project/webhooks", webhookProvider.CreateWeb) + a.DELETE("/project/:project/webhooks/:webhook", webhookProvider.DeleteWeb) + a.POST("/project/:project/webhooks/:webhook", webhookProvider.UpdateWeb) } func registerMigrations(m *echo.Group) { From 7d1c5c50c58dddf9131e8a2ba8f87306c0a858f1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Sep 2023 11:16:07 +0200 Subject: [PATCH 05/28] feat(webhooks): add basic sending of webhooks --- pkg/models/events.go | 106 ++++++++++++++++++++-------------------- pkg/models/listeners.go | 40 ++++++++++++--- 2 files changed, 86 insertions(+), 60 deletions(-) diff --git a/pkg/models/events.go b/pkg/models/events.go index 467f8cf3ff5..b42f6d7ba1c 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -27,8 +27,8 @@ import ( // TaskCreatedEvent represents an event where a task has been created type TaskCreatedEvent struct { - Task *Task - Doer *user.User + Task *Task `json:"task"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskCreatedEvent @@ -42,8 +42,8 @@ func (t *TaskCreatedEvent) ProjectID() int64 { // TaskUpdatedEvent represents an event where a task has been updated type TaskUpdatedEvent struct { - Task *Task - Doer *user.User + Task *Task `json:"task"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskUpdatedEvent @@ -57,8 +57,8 @@ func (t *TaskUpdatedEvent) ProjectID() int64 { // TaskDeletedEvent represents a TaskDeletedEvent event type TaskDeletedEvent struct { - Task *Task - Doer *user.User + Task *Task `json:"task"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskDeletedEvent @@ -72,9 +72,9 @@ func (t *TaskDeletedEvent) ProjectID() int64 { // TaskAssigneeCreatedEvent represents an event where a task has been assigned to a user type TaskAssigneeCreatedEvent struct { - Task *Task - Assignee *user.User - Doer *user.User + Task *Task `json:"task"` + Assignee *user.User `json:"assignee"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskAssigneeCreatedEvent @@ -88,9 +88,9 @@ func (t *TaskAssigneeCreatedEvent) ProjectID() int64 { // TaskAssigneeDeletedEvent represents a TaskAssigneeDeletedEvent event type TaskAssigneeDeletedEvent struct { - Task *Task - Assignee *user.User - Doer *user.User + Task *Task `json:"task"` + Assignee *user.User `json:"assignee"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskAssigneeDeletedEvent @@ -104,9 +104,9 @@ func (t *TaskAssigneeDeletedEvent) ProjectID() int64 { // TaskCommentCreatedEvent represents an event where a task comment has been created type TaskCommentCreatedEvent struct { - Task *Task - Comment *TaskComment - Doer *user.User + Task *Task `json:"task"` + Comment *TaskComment `json:"comment"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskCommentCreatedEvent @@ -120,9 +120,9 @@ func (t *TaskCommentCreatedEvent) ProjectID() int64 { // TaskCommentUpdatedEvent represents a TaskCommentUpdatedEvent event type TaskCommentUpdatedEvent struct { - Task *Task - Comment *TaskComment - Doer *user.User + Task *Task `json:"task"` + Comment *TaskComment `json:"comment"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskCommentUpdatedEvent @@ -136,9 +136,9 @@ func (t *TaskCommentUpdatedEvent) ProjectID() int64 { // TaskCommentDeletedEvent represents a TaskCommentDeletedEvent event type TaskCommentDeletedEvent struct { - Task *Task - Comment *TaskComment - Doer *user.User + Task *Task `json:"task"` + Comment *TaskComment `json:"comment"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskCommentDeletedEvent @@ -152,9 +152,9 @@ func (t *TaskCommentDeletedEvent) ProjectID() int64 { // TaskAttachmentCreatedEvent represents a TaskAttachmentCreatedEvent event type TaskAttachmentCreatedEvent struct { - Task *Task - Attachment *TaskAttachment - Doer *user.User + Task *Task `json:"task"` + Attachment *TaskAttachment `json:"attachment"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskAttachmentCreatedEvent @@ -168,9 +168,9 @@ func (t *TaskAttachmentCreatedEvent) ProjectID() int64 { // TaskAttachmentDeletedEvent represents a TaskAttachmentDeletedEvent event type TaskAttachmentDeletedEvent struct { - Task *Task - Attachment *TaskAttachment - Doer *user.User + Task *Task `json:"task"` + Attachment *TaskAttachment `json:"attachment"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskAttachmentDeletedEvent @@ -184,9 +184,9 @@ func (t *TaskAttachmentDeletedEvent) ProjectID() int64 { // TaskRelationCreatedEvent represents a TaskRelationCreatedEvent event type TaskRelationCreatedEvent struct { - Task *Task - Relation *TaskRelation - Doer *user.User + Task *Task `json:"task"` + Relation *TaskRelation `json:"relation"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskRelationCreatedEvent @@ -200,9 +200,9 @@ func (t *TaskRelationCreatedEvent) ProjectID() int64 { // TaskRelationDeletedEvent represents a TaskRelationDeletedEvent event type TaskRelationDeletedEvent struct { - Task *Task - Relation *TaskRelation - Doer *user.User + Task *Task `json:"task"` + Relation *TaskRelation `json:"relation"` + Doer *user.User `json:"doer"` } // Name defines the name for TaskRelationDeletedEvent @@ -220,8 +220,8 @@ func (t *TaskRelationDeletedEvent) ProjectID() int64 { // ProjectCreatedEvent represents an event where a project has been created type ProjectCreatedEvent struct { - Project *Project - Doer *user.User + Project *Project `json:"project"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectCreatedEvent @@ -231,8 +231,8 @@ func (l *ProjectCreatedEvent) Name() string { // ProjectUpdatedEvent represents an event where a project has been updated type ProjectUpdatedEvent struct { - Project *Project - Doer web.Auth + Project *Project `json:"project"` + Doer web.Auth `json:"doer"` } // Name defines the name for ProjectUpdatedEvent @@ -246,8 +246,8 @@ func (p *ProjectUpdatedEvent) ProjectID() int64 { // ProjectDeletedEvent represents an event where a project has been deleted type ProjectDeletedEvent struct { - Project *Project - Doer web.Auth + Project *Project `json:"project"` + Doer web.Auth `json:"doer"` } // Name defines the name for ProjectDeletedEvent @@ -265,9 +265,9 @@ func (p *ProjectDeletedEvent) ProjectID() int64 { // ProjectSharedWithUserEvent represents an event where a project has been shared with a user type ProjectSharedWithUserEvent struct { - Project *Project - User *user.User - Doer web.Auth + Project *Project `json:"project"` + User *user.User `json:"user"` + Doer web.Auth `json:"doer"` } // Name defines the name for ProjectSharedWithUserEvent @@ -281,9 +281,9 @@ func (p *ProjectSharedWithUserEvent) ProjectID() int64 { // ProjectSharedWithTeamEvent represents an event where a project has been shared with a team type ProjectSharedWithTeamEvent struct { - Project *Project - Team *Team - Doer web.Auth + Project *Project `json:"project"` + Team *Team `json:"team"` + Doer web.Auth `json:"doer"` } // Name defines the name for ProjectSharedWithTeamEvent @@ -301,9 +301,9 @@ func (p *ProjectSharedWithTeamEvent) ProjectID() int64 { // TeamMemberAddedEvent defines an event where a user is added to a team type TeamMemberAddedEvent struct { - Team *Team - Member *user.User - Doer *user.User + Team *Team `json:"team"` + Member *user.User `json:"member"` + Doer *user.User `json:"doer"` } // Name defines the name for TeamMemberAddedEvent @@ -313,8 +313,8 @@ func (t *TeamMemberAddedEvent) Name() string { // TeamCreatedEvent represents a TeamCreatedEvent event type TeamCreatedEvent struct { - Team *Team - Doer web.Auth + Team *Team `json:"team"` + Doer web.Auth `json:"doer"` } // Name defines the name for TeamCreatedEvent @@ -324,8 +324,8 @@ func (t *TeamCreatedEvent) Name() string { // TeamDeletedEvent represents a TeamDeletedEvent event type TeamDeletedEvent struct { - Team *Team - Doer web.Auth + Team *Team `json:"team"` + Doer web.Auth `json:"doer"` } // Name defines the name for TeamDeletedEvent @@ -335,7 +335,7 @@ func (t *TeamDeletedEvent) Name() string { // UserDataExportRequestedEvent represents a UserDataExportRequestedEvent event type UserDataExportRequestedEvent struct { - User *user.User + User *user.User `json:"user"` } // Name defines the name for UserDataExportRequestedEvent diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index b443b22a098..db6aa5a60a1 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -639,24 +639,43 @@ func (wl *WebhookListener) Name() string { } type WebhookPayload struct { - EventName string `json:"event_name"` - Time time.Time `json:"time"` - Data WebhookEvent `json:"data"` + EventName string `json:"event_name"` + Time time.Time `json:"time"` + Data interface{} `json:"data"` +} + +func getProjectIDFromAnyEvent(eventPayload map[string]interface{}) int64 { + if task, has := eventPayload["task"]; has { + t := task.(map[string]interface{}) + if projectID, has := t["project_id"]; has { + switch projectID.(type) { + case int64: + return projectID.(int64) + case float64: + return int64(projectID.(float64)) + } + return projectID.(int64) + } + } + + return 0 } // Handle is executed when the event WebhookListener listens on is fired func (wl *WebhookListener) Handle(msg *message.Message) (err error) { - var event WebhookEvent - err = json.Unmarshal(msg.Payload, event) + var event map[string]interface{} + err = json.Unmarshal(msg.Payload, &event) if err != nil { return err } + projectID := getProjectIDFromAnyEvent(event) + s := db.NewSession() defer s.Close() ws := []*Webhook{} - err = s.Where("project_id = ?", event.ProjectID()). + err = s.Where("project_id = ?", projectID). Find(&ws) if err != nil { return err @@ -685,7 +704,14 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { if err != nil { return err } - _, err = http.NewRequest(http.MethodPost, webhook.TargetURL, bytes.NewReader(payload)) + req, err := http.NewRequest(http.MethodPost, webhook.TargetURL, bytes.NewReader(payload)) + if err != nil { + return err + } + _, err = http.DefaultClient.Do(req) + if err == nil { + log.Debugf("Sent webhook payload for webhook %d for event %s", webhook.ID, wl.EventName) + } return } From 8d7a4929364e17f2eec75f22e9e04b472fe79ab1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Sep 2023 12:12:16 +0200 Subject: [PATCH 06/28] feat(webhooks): add filter based on project id --- pkg/models/listeners.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index db6aa5a60a1..a75153f8155 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -658,6 +658,19 @@ func getProjectIDFromAnyEvent(eventPayload map[string]interface{}) int64 { } } + if project, has := eventPayload["project"]; has { + t := project.(map[string]interface{}) + if projectID, has := t["id"]; has { + switch projectID.(type) { + case int64: + return projectID.(int64) + case float64: + return int64(projectID.(float64)) + } + return projectID.(int64) + } + } + return 0 } @@ -670,6 +683,10 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { } projectID := getProjectIDFromAnyEvent(event) + if projectID == 0 { + log.Debugf("event %s does not contain a project id, not handling webhook", wl.EventName) + return nil + } s := db.NewSession() defer s.Close() @@ -693,6 +710,7 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { } if webhook == nil { + log.Debugf("Did not find any webhook for the %s event for project %d, not sending", wl.EventName, projectID) return nil } From 57de44694c663c50a209effeffd9615be81f7550 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Sep 2023 12:13:06 +0200 Subject: [PATCH 07/28] feat(webhooks): add index on project id --- pkg/migration/20230913202615.go | 2 +- pkg/models/webhooks.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/migration/20230913202615.go b/pkg/migration/20230913202615.go index 1df44f1e91b..f96badbede2 100644 --- a/pkg/migration/20230913202615.go +++ b/pkg/migration/20230913202615.go @@ -26,7 +26,7 @@ type webhooks20230913202615 struct { ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"` TargetURL string `xorm:"not null" valid:"minstringlength(1)" minLength:"1" json:"target_url"` Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"event"` - ProjectID int64 `xorm:"not null" json:"project_id" param:"project"` + ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"` CreatedByID int64 `xorm:"bigint not null" json:"-"` Created time.Time `xorm:"created not null" json:"created"` Updated time.Time `xorm:"updated not null" json:"updated"` diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index f3a99e4c7b4..f81f3962e13 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -29,7 +29,7 @@ type Webhook struct { ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"` TargetURL string `xorm:"not null" valid:"minstringlength(1)" minLength:"1" json:"target_url"` Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"event"` - ProjectID int64 `xorm:"not null" json:"project_id" param:"project"` + ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"` // The user who initially created the webhook target. CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"` From eb1b9247ad5f5c3a2141db7c8d9989f60443abe3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Sep 2023 12:15:37 +0200 Subject: [PATCH 08/28] feat(webhooks): prevent link shares from managing webhooks --- pkg/models/webhooks_rights.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/models/webhooks_rights.go b/pkg/models/webhooks_rights.go index fed8483be20..b5cc88bd3b7 100644 --- a/pkg/models/webhooks_rights.go +++ b/pkg/models/webhooks_rights.go @@ -27,16 +27,23 @@ func (w *Webhook) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { } func (w *Webhook) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { - p := &Project{ID: w.ProjectID} - return p.CanUpdate(s, a) + return w.canDoWebhook(s, a) } func (w *Webhook) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { - p := &Project{ID: w.ProjectID} - return p.CanUpdate(s, a) + return w.canDoWebhook(s, a) } func (w *Webhook) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + return w.canDoWebhook(s, a) +} + +func (w *Webhook) canDoWebhook(s *xorm.Session, a web.Auth) (bool, error) { + _, isShareAuth := a.(*LinkSharing) + if isShareAuth { + return false, nil + } + p := &Project{ID: w.ProjectID} return p.CanUpdate(s, a) } From 96ccf6b92395b4d8424ff0fa9b3b829ff5d459ce Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Sep 2023 12:21:20 +0200 Subject: [PATCH 09/28] feat(webhooks): add route to get all available webhook events --- pkg/models/webhooks.go | 12 ++++++++++++ pkg/routes/api/v1/webhooks.go | 27 +++++++++++++++++++++++++++ pkg/routes/routes.go | 1 + 3 files changed, 40 insertions(+) create mode 100644 pkg/routes/api/v1/webhooks.go diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index f81f3962e13..35510150e9b 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -20,6 +20,7 @@ import ( "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" + "sort" "sync" "time" "xorm.io/xorm" @@ -71,6 +72,17 @@ func RegisterEventForWebhook(event WebhookEvent) { }) } +func GetAvailableWebhookEvents() []string { + evts := []string{} + for e := range availableWebhookEvents { + evts = append(evts, e) + } + + sort.Strings(evts) + + return evts +} + func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { // TODO: check valid webhook events w.CreatedByID = a.GetID() diff --git a/pkg/routes/api/v1/webhooks.go b/pkg/routes/api/v1/webhooks.go new file mode 100644 index 00000000000..1f418e3c40d --- /dev/null +++ b/pkg/routes/api/v1/webhooks.go @@ -0,0 +1,27 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package v1 + +import ( + "code.vikunja.io/api/pkg/models" + "github.com/labstack/echo/v4" + "net/http" +) + +func GetAvailableWebhookEvents(c echo.Context) error { + return c.JSON(http.StatusOK, models.GetAvailableWebhookEvents()) +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 0905d7bde8e..4efaaecc4b7 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -585,6 +585,7 @@ func registerAPIRoutes(a *echo.Group) { a.PUT("/project/:project/webhooks", webhookProvider.CreateWeb) a.DELETE("/project/:project/webhooks/:webhook", webhookProvider.DeleteWeb) a.POST("/project/:project/webhooks/:webhook", webhookProvider.UpdateWeb) + a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents) } func registerMigrations(m *echo.Group) { From 4253d14367ba855ac874d619f6e58c5744639a2e Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Sep 2023 12:22:57 +0200 Subject: [PATCH 10/28] chore(webhooks): remove WebhookEvent interface --- pkg/models/events.go | 64 ------------------------------------------ pkg/models/webhooks.go | 7 +---- 2 files changed, 1 insertion(+), 70 deletions(-) diff --git a/pkg/models/events.go b/pkg/models/events.go index b42f6d7ba1c..5fd75bf5d11 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -36,10 +36,6 @@ func (t *TaskCreatedEvent) Name() string { return "task.created" } -func (t *TaskCreatedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskUpdatedEvent represents an event where a task has been updated type TaskUpdatedEvent struct { Task *Task `json:"task"` @@ -51,10 +47,6 @@ func (t *TaskUpdatedEvent) Name() string { return "task.updated" } -func (t *TaskUpdatedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskDeletedEvent represents a TaskDeletedEvent event type TaskDeletedEvent struct { Task *Task `json:"task"` @@ -66,10 +58,6 @@ func (t *TaskDeletedEvent) Name() string { return "task.deleted" } -func (t *TaskDeletedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskAssigneeCreatedEvent represents an event where a task has been assigned to a user type TaskAssigneeCreatedEvent struct { Task *Task `json:"task"` @@ -82,10 +70,6 @@ func (t *TaskAssigneeCreatedEvent) Name() string { return "task.assignee.created" } -func (t *TaskAssigneeCreatedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskAssigneeDeletedEvent represents a TaskAssigneeDeletedEvent event type TaskAssigneeDeletedEvent struct { Task *Task `json:"task"` @@ -98,10 +82,6 @@ func (t *TaskAssigneeDeletedEvent) Name() string { return "task.assignee.deleted" } -func (t *TaskAssigneeDeletedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskCommentCreatedEvent represents an event where a task comment has been created type TaskCommentCreatedEvent struct { Task *Task `json:"task"` @@ -114,10 +94,6 @@ func (t *TaskCommentCreatedEvent) Name() string { return "task.comment.created" } -func (t *TaskCommentCreatedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskCommentUpdatedEvent represents a TaskCommentUpdatedEvent event type TaskCommentUpdatedEvent struct { Task *Task `json:"task"` @@ -130,10 +106,6 @@ func (t *TaskCommentUpdatedEvent) Name() string { return "task.comment.edited" } -func (t *TaskCommentUpdatedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskCommentDeletedEvent represents a TaskCommentDeletedEvent event type TaskCommentDeletedEvent struct { Task *Task `json:"task"` @@ -146,10 +118,6 @@ func (t *TaskCommentDeletedEvent) Name() string { return "task.comment.deleted" } -func (t *TaskCommentDeletedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskAttachmentCreatedEvent represents a TaskAttachmentCreatedEvent event type TaskAttachmentCreatedEvent struct { Task *Task `json:"task"` @@ -162,10 +130,6 @@ func (t *TaskAttachmentCreatedEvent) Name() string { return "task.attachment.created" } -func (t *TaskAttachmentCreatedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskAttachmentDeletedEvent represents a TaskAttachmentDeletedEvent event type TaskAttachmentDeletedEvent struct { Task *Task `json:"task"` @@ -178,10 +142,6 @@ func (t *TaskAttachmentDeletedEvent) Name() string { return "task.attachment.deleted" } -func (t *TaskAttachmentDeletedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskRelationCreatedEvent represents a TaskRelationCreatedEvent event type TaskRelationCreatedEvent struct { Task *Task `json:"task"` @@ -194,10 +154,6 @@ func (t *TaskRelationCreatedEvent) Name() string { return "task.relation.created" } -func (t *TaskRelationCreatedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - // TaskRelationDeletedEvent represents a TaskRelationDeletedEvent event type TaskRelationDeletedEvent struct { Task *Task `json:"task"` @@ -210,10 +166,6 @@ func (t *TaskRelationDeletedEvent) Name() string { return "task.relation.deleted" } -func (t *TaskRelationDeletedEvent) ProjectID() int64 { - return t.Task.ProjectID -} - //////////////////// // Project Events // //////////////////// @@ -240,10 +192,6 @@ func (p *ProjectUpdatedEvent) Name() string { return "project.updated" } -func (p *ProjectUpdatedEvent) ProjectID() int64 { - return p.Project.ID -} - // ProjectDeletedEvent represents an event where a project has been deleted type ProjectDeletedEvent struct { Project *Project `json:"project"` @@ -255,10 +203,6 @@ func (p *ProjectDeletedEvent) Name() string { return "project.deleted" } -func (p *ProjectDeletedEvent) ProjectID() int64 { - return p.Project.ID -} - //////////////////// // Sharing Events // //////////////////// @@ -275,10 +219,6 @@ func (p *ProjectSharedWithUserEvent) Name() string { return "project.shared.user" } -func (p *ProjectSharedWithUserEvent) ProjectID() int64 { - return p.Project.ID -} - // ProjectSharedWithTeamEvent represents an event where a project has been shared with a team type ProjectSharedWithTeamEvent struct { Project *Project `json:"project"` @@ -291,10 +231,6 @@ func (p *ProjectSharedWithTeamEvent) Name() string { return "project.shared.team" } -func (p *ProjectSharedWithTeamEvent) ProjectID() int64 { - return p.Project.ID -} - ///////////////// // Team Events // ///////////////// diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 35510150e9b..94e5285545c 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -49,11 +49,6 @@ func (w *Webhook) TableName() string { return "webhooks" } -type WebhookEvent interface { - events.Event - ProjectID() int64 -} - var availableWebhookEvents map[string]bool var availableWebhookEventsLock *sync.Mutex @@ -62,7 +57,7 @@ func init() { availableWebhookEventsLock = &sync.Mutex{} } -func RegisterEventForWebhook(event WebhookEvent) { +func RegisterEventForWebhook(event events.Event) { availableWebhookEventsLock.Lock() defer availableWebhookEventsLock.Unlock() From a3a323cbf1490e1ae755e9d636812604f2b27327 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Sep 2023 12:24:48 +0200 Subject: [PATCH 11/28] feat(webhooks): set user agent header to Vikunja --- pkg/models/listeners.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index a75153f8155..2972914ef95 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -18,6 +18,7 @@ package models import ( "bytes" + "code.vikunja.io/api/pkg/version" "encoding/json" "net/http" "strconv" @@ -726,6 +727,7 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { if err != nil { return err } + req.Header.Add("User-Agent", "Vikunja/"+version.Version) _, err = http.DefaultClient.Do(req) if err == nil { log.Debugf("Sent webhook payload for webhook %d for event %s", webhook.ID, wl.EventName) From a0d8b28813821205d4762181ac473b13863fb079 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 13 Oct 2023 18:10:37 +0200 Subject: [PATCH 12/28] feat(webhooks): add hmac signing --- pkg/migration/20230913202615.go | 1 + pkg/models/listeners.go | 17 +------------- pkg/models/webhooks.go | 41 ++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/pkg/migration/20230913202615.go b/pkg/migration/20230913202615.go index f96badbede2..5eceba38d1f 100644 --- a/pkg/migration/20230913202615.go +++ b/pkg/migration/20230913202615.go @@ -27,6 +27,7 @@ type webhooks20230913202615 struct { TargetURL string `xorm:"not null" valid:"minstringlength(1)" minLength:"1" json:"target_url"` Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"event"` ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"` + Secret string `xorm:"null" json:"secret"` CreatedByID int64 `xorm:"bigint not null" json:"-"` Created time.Time `xorm:"created not null" json:"created"` Updated time.Time `xorm:"updated not null" json:"updated"` diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 2972914ef95..b0f9e665b4e 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -17,10 +17,7 @@ package models import ( - "bytes" - "code.vikunja.io/api/pkg/version" "encoding/json" - "net/http" "strconv" "time" @@ -715,23 +712,11 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { return nil } - payload, err := json.Marshal(WebhookPayload{ + err = webhook.sendWebhookPayload(&WebhookPayload{ EventName: wl.EventName, Time: time.Now(), Data: event, }) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodPost, webhook.TargetURL, bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Add("User-Agent", "Vikunja/"+version.Version) - _, err = http.DefaultClient.Do(req) - if err == nil { - log.Debugf("Sent webhook payload for webhook %d for event %s", webhook.ID, wl.EventName) - } return } diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 94e5285545c..0afd50c6d5f 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -17,9 +17,17 @@ package models import ( + "bytes" "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/version" "code.vikunja.io/web" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" "sort" "sync" "time" @@ -29,8 +37,9 @@ import ( type Webhook struct { ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"` TargetURL string `xorm:"not null" valid:"minstringlength(1)" minLength:"1" json:"target_url"` - Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"event"` + Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"events"` ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"` + Secret string `xorm:"null" json:"secret"` // The user who initially created the webhook target. CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"` @@ -124,3 +133,33 @@ func (w *Webhook) Delete(s *xorm.Session, a web.Auth) (err error) { _, err = s.Where("id = ?", w.ID).Delete(&Webhook{}) return } + +func (w *Webhook) sendWebhookPayload(p *WebhookPayload) (err error) { + payload, err := json.Marshal(p) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, w.TargetURL, bytes.NewReader(payload)) + if err != nil { + return err + } + + if len(w.Secret) > 0 { + sig256 := hmac.New(sha256.New, []byte(w.Secret)) + _, err = sig256.Write(payload) + if err != nil { + log.Errorf("Could not generate webhook signature for Webhook %d: %s", w.ID, err) + } + signature := hex.EncodeToString(sig256.Sum(nil)) + req.Header.Add("X-Vikunja-Signature", signature) + } + + req.Header.Add("User-Agent", "Vikunja/"+version.Version) + + _, err = http.DefaultClient.Do(req) + if err == nil { + log.Debugf("Sent webhook payload for webhook %d for event %s", w.ID, p.EventName) + } + return +} From 8cc775ac4c2f8f0390ee10e10a5515c0d6a9d9ac Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 13 Oct 2023 18:10:59 +0200 Subject: [PATCH 13/28] fix(webhooks): routes should use the common schema used for other routes already --- pkg/routes/routes.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 4efaaecc4b7..b43bc4b67fe 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -581,10 +581,10 @@ func registerAPIRoutes(a *echo.Group) { return &models.Webhook{} }, } - a.GET("/project/:project/webhooks", webhookProvider.ReadAllWeb) - a.PUT("/project/:project/webhooks", webhookProvider.CreateWeb) - a.DELETE("/project/:project/webhooks/:webhook", webhookProvider.DeleteWeb) - a.POST("/project/:project/webhooks/:webhook", webhookProvider.UpdateWeb) + a.GET("/projects/:project/webhooks", webhookProvider.ReadAllWeb) + a.PUT("/projects/:project/webhooks", webhookProvider.CreateWeb) + a.DELETE("/projects/:project/webhooks/:webhook", webhookProvider.DeleteWeb) + a.POST("/projects/:project/webhooks/:webhook", webhookProvider.UpdateWeb) a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents) } From 34a92b759e8cbcc6cf8dcedbe283644aab92077d Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 17 Oct 2023 19:03:11 +0200 Subject: [PATCH 14/28] feat(webhooks): add setting to enable webhooks --- pkg/config/config.go | 5 +++++ pkg/models/webhooks.go | 5 +++++ pkg/routes/routes.go | 20 +++++++++++--------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 57f7bef8f76..c1fbaa74eac 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -172,6 +172,9 @@ const ( DefaultSettingsLanguage Key = `defaultsettings.language` DefaultSettingsTimezone Key = `defaultsettings.timezone` DefaultSettingsOverdueTaskRemindersTime Key = `defaultsettings.overdue_tasks_reminders_time` + + WebhooksEnabled Key = `webhooks.enabled` + WebhooksTimeoutSeconds Key = `webhooks.timeoutseconds` ) // GetString returns a string config value @@ -387,6 +390,8 @@ func InitDefaultConfig() { DefaultSettingsAvatarProvider.setDefault("initials") DefaultSettingsOverdueTaskRemindersEnabled.setDefault(true) DefaultSettingsOverdueTaskRemindersTime.setDefault("9:00") + // Webhook + WebhooksEnabled.setDefault(true) } // InitConfig initializes the config, sets defaults etc. diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 0afd50c6d5f..f440d8f2ea3 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -18,6 +18,7 @@ package models import ( "bytes" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/user" @@ -67,6 +68,10 @@ func init() { } func RegisterEventForWebhook(event events.Event) { + if !config.WebhooksEnabled.GetBool() { + return + } + availableWebhookEventsLock.Lock() defer availableWebhookEventsLock.Unlock() diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index b43bc4b67fe..04a98c7386f 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -576,16 +576,18 @@ func registerAPIRoutes(a *echo.Group) { a.DELETE("/tokens/:token", apiTokenProvider.DeleteWeb) // Webhooks - webhookProvider := &handler.WebHandler{ - EmptyStruct: func() handler.CObject { - return &models.Webhook{} - }, + if config.WebhooksEnabled.GetBool() { + webhookProvider := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.Webhook{} + }, + } + a.GET("/projects/:project/webhooks", webhookProvider.ReadAllWeb) + a.PUT("/projects/:project/webhooks", webhookProvider.CreateWeb) + a.DELETE("/projects/:project/webhooks/:webhook", webhookProvider.DeleteWeb) + a.POST("/projects/:project/webhooks/:webhook", webhookProvider.UpdateWeb) + a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents) } - a.GET("/projects/:project/webhooks", webhookProvider.ReadAllWeb) - a.PUT("/projects/:project/webhooks", webhookProvider.CreateWeb) - a.DELETE("/projects/:project/webhooks/:webhook", webhookProvider.DeleteWeb) - a.POST("/projects/:project/webhooks/:webhook", webhookProvider.UpdateWeb) - a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents) } func registerMigrations(m *echo.Group) { From b38360c9a59624866e467de81acf62e38af8db59 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 17 Oct 2023 19:06:25 +0200 Subject: [PATCH 15/28] feat(webhooks): add timeout config option --- pkg/config/config.go | 1 + pkg/models/webhooks.go | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index c1fbaa74eac..0b39a318d09 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -392,6 +392,7 @@ func InitDefaultConfig() { DefaultSettingsOverdueTaskRemindersTime.setDefault("9:00") // Webhook WebhooksEnabled.setDefault(true) + WebhooksTimeoutSeconds.setDefault(30) } // InitConfig initializes the config, sets defaults etc. diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index f440d8f2ea3..4f32a84cafe 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -162,7 +162,10 @@ func (w *Webhook) sendWebhookPayload(p *WebhookPayload) (err error) { req.Header.Add("User-Agent", "Vikunja/"+version.Version) - _, err = http.DefaultClient.Do(req) + client := http.DefaultClient + client.Timeout = time.Duration(config.WebhooksTimeoutSeconds.GetInt()) * time.Second + + _, err = client.Do(req) if err == nil { log.Debugf("Sent webhook payload for webhook %d for event %s", w.ID, p.EventName) } From 831aa4a01449f141a3590adc7e3baa6223aa7935 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 17 Oct 2023 19:42:00 +0200 Subject: [PATCH 16/28] feat(webhooks): add support for webhook proxy --- pkg/config/config.go | 2 ++ pkg/models/webhooks.go | 29 +++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 0b39a318d09..50e93e0aaf0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -175,6 +175,8 @@ const ( WebhooksEnabled Key = `webhooks.enabled` WebhooksTimeoutSeconds Key = `webhooks.timeoutseconds` + WebhooksProxyURL Key = `webhooks.proxyurl` + WebhooksProxyPassword Key = `webhooks.proxypassword` ) // GetString returns a string config value diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 4f32a84cafe..36debf4b3ac 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -26,9 +26,11 @@ import ( "code.vikunja.io/web" "crypto/hmac" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "net/http" + "net/url" "sort" "sync" "time" @@ -139,6 +141,27 @@ func (w *Webhook) Delete(s *xorm.Session, a web.Auth) (err error) { return } +func getWebhookHTTPClient() (client *http.Client) { + client = http.DefaultClient + client.Timeout = time.Duration(config.WebhooksTimeoutSeconds.GetInt()) * time.Second + + if config.WebhooksProxyURL.GetString() == "" || config.WebhooksProxyPassword.GetString() == "" { + return + } + + proxyURL, _ := url.Parse(config.WebhooksProxyURL.GetString()) + + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + ProxyConnectHeader: http.Header{ + "Proxy-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("vikunja:"+config.WebhooksProxyPassword.GetString()))}, + "User-Agent": []string{"Vikunja/" + version.Version}, + }, + } + + return +} + func (w *Webhook) sendWebhookPayload(p *WebhookPayload) (err error) { payload, err := json.Marshal(p) if err != nil { @@ -162,9 +185,11 @@ func (w *Webhook) sendWebhookPayload(p *WebhookPayload) (err error) { req.Header.Add("User-Agent", "Vikunja/"+version.Version) - client := http.DefaultClient - client.Timeout = time.Duration(config.WebhooksTimeoutSeconds.GetInt()) * time.Second + if config.WebhooksProxyURL.GetString() != "" && config.WebhooksProxyPassword.GetString() != "" { + req.Header.Add("Proxy-Authorization", base64.StdEncoding.EncodeToString([]byte(config.WebhooksProxyPassword.GetString()))) + } + client := getWebhookHTTPClient() _, err = client.Do(req) if err == nil { log.Debugf("Sent webhook payload for webhook %d for event %s", w.ID, p.EventName) From c3947e10163b6133f6cdad9ecd05f8ba0b663a40 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 17 Oct 2023 19:47:23 +0200 Subject: [PATCH 17/28] docs(webhooks): add webhook config to sample config --- config.yml.sample | 10 +++++++ docs/content/doc/setup/config.md | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/config.yml.sample b/config.yml.sample index 3a56d948b69..954118d9cd4 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -346,3 +346,13 @@ defaultsettings: language: # The time zone of each individual user. This will affect when users get reminders and overdue task emails. timezone: