Files
vikunja/pkg/models/subscription.go
T
kolaente 9077604829 fix(subscriptions): marshal unknown subscription entity type as null
The fallback returned the literal bytes `nil`, which is invalid JSON.
2026-07-29 22:28:31 +02:00

559 lines
17 KiB
Go

// 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 models
import (
"encoding/json"
"strconv"
"time"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web"
"github.com/danielgtaylor/huma/v2"
"xorm.io/xorm"
)
// SubscriptionEntityType represents all entities which can be subscribed to
type SubscriptionEntityType int
const (
SubscriptionEntityUnknown = iota
SubscriptionEntityNamespace // Kept even though not used anymore since we don't want to manually change all ids
SubscriptionEntityProject
SubscriptionEntityTask
)
func (st *SubscriptionEntityType) UnmarshalJSON(bytes []byte) error {
var value string
err := json.Unmarshal(bytes, &value)
if err != nil {
return err
}
switch value {
case "project":
*st = SubscriptionEntityProject
case "task":
*st = SubscriptionEntityTask
default:
return &ErrUnknownSubscriptionEntityType{EntityType: *st}
}
return nil
}
func (st SubscriptionEntityType) MarshalJSON() ([]byte, error) {
switch st {
case SubscriptionEntityProject:
return []byte(`"project"`), nil
case SubscriptionEntityTask:
return []byte(`"task"`), nil
}
return []byte(`null`), nil
}
// Schema lets Huma (/api/v2) reflect this type as a string enum; see the note
// on ProjectViewKind.Schema for why this is needed.
func (*SubscriptionEntityType) Schema(_ huma.Registry) *huma.Schema {
return &huma.Schema{
Type: "string",
Enum: []any{"project", "task"},
}
}
func getEntityTypeFromString(entityType string) SubscriptionEntityType {
switch entityType {
case entityProject:
return SubscriptionEntityProject
case entityTask:
return SubscriptionEntityTask
}
return SubscriptionEntityUnknown
}
func (st SubscriptionEntityType) validate() error {
if st == SubscriptionEntityProject ||
st == SubscriptionEntityTask {
return nil
}
return &ErrUnknownSubscriptionEntityType{EntityType: st}
}
const (
entityProject = `project`
entityTask = `task`
)
// Subscription represents a subscription for an entity
type Subscription struct {
// The numeric ID of the subscription
ID int64 `xorm:"autoincr not null unique pk" json:"id" readOnly:"true" doc:"The numeric id of the subscription."`
EntityType SubscriptionEntityType `xorm:"index not null" json:"entity" readOnly:"true" doc:"The kind of entity this subscription is for. Either project or task; derived server-side from the request path."`
Entity string `xorm:"-" json:"-" param:"entity"`
// The id of the entity to subscribe to.
EntityID int64 `xorm:"bigint index not null" json:"entity_id" param:"entityID" readOnly:"true" doc:"The numeric id of the subscribed entity; taken from the request path."`
// The user who made this subscription
UserID int64 `xorm:"bigint index not null" json:"-"`
// A timestamp when this subscription was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this subscription was created. You cannot change this value."`
web.CRUDable `xorm:"-" json:"-"`
web.Permissions `xorm:"-" json:"-"`
}
type SubscriptionWithUser struct {
Subscription `xorm:"extends"`
User *user.User `xorm:"extends" json:"user"`
}
type subscriptionResolved struct {
OriginalEntityID int64
SubscriptionID int64
SubscriptionWithUser `xorm:"extends"`
}
// TableName gives us a better table name for the subscriptions table
func (sb *Subscription) TableName() string {
return "subscriptions"
}
// Create subscribes the current user to an entity
// @Summary Subscribes the current user to an entity.
// @Description Subscribes the current user to an entity.
// @tags subscriptions
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param entity path string true "The entity the user subscribes to. Can be either `project` or `task`."
// @Param entityID path string true "The numeric id of the entity to subscribe to."
// @Success 201 {object} models.Subscription "The subscription"
// @Failure 403 {object} web.HTTPError "The user does not have access to subscribe to this entity."
// @Failure 412 {object} web.HTTPError "The subscription already exists."
// @Failure 412 {object} web.HTTPError "The subscription entity is invalid."
// @Failure 500 {object} models.Message "Internal error"
// @Router /subscriptions/{entity}/{entityID} [put]
func (sb *Subscription) Create(s *xorm.Session, auth web.Auth) (err error) {
// Permissions method already does the validation of the entity type, so we don't need to do that here
sb.ID = 0
sb.UserID = auth.GetID()
sub, err := GetSubscriptionForUser(s, sb.EntityType, sb.EntityID, auth)
if err != nil {
return err
}
if sub != nil {
return &ErrSubscriptionAlreadyExists{
EntityID: sub.EntityID,
EntityType: sub.EntityType,
UserID: sub.UserID,
}
}
_, err = s.Insert(sb)
return
}
// Delete unsubscribes the current user to an entity
// @Summary Unsubscribe the current user from an entity.
// @Description Unsubscribes the current user to an entity.
// @tags subscriptions
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param entity path string true "The entity the user subscribed to. Can be either `project` or `task`."
// @Param entityID path string true "The numeric id of the subscribed entity to."
// @Success 200 {object} models.Subscription "The subscription"
// @Failure 403 {object} web.HTTPError "The user does not have access to subscribe to this entity."
// @Failure 404 {object} web.HTTPError "The subscription does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /subscriptions/{entity}/{entityID} [delete]
func (sb *Subscription) Delete(s *xorm.Session, auth web.Auth) (err error) {
sb.UserID = auth.GetID()
_, err = s.
Where("entity_id = ? AND entity_type = ? AND user_id = ?", sb.EntityID, sb.EntityType, sb.UserID).
Delete(&Subscription{})
return
}
func GetSubscriptionForUser(s *xorm.Session, entityType SubscriptionEntityType, entityID int64, a web.Auth) (subscription *SubscriptionWithUser, err error) {
u, is := a.(*user.User)
if !is || u == nil {
return
}
subs, err := GetSubscriptionsForEntitiesAndUser(s, entityType, []int64{entityID}, u)
if err != nil || len(subs) == 0 || len(subs[entityID]) == 0 {
return nil, err
}
return subs[entityID][0], nil
}
// GetSubscriptionsForEntities returns a list of subscriptions to for an entity ID
func GetSubscriptionsForEntities(s *xorm.Session, entityType SubscriptionEntityType, entityIDs []int64) (subscriptions map[int64][]*SubscriptionWithUser, err error) {
return getSubscriptionsForEntitiesAndUser(s, entityType, entityIDs, nil, false, false)
}
func GetSubscriptionsForEntitiesAndUser(s *xorm.Session, entityType SubscriptionEntityType, entityIDs []int64, u *user.User) (subscriptions map[int64][]*SubscriptionWithUser, err error) {
return getSubscriptionsForEntitiesAndUser(s, entityType, entityIDs, u, true, false)
}
func GetSubscriptionsForEntity(s *xorm.Session, entityType SubscriptionEntityType, entityID int64) (subscriptions []*SubscriptionWithUser, err error) {
subs, err := GetSubscriptionsForEntities(s, entityType, []int64{entityID})
if err != nil || len(subs[entityID]) == 0 {
return
}
return subs[entityID], nil
}
// GetSubscriptionsForDeletedTask returns the subscribers of an already soft-deleted task.
// The task deleted listener runs after the deleting transaction committed, so every other
// lookup filters the task out and finds nobody. Permissions come from the project instead,
// since the task itself is gone.
func GetSubscriptionsForDeletedTask(s *xorm.Session, task *Task) (subscriptions []*SubscriptionWithUser, err error) {
subs, err := getSubscriptionsForEntitiesAndUser(s, SubscriptionEntityTask, []int64{task.ID}, nil, false, true)
if err != nil {
return nil, err
}
filtered, err := filterSubscriptionsByProjectPermission(s, subs, map[int64]int64{task.ID: task.ProjectID})
if err != nil {
if IsErrProjectDoesNotExist(err) {
return nil, nil
}
return nil, err
}
return filtered[task.ID], nil
}
// This function returns a matching subscription for an entity and user.
// It will return the next parent of a subscription. That means for tasks, it will first look for a subscription for
// that task, if there is none it will look for a subscription on the project the task belongs to.
// It will return a map where the key is the entity id and the value is a slice with all subscriptions for that entity.
func getSubscriptionsForEntitiesAndUser(s *xorm.Session, entityType SubscriptionEntityType, entityIDs []int64, u *user.User, userOnly, includeDeletedTasks bool) (subscriptions map[int64][]*SubscriptionWithUser, err error) {
if err := entityType.validate(); err != nil {
return nil, err
}
rawSubscriptions := []*subscriptionResolved{}
entityIDString := utils.JoinInt64Slice(entityIDs, ", ")
var sUserCond string
if userOnly {
if u == nil {
return nil, &ErrMustProvideUser{}
}
sUserCond = " AND s.user_id = " + strconv.FormatInt(u.ID, 10)
}
tNotDeletedCond := " AND t.deleted_at IS NULL"
if includeDeletedTasks {
tNotDeletedCond = ""
}
switch entityType {
case SubscriptionEntityProject:
err = s.SQL(`
WITH RECURSIVE project_hierarchy AS (
-- Base case: Start with the specified projects
SELECT
id,
parent_project_id,
0 AS level,
id AS original_project_id
FROM projects
WHERE id IN (`+entityIDString+`)
UNION ALL
-- Recursive case: Get parent projects
SELECT
p.id,
p.parent_project_id,
ph.level + 1,
ph.original_project_id
FROM projects p
INNER JOIN project_hierarchy ph ON p.id = ph.parent_project_id
),
subscription_hierarchy AS (
-- Check for project subscriptions (including parent projects)
SELECT
s.id,
s.entity_type,
s.entity_id,
s.created,
s.user_id,
CASE
WHEN s.entity_id = ph.original_project_id THEN 1 -- Direct project match
ELSE ph.level + 1 -- Parent projects
END AS priority,
ph.original_project_id
FROM subscriptions s
INNER JOIN project_hierarchy ph ON s.entity_id = ph.id
WHERE s.entity_type = ?`+sUserCond+`
)
SELECT
p.id AS original_entity_id,
sh.id AS subscription_id,
sh.entity_type,
sh.entity_id,
sh.created,
sh.user_id,
CASE
WHEN sh.priority = 1 THEN 'Direct Project'
ELSE 'Parent Project'
END
AS subscription_level,
users.*
FROM projects p
LEFT JOIN (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY original_project_id, user_id ORDER BY priority) AS rn
FROM subscription_hierarchy
) sh ON p.id = sh.original_project_id AND sh.rn = 1
LEFT JOIN users ON sh.user_id = users.id
WHERE p.id IN (`+entityIDString+`)
ORDER BY p.id, sh.user_id`, SubscriptionEntityProject).
Find(&rawSubscriptions)
case SubscriptionEntityTask:
err = s.SQL(`
WITH RECURSIVE project_hierarchy AS (
-- Base case: Start with the projects associated with the tasks
SELECT
p.id,
p.parent_project_id,
0 AS level,
t.id AS task_id
FROM tasks t
JOIN projects p ON t.project_id = p.id
WHERE t.id IN (`+entityIDString+`)`+tNotDeletedCond+`
UNION ALL
-- Recursive case: Get parent projects
SELECT
p.id,
p.parent_project_id,
ph.level + 1,
ph.task_id
FROM projects p
INNER JOIN project_hierarchy ph ON p.id = ph.parent_project_id
),
subscription_hierarchy AS (
-- Check for task subscriptions
SELECT
s.id,
s.entity_type,
s.entity_id,
s.created,
s.user_id,
1 AS priority,
t.id AS task_id
FROM subscriptions s
JOIN tasks t ON s.entity_id = t.id
WHERE s.entity_type = ? AND t.id IN (`+entityIDString+`)`+tNotDeletedCond+sUserCond+`
UNION ALL
-- Check for project subscriptions (including parent projects)
SELECT
s.id,
s.entity_type,
s.entity_id,
s.created,
s.user_id,
ph.level + 2 AS priority,
ph.task_id
FROM subscriptions s
INNER JOIN project_hierarchy ph ON s.entity_id = ph.id
WHERE s.entity_type = ?`+sUserCond+`
)
SELECT
t.id AS original_entity_id,
sh.id AS subscription_id,
sh.entity_type,
sh.entity_id,
sh.created,
sh.user_id,
CASE
WHEN sh.entity_type = ? THEN 'Task'
WHEN sh.priority = ? THEN 'Direct Project'
ELSE 'Parent Project'
END
AS subscription_level,
users.*
FROM tasks t
LEFT JOIN (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY task_id, user_id ORDER BY priority) AS rn
FROM subscription_hierarchy
) sh ON t.id = sh.task_id AND sh.rn = 1
LEFT JOIN users ON sh.user_id = users.id
WHERE t.id IN (`+entityIDString+`)`+tNotDeletedCond+`
ORDER BY t.id, sh.user_id`,
SubscriptionEntityTask, SubscriptionEntityProject, SubscriptionEntityTask, SubscriptionEntityProject).
Find(&rawSubscriptions)
}
if err != nil {
return nil, err
}
subscriptions = make(map[int64][]*SubscriptionWithUser)
for _, sub := range rawSubscriptions {
if sub.EntityID == 0 {
continue
}
_, has := subscriptions[sub.OriginalEntityID]
if !has {
subscriptions[sub.OriginalEntityID] = []*SubscriptionWithUser{}
}
sub.ID = sub.SubscriptionID
if sub.User != nil {
sub.User.ID = sub.UserID
}
subscriptions[sub.OriginalEntityID] = append(subscriptions[sub.OriginalEntityID], &sub.SubscriptionWithUser)
}
// A soft-deleted task cannot be resolved back to its project here, so that
// caller filters with the project id it already holds.
if userOnly || includeDeletedTasks {
return subscriptions, nil
}
return filterSubscriptionsByReadPermission(s, entityType, subscriptions)
}
type subscribedEntityProject struct {
ID int64
ProjectID int64
}
// Subscription rows outlive access, so subscribers who lost read access to the entity's project are
// filtered out here rather than deleted: a subscription is user intent and resumes once access returns.
func filterSubscriptionsByReadPermission(s *xorm.Session, entityType SubscriptionEntityType, subscriptions map[int64][]*SubscriptionWithUser) (map[int64][]*SubscriptionWithUser, error) {
if len(subscriptions) == 0 {
return subscriptions, nil
}
entityIDs := make([]int64, 0, len(subscriptions))
for entityID := range subscriptions {
entityIDs = append(entityIDs, entityID)
}
projectIDForEntity := make(map[int64]int64, len(subscriptions))
switch entityType {
case SubscriptionEntityProject:
for _, entityID := range entityIDs {
projectIDForEntity[entityID] = entityID
}
case SubscriptionEntityTask:
tasks := []*subscribedEntityProject{}
err := s.Table("tasks").
In("id", entityIDs).
Cols("id", "project_id").
Find(&tasks)
if err != nil {
return nil, err
}
for _, t := range tasks {
projectIDForEntity[t.ID] = t.ProjectID
}
}
return filterSubscriptionsByProjectPermission(s, subscriptions, projectIDForEntity)
}
// filterSubscriptionsByProjectPermission drops subscribers who can no longer read
// the project each entity belongs to.
func filterSubscriptionsByProjectPermission(s *xorm.Session, subscriptions map[int64][]*SubscriptionWithUser, projectIDForEntity map[int64]int64) (map[int64][]*SubscriptionWithUser, error) {
subscribers := make(map[int64]*user.User)
projectIDs := make([]int64, 0, len(projectIDForEntity))
seenProjectID := make(map[int64]bool, len(projectIDForEntity))
for entityID, subs := range subscriptions {
projectID, has := projectIDForEntity[entityID]
if !has {
continue
}
for _, sub := range subs {
if sub.User == nil {
continue
}
if _, has := subscribers[sub.User.ID]; !has {
subscribers[sub.User.ID] = sub.User
}
if !seenProjectID[projectID] {
seenProjectID[projectID] = true
projectIDs = append(projectIDs, projectID)
}
}
}
readable := make(map[int64]map[int64]*projectReadPermission, len(subscribers))
for userID, u := range subscribers {
permissions, err := checkReadPermissionsForProjects(s, u, projectIDs)
if err != nil {
return nil, err
}
readable[userID] = permissions
}
filtered := make(map[int64][]*SubscriptionWithUser, len(subscriptions))
for entityID, subs := range subscriptions {
projectID, has := projectIDForEntity[entityID]
if !has {
continue
}
for _, sub := range subs {
if sub.User == nil {
continue
}
permission, has := readable[sub.User.ID][projectID]
if !has || !permission.canRead {
continue
}
filtered[entityID] = append(filtered[entityID], sub)
}
}
return filtered, nil
}