diff --git a/pkg/db/fixtures/webhooks.yml b/pkg/db/fixtures/webhooks.yml new file mode 100644 index 000000000..0655d9288 --- /dev/null +++ b/pkg/db/fixtures/webhooks.yml @@ -0,0 +1,7 @@ +- id: 1 + target_url: "https://example.com/webhook-fixture" + events: '["task.updated"]' + project_id: 1 + created_by_id: 1 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 diff --git a/pkg/migration/20260224215050.go b/pkg/migration/20260224215050.go new file mode 100644 index 000000000..18e7ed6b0 --- /dev/null +++ b/pkg/migration/20260224215050.go @@ -0,0 +1,63 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20260224215050", + Description: "Add user_id to webhooks table and make project_id nullable", + Migrate: func(tx *xorm.Engine) error { + exists, err := columnExists(tx, "webhooks", "user_id") + if err != nil { + return err + } + if !exists { + if _, err = tx.Exec("ALTER TABLE webhooks ADD COLUMN user_id bigint NULL"); err != nil { + return err + } + } + + if _, err = tx.Exec("CREATE INDEX IF NOT EXISTS IDX_webhooks_user_id ON webhooks (user_id)"); err != nil { + return err + } + + // Make project_id nullable so user-level webhooks can have NULL project_id. + // SQLite does not support ALTER COLUMN, but it already allows NULL in bigint columns. + switch config.DatabaseType.GetString() { + case "mysql": + _, err = tx.Exec("ALTER TABLE webhooks MODIFY COLUMN project_id bigint NULL") + case "postgres": + _, err = tx.Exec("ALTER TABLE webhooks ALTER COLUMN project_id DROP NOT NULL") + } + if err != nil { + return err + } + + return nil + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/events.go b/pkg/models/events.go index 593378884..658f8a1f7 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -178,8 +178,10 @@ func (t *TaskPositionsRecalculatedEvent) Name() string { // TaskReminderFiredEvent represents an event where a task reminder has fired type TaskReminderFiredEvent struct { - Task *Task `json:"task"` - Project *Project `json:"project"` + Task *Task `json:"task"` + User *user.User `json:"user"` + Project *Project `json:"project"` + Reminder *TaskReminder `json:"reminder"` } // Name defines the name for TaskReminderFiredEvent @@ -189,8 +191,9 @@ func (t *TaskReminderFiredEvent) Name() string { // TaskOverdueEvent represents an event where a task is overdue type TaskOverdueEvent struct { - Task *Task `json:"task"` - Project *Project `json:"project"` + Task *Task `json:"task"` + User *user.User `json:"user"` + Project *Project `json:"project"` } // Name defines the name for TaskOverdueEvent @@ -198,6 +201,18 @@ func (t *TaskOverdueEvent) Name() string { return "task.overdue" } +// TasksOverdueEvent represents an event where multiple tasks are overdue for a user +type TasksOverdueEvent struct { + Tasks []*Task `json:"tasks"` + User *user.User `json:"user"` + Projects map[int64]*Project `json:"projects"` +} + +// Name defines the name for TasksOverdueEvent +func (t *TasksOverdueEvent) Name() string { + return "tasks.overdue" +} + //////////////////// // Project Events // //////////////////// diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 7459ea1e1..de83c9d58 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -86,8 +86,9 @@ func RegisterListeners() { RegisterEventForWebhook(&ProjectDeletedEvent{}) RegisterEventForWebhook(&ProjectSharedWithUserEvent{}) RegisterEventForWebhook(&ProjectSharedWithTeamEvent{}) - RegisterEventForWebhook(&TaskReminderFiredEvent{}) - RegisterEventForWebhook(&TaskOverdueEvent{}) + RegisterUserDirectedEventForWebhook(&TaskReminderFiredEvent{}) + RegisterUserDirectedEventForWebhook(&TaskOverdueEvent{}) + RegisterUserDirectedEventForWebhook(&TasksOverdueEvent{}) } } @@ -832,6 +833,20 @@ func getProjectIDFromAnyEvent(eventPayload map[string]interface{}) int64 { return 0 } +func getUserIDFromAnyEvent(eventPayload map[string]interface{}) int64 { + if u, has := eventPayload["user"]; has { + userMap, ok := u.(map[string]interface{}) + if !ok { + return 0 + } + if userID, has := userMap["id"]; has { + return getIDAsInt64(userID) + } + } + + return 0 +} + func reloadDoerInEvent(s *xorm.Session, event map[string]interface{}) (doerID int64, err error) { doer, has := event["doer"] if !has || doer == nil { @@ -957,6 +972,33 @@ func reloadAssigneeInEvent(s *xorm.Session, event map[string]interface{}) error return nil } +func reloadUserInEvent(s *xorm.Session, event map[string]interface{}) error { + u, has := event["user"] + if !has || u == nil { + return nil + } + + userMap, ok := u.(map[string]interface{}) + if !ok { + return nil + } + + userID := getIDAsInt64(userMap["id"]) + if userID <= 0 { + return nil + } + + fullUser, err := user.GetUserByID(s, userID) + if err != nil && !user.IsErrUserDoesNotExist(err) { + return err + } + if err == nil { + event["user"] = fullUser + } + + return nil +} + func reloadEventData(s *xorm.Session, event map[string]interface{}, projectID int64) (eventWithData map[string]interface{}, doerID int64, err error) { // Load event data again so that it is always populated in the webhook payload @@ -980,6 +1022,11 @@ func reloadEventData(s *xorm.Session, event map[string]interface{}, projectID in return nil, doerID, err } + err = reloadUserInEvent(s, event) + if err != nil { + return nil, doerID, err + } + return event, doerID, nil } @@ -991,46 +1038,73 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { return err } + s := db.NewSession() + defer s.Close() + projectID := getProjectIDFromAnyEvent(event) - if projectID == 0 { + isUserDirected := IsUserDirectedEvent(wl.EventName) + + // For non-user-directed events, we need a project ID + if projectID == 0 && !isUserDirected { log.Debugf("event %s does not contain a project id, not handling webhook", wl.EventName) return nil } - s := db.NewSession() - defer s.Close() - - parents, err := GetAllParentProjects(s, projectID) - if err != nil { - return err - } - - projectIDs := make([]int64, 0, len(parents)+1) - projectIDs = append(projectIDs, projectID) - - for _, p := range parents { - projectIDs = append(projectIDs, p.ID) - } - - ws := []*Webhook{} - err = s.In("project_id", projectIDs). - Find(&ws) - if err != nil { - return err - } - + // Look up project-level webhooks matchingWebhooks := []*Webhook{} - for _, w := range ws { - for _, e := range w.Events { - if e == wl.EventName { - matchingWebhooks = append(matchingWebhooks, w) - break + if projectID > 0 { + parents, err := GetAllParentProjects(s, projectID) + if err != nil { + return err + } + + projectIDs := make([]int64, 0, len(parents)+1) + projectIDs = append(projectIDs, projectID) + for _, p := range parents { + projectIDs = append(projectIDs, p.ID) + } + + ws := []*Webhook{} + err = s.In("project_id", projectIDs). + Find(&ws) + if err != nil { + return err + } + + for _, w := range ws { + for _, e := range w.Events { + if e == wl.EventName { + matchingWebhooks = append(matchingWebhooks, w) + break + } + } + } + } + + // Look up user-level webhooks for user-directed events + if isUserDirected { + userID := getUserIDFromAnyEvent(event) + if userID > 0 { + userWebhooks := []*Webhook{} + err = s.Where("user_id = ? AND (project_id IS NULL OR project_id = 0)", userID). + Find(&userWebhooks) + if err != nil { + return err + } + + for _, w := range userWebhooks { + for _, e := range w.Events { + if e == wl.EventName { + matchingWebhooks = append(matchingWebhooks, w) + break + } + } } } } if len(matchingWebhooks) == 0 { - log.Debugf("Did not find any webhook for the %s event for project %d, not sending", wl.EventName, projectID) + log.Debugf("Did not find any webhook for the %s event, not sending", wl.EventName) return nil } @@ -1041,8 +1115,7 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { } for _, webhook := range matchingWebhooks { - - if _, has := event["project"]; !has { + if _, has := event["project"]; !has && webhook.ProjectID > 0 { project, err := GetProjectSimpleByID(s, webhook.ProjectID) if err != nil && !IsErrProjectDoesNotExist(err) { log.Errorf("Could not load project for webhook %d: %s", webhook.ID, err) diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 3d2e595bb..2601f5adc 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -56,9 +56,10 @@ func getThreadID(taskID int64) string { // ReminderDueNotification represents a ReminderDueNotification notification type ReminderDueNotification struct { - User *user.User `json:"user,omitempty"` - Task *Task `json:"task"` - Project *Project `json:"project"` + User *user.User `json:"user,omitempty"` + Task *Task `json:"task"` + Project *Project `json:"project"` + TaskReminder *TaskReminder `json:"reminder"` } // ToMail returns the mail notification for ReminderDueNotification diff --git a/pkg/models/setup_tests.go b/pkg/models/setup_tests.go index 60353a495..2c374609d 100644 --- a/pkg/models/setup_tests.go +++ b/pkg/models/setup_tests.go @@ -75,6 +75,7 @@ func SetupTests() { "task_positions", "task_buckets", "sessions", + "webhooks", ) if err != nil { log.Fatal(err) diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index 18494860d..3351c59d6 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -52,7 +52,9 @@ type Webhook struct { // The webhook events which should fire this webhook target Events []string `xorm:"JSON not null" valid:"required" json:"events"` // The project ID of the project this webhook target belongs to - ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"` + ProjectID int64 `xorm:"bigint null index" json:"project_id" param:"project"` + // The user ID if this is a user-level webhook (mutually exclusive with ProjectID) + UserID int64 `xorm:"bigint null index" json:"user_id"` // If provided, webhook requests will be signed using HMAC. Check out the docs about how to use this: https://vikunja.io/docs/webhooks/#signing Secret string `xorm:"null" json:"secret"` // If provided, webhook requests will be sent with a Basic Auth header. @@ -78,10 +80,12 @@ func (w *Webhook) TableName() string { var availableWebhookEvents map[string]bool var availableWebhookEventsLock *sync.Mutex +var userDirectedWebhookEvents map[string]bool func init() { availableWebhookEvents = make(map[string]bool) availableWebhookEventsLock = &sync.Mutex{} + userDirectedWebhookEvents = make(map[string]bool) } func RegisterEventForWebhook(event events.Event) { @@ -105,6 +109,34 @@ func GetAvailableWebhookEvents() []string { return evts } +// RegisterUserDirectedEventForWebhook registers an event as both a webhook event and a user-directed event +func RegisterUserDirectedEventForWebhook(event events.Event) { + RegisterEventForWebhook(event) + availableWebhookEventsLock.Lock() + defer availableWebhookEventsLock.Unlock() + userDirectedWebhookEvents[event.Name()] = true +} + +// IsUserDirectedEvent returns whether an event name is user-directed +func IsUserDirectedEvent(eventName string) bool { + availableWebhookEventsLock.Lock() + defer availableWebhookEventsLock.Unlock() + return userDirectedWebhookEvents[eventName] +} + +// GetUserDirectedWebhookEvents returns a sorted list of user-directed webhook event names +func GetUserDirectedWebhookEvents() []string { + availableWebhookEventsLock.Lock() + defer availableWebhookEventsLock.Unlock() + + evts := []string{} + for e := range userDirectedWebhookEvents { + evts = append(evts, e) + } + sort.Strings(evts) + return evts +} + // Create creates a webhook target // @Summary Create a webhook target // @Description Create a webhook target which receives POST requests about specified events from a project. @@ -120,6 +152,14 @@ func GetAvailableWebhookEvents() []string { // @Router /projects/{id}/webhooks [put] func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { + // Validate that exactly one of ProjectID or UserID is set + if w.ProjectID == 0 && w.UserID == 0 { + return InvalidFieldError([]string{"project_id", "user_id"}) + } + if w.ProjectID != 0 && w.UserID != 0 { + return InvalidFieldError([]string{"project_id", "user_id"}) + } + if !strings.HasPrefix(w.TargetURL, "http") { return InvalidFieldError([]string{"target_url"}) } @@ -128,6 +168,10 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { if _, has := availableWebhookEvents[event]; !has { return InvalidFieldError([]string{"events"}) } + // User-level webhooks can only subscribe to user-directed events + if w.UserID != 0 && !IsUserDirectedEvent(event) { + return InvalidFieldError([]string{"events"}) + } } w.CreatedByID = a.GetID() diff --git a/pkg/models/webhooks_permissions.go b/pkg/models/webhooks_permissions.go index efda80719..1f71dcd03 100644 --- a/pkg/models/webhooks_permissions.go +++ b/pkg/models/webhooks_permissions.go @@ -22,6 +22,12 @@ import ( ) func (w *Webhook) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + // User-level webhook: user owns it + if w.UserID > 0 { + return w.UserID == a.GetID(), int(PermissionRead), nil + } + + // Project-level webhook: delegate to project p := &Project{ID: w.ProjectID} return p.CanRead(s, a) } @@ -44,6 +50,26 @@ func (w *Webhook) canDoWebhook(s *xorm.Session, a web.Auth) (bool, error) { return false, nil } + // Load the webhook from DB to check ownership + if w.ID > 0 { + existing := &Webhook{ID: w.ID} + has, err := s.Get(existing) + if err != nil { + return false, err + } + if !has { + return false, nil + } + w.UserID = existing.UserID + w.ProjectID = existing.ProjectID + } + + // User-level webhook: user owns it or is creating new + if w.UserID > 0 || w.ProjectID == 0 { + return w.UserID == 0 || w.UserID == a.GetID(), nil + } + + // Project-level webhook: delegate to project p := &Project{ID: w.ProjectID} return p.CanUpdate(s, a) }