mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-03-11 17:48:44 -05:00
feat: add user_id to webhooks and user-directed event infrastructure
Add user_id column to webhooks table (nullable, for user-level webhooks vs project-level). Extend webhook model, permissions, and listener to support user-level webhooks that fire for user-directed events like task reminders and overdue task notifications. Add TasksOverdueEvent for dispatching overdue notifications via webhooks. Update webhook permissions to handle both user-level and project-level ownership. Add webhook test fixture and register webhooks table in test fixture loader.
This commit is contained in:
7
pkg/db/fixtures/webhooks.yml
Normal file
7
pkg/db/fixtures/webhooks.yml
Normal file
@@ -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
|
||||
63
pkg/migration/20260224215050.go
Normal file
63
pkg/migration/20260224215050.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 //
|
||||
////////////////////
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,7 @@ func SetupTests() {
|
||||
"task_positions",
|
||||
"task_buckets",
|
||||
"sessions",
|
||||
"webhooks",
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user