diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index d484bb314..42aa602df 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -99,7 +99,7 @@ type Task struct { // The task identifier, based on the project identifier and the task's index Identifier string `xorm:"-" json:"identifier"` // The task index, calculated per project - Index int64 `xorm:"bigint not null default 0" json:"index"` + Index int64 `xorm:"bigint not null default 0" json:"index" param:"index"` // The UID is currently not used for anything other than CalDAV, which is why we don't expose it over json UID string `xorm:"varchar(250) null" json:"-"` @@ -353,6 +353,41 @@ func GetTaskByIDSimple(s *xorm.Session, taskID int64) (task Task, err error) { return GetTaskSimple(s, &Task{ID: taskID}) } +// GetTaskByProjectAndIndex returns a task by its per-project index. +// Returns ErrTaskDoesNotExist if nothing matches. +func GetTaskByProjectAndIndex(s *xorm.Session, projectID, index int64) (task Task, err error) { + if projectID < 1 || index < 1 { + return Task{}, ErrTaskDoesNotExist{} + } + + has, err := s. + Where("project_id = ? AND `index` = ?", projectID, index). + Get(&task) + if err != nil { + return Task{}, err + } + if !has { + return Task{}, ErrTaskDoesNotExist{} + } + + return task, nil +} + +// resolveIDFromProjectAndIndex populates t.ID from (ProjectID, Index) for the +// by-index route, which binds project+index from the URL but not id. No-op +// when id is already set. +func (t *Task) resolveIDFromProjectAndIndex(s *xorm.Session) error { + if t.ID != 0 || t.ProjectID < 1 || t.Index < 1 { + return nil + } + resolved, err := GetTaskByProjectAndIndex(s, t.ProjectID, t.Index) + if err != nil { + return err + } + t.ID = resolved.ID + return nil +} + // GetTaskSimple returns a raw task without extra data func GetTaskSimple(s *xorm.Session, t *Task) (task Task, err error) { task = *t @@ -1933,6 +1968,9 @@ func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) { t.Expand = append(t.Expand, t.ExpandArr...) expand := t.Expand + if err = t.resolveIDFromProjectAndIndex(s); err != nil { + return + } *t, err = GetTaskByIDSimple(s, t.ID) if err != nil { return diff --git a/pkg/models/tasks_permissions.go b/pkg/models/tasks_permissions.go index bd3ef778f..71459b13f 100644 --- a/pkg/models/tasks_permissions.go +++ b/pkg/models/tasks_permissions.go @@ -42,7 +42,9 @@ func (t *Task) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { func (t *Task) CanRead(s *xorm.Session, a web.Auth) (canRead bool, maxPermission int, err error) { t.Expand = append(t.Expand, t.ExpandArr...) expand := t.Expand - // Get the task, error out if it doesn't exist + if err = t.resolveIDFromProjectAndIndex(s); err != nil { + return + } *t, err = GetTaskByIDSimple(s, t.ID) if err != nil { return diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 536e3a173..375377b04 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -1303,3 +1303,63 @@ func TestGetTasksByUIDs(t *testing.T) { assert.Equal(t, int64(1), tasks[0].ID, "only user 1's task should be returned") }) } + +func TestGetTaskByProjectAndIndex(t *testing.T) { + t.Run("existing task", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + task, err := GetTaskByProjectAndIndex(s, 1, 1) + require.NoError(t, err) + assert.Equal(t, int64(1), task.ID) + assert.Equal(t, "task #1", task.Title) + }) + + t.Run("nonexistent index", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := GetTaskByProjectAndIndex(s, 1, 99999) + require.Error(t, err) + assert.True(t, IsErrTaskDoesNotExist(err)) + }) + + t.Run("wrong project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 4 has no tasks at all. + _, err := GetTaskByProjectAndIndex(s, 4, 1) + require.Error(t, err) + assert.True(t, IsErrTaskDoesNotExist(err)) + }) + + t.Run("index exists only in another project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 2 has indexes 1 and 2; index 5 lives under project 1 (task 5). + // A non-scoped WHERE clause would leak task 5 here. + _, err := GetTaskByProjectAndIndex(s, 2, 5) + require.Error(t, err) + assert.True(t, IsErrTaskDoesNotExist(err)) + }) + + t.Run("invalid input", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := GetTaskByProjectAndIndex(s, 0, 1) + require.Error(t, err) + assert.True(t, IsErrTaskDoesNotExist(err)) + + _, err = GetTaskByProjectAndIndex(s, 1, 0) + require.Error(t, err) + assert.True(t, IsErrTaskDoesNotExist(err)) + }) +}