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:
kolaente
2026-03-08 19:25:30 +01:00
parent aacf650ec2
commit d4577c660f
8 changed files with 271 additions and 41 deletions

View 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

View 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
},
})
}

View File

@@ -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 //
////////////////////

View File

@@ -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)

View File

@@ -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

View File

@@ -75,6 +75,7 @@ func SetupTests() {
"task_positions",
"task_buckets",
"sessions",
"webhooks",
)
if err != nil {
log.Fatal(err)

View File

@@ -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()

View File

@@ -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)
}