Item
after`
+
+ oldTask := &Task{
+ Done: false,
+ RepeatAfter: 0,
+ RepeatMode: TaskRepeatModeDefault,
+ DueDate: time.Unix(1550000000, 0),
+ }
+ newTask := &Task{
+ Done: true,
+ Description: checked,
+ }
+
+ updateDone(oldTask, newTask)
+
+ assert.True(t, newTask.Done)
+ assert.Equal(t, checked, newTask.Description)
+ })
})
}
diff --git a/pkg/models/team_members.go b/pkg/models/team_members.go
index ac2654214..b31929277 100644
--- a/pkg/models/team_members.go
+++ b/pkg/models/team_members.go
@@ -69,11 +69,10 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
- doer, _ := user2.GetFromAuth(a)
events.DispatchOnCommit(s, &TeamMemberAddedEvent{
Team: team,
Member: member,
- Doer: doer,
+ Doer: doerFromAuth(s, a),
})
return nil
}
diff --git a/pkg/models/teams.go b/pkg/models/teams.go
index 98c87161c..ab0a80846 100644
--- a/pkg/models/teams.go
+++ b/pkg/models/teams.go
@@ -222,7 +222,7 @@ func (t *Team) CreateNewTeam(s *xorm.Session, a web.Auth, firstUserShouldBeAdmin
events.DispatchOnCommit(s, &TeamCreatedEvent{
Team: t,
- Doer: a,
+ Doer: doer,
})
return nil
}
@@ -362,7 +362,7 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &TeamDeletedEvent{
Team: t,
- Doer: a,
+ Doer: doerFromAuth(s, a),
})
return nil
}
diff --git a/pkg/models/time_tracking.go b/pkg/models/time_tracking.go
new file mode 100644
index 000000000..fc9ee3251
--- /dev/null
+++ b/pkg/models/time_tracking.go
@@ -0,0 +1,441 @@
+// 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 models
+
+import (
+ "time"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web"
+ "xorm.io/builder"
+ "xorm.io/xorm"
+)
+
+// TimeEntry is a single tracked time span attached to either a task or a
+// project — exactly one of TaskID / ProjectID is set (XOR). A running live
+// timer is just an entry whose EndTime is still null.
+//
+// v2-only: doc: tags are the schema's source of truth (no v1 swaggo), and it
+// implements CRUDable + Permissions because the shared handler.Do* pipeline needs them.
+type TimeEntry struct {
+ ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"timeentry" readOnly:"true" doc:"The unique, numeric id of this time entry."`
+
+ UserID int64 `xorm:"bigint not null INDEX" json:"user_id" readOnly:"true" doc:"The id of the user who logged this time entry. Set by the server."`
+
+ TaskID int64 `xorm:"bigint null INDEX" json:"task_id" doc:"The task this entry is attached to. Exactly one of task_id / project_id must be set."`
+ ProjectID int64 `xorm:"bigint null INDEX" json:"project_id" doc:"The project this entry is attached to directly. Exactly one of task_id / project_id must be set."`
+
+ StartTime time.Time `xorm:"not null INDEX" json:"start_time" doc:"When the tracked time started."`
+ EndTime *time.Time `xorm:"null" json:"end_time" doc:"When the tracked time ended. Null means a live timer is still running."`
+
+ Comment string `xorm:"text null" json:"comment" doc:"An optional comment describing the logged time."`
+
+ Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this time entry was created. You cannot change this value."`
+ Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this time entry was last updated. You cannot change this value."`
+
+ // Filter-only fields (not persisted): set by the v2 list route, read by ReadAll.
+ Filter string `xorm:"-" json:"-"`
+ FilterTimezone string `xorm:"-" json:"-"`
+
+ web.CRUDable `xorm:"-" json:"-"`
+ web.Permissions `xorm:"-" json:"-"`
+}
+
+// TableName is time_entries, not the xorm-default time_entry.
+func (*TimeEntry) TableName() string {
+ return "time_entries"
+}
+
+// --- CRUDable ---
+
+func (te *TimeEntry) Create(s *xorm.Session, a web.Auth) (err error) {
+ te.UserID = a.GetID()
+
+ // Starting a new running timer auto-stops the previous one; a completed
+ // manual entry (EndTime set) must leave the running timer alone.
+ if te.EndTime == nil {
+ if _, err = stopRunningTimerForUser(s, te.UserID); err != nil {
+ return err
+ }
+ }
+
+ if te.StartTime.IsZero() {
+ te.StartTime = time.Now()
+ }
+
+ if err = te.validateTimes(); err != nil {
+ return err
+ }
+
+ if _, err = s.Insert(te); err != nil {
+ return err
+ }
+
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return err
+ }
+ events.DispatchOnCommit(s, &TimeEntryCreatedEvent{TimeEntry: te, Doer: doer})
+ return nil
+}
+
+func (te *TimeEntry) ReadOne(_ *xorm.Session, _ web.Auth) (err error) {
+ // entry got already fetched in CanRead, nothing left to do here
+ return nil
+}
+
+// stopRunningTimerForUser stops the user's active timer (end_time = now) and
+// returns it, or nil if no timer is running.
+func stopRunningTimerForUser(s *xorm.Session, userID int64) (*TimeEntry, error) {
+ running := &TimeEntry{}
+ exists, err := s.Where("user_id = ? AND end_time IS NULL", userID).Get(running)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ return nil, nil
+ }
+ if err := running.stop(s); err != nil {
+ return nil, err
+ }
+
+ doer, err := user.GetUserByID(s, userID)
+ if err != nil {
+ return nil, err
+ }
+ events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: running, Doer: doer})
+ return running, nil
+}
+
+// StopRunningTimer stops the authenticated user's active timer and returns it,
+// or ErrNoRunningTimer when none is running. The stop time is the server's now.
+func StopRunningTimer(s *xorm.Session, a web.Auth) (*TimeEntry, error) {
+ // Link shares have no time tracking (mirrors the Can* methods). Their id is a
+ // share id, not a user id, so without this a share whose id collides with a
+ // user's would stop and read that user's running timer.
+ if _, isShare := a.(*LinkSharing); isShare {
+ return nil, ErrGenericForbidden{}
+ }
+
+ running, err := stopRunningTimerForUser(s, a.GetID())
+ if err != nil {
+ return nil, err
+ }
+ if running == nil {
+ return nil, ErrNoRunningTimer{UserID: a.GetID()}
+ }
+ return running, nil
+}
+
+// readableTimeEntriesCond restricts a query to entries the auth can read: a
+// standalone entry on an accessible project, or one on a task in such a project.
+func readableTimeEntriesCond(a web.Auth) builder.Cond {
+ return entriesForProjectCond(accessibleProjectIDsSubquery(a, "project_id"))
+}
+
+func (te *TimeEntry) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result any, resultCount int, numberOfTotalItems int64, err error) {
+ // Link shares have no time-tracking access (mirrors the Can* methods);
+ // DoReadAll skips the permission check, so it must be guarded here too.
+ if _, isShareAuth := a.(*LinkSharing); isShareAuth {
+ return []*TimeEntry{}, 0, 0, nil
+ }
+
+ cond := readableTimeEntriesCond(a)
+ if te.TaskID > 0 {
+ cond = cond.And(builder.Eq{"task_id": te.TaskID})
+ }
+ if te.ProjectID > 0 {
+ cond = cond.And(entriesForProjectCond(builder.Eq{"project_id": te.ProjectID}))
+ }
+
+ filterCond, err := timeEntryFilterCond(te.Filter, te.FilterTimezone)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ if filterCond != nil {
+ cond = cond.And(filterCond)
+ }
+
+ if search != "" {
+ cond = cond.And(db.MultiFieldSearch([]string{"comment"}, search))
+ }
+
+ total, err := s.Where(cond).
+ Count(&TimeEntry{})
+ if err != nil {
+ return nil, 0, 0, err
+ }
+
+ entries := []*TimeEntry{}
+ err = s.Where(cond).
+ OrderBy("start_time ASC").
+ Limit(getLimitFromPageIndex(page, perPage)).
+ Find(&entries)
+ return entries, len(entries), total, err
+}
+
+func (te *TimeEntry) Update(s *xorm.Session, a web.Auth) (err error) {
+ // A completed entry can't be reopened into a running timer via update — that
+ // would sidestep Create's single-active-timer rule; start a new one instead.
+ existing, err := getTimeEntryByID(s, te.ID)
+ if err != nil {
+ return err
+ }
+ if existing.EndTime != nil && te.EndTime == nil {
+ return ErrTimeEntryAlreadyEnded{TimeEntryID: te.ID}
+ }
+
+ if err = te.validateTimes(); err != nil {
+ return err
+ }
+
+ // task_id / project_id are listed so a reassignment (and the zero value of
+ // the side being cleared) is written; the XOR was validated in CanUpdate.
+ _, err = s.
+ Where("id = ?", te.ID).
+ Cols("task_id", "project_id", "start_time", "end_time", "comment").
+ Update(te)
+ if err != nil {
+ return err
+ }
+
+ // reload: Update wrote only the editable columns
+ updated, err := getTimeEntryByID(s, te.ID)
+ if err != nil {
+ return err
+ }
+ *te = *updated
+
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return err
+ }
+ events.DispatchOnCommit(s, &TimeEntryUpdatedEvent{TimeEntry: te, Doer: doer})
+ return nil
+}
+
+func (te *TimeEntry) Delete(s *xorm.Session, a web.Auth) (err error) {
+ entry, err := getTimeEntryByID(s, te.ID)
+ if err != nil {
+ return err
+ }
+ if _, err = s.Where("id = ?", te.ID).Delete(&TimeEntry{}); err != nil {
+ return err
+ }
+
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return err
+ }
+ events.DispatchOnCommit(s, &TimeEntryDeletedEvent{TimeEntry: entry, Doer: doer})
+ return nil
+}
+
+func getTimeEntryByID(s *xorm.Session, id int64) (*TimeEntry, error) {
+ entry := &TimeEntry{}
+ exists, err := s.Where("id = ?", id).Get(entry)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ return nil, ErrTimeEntryDoesNotExist{TimeEntryID: id}
+ }
+ return entry, nil
+}
+
+func (te *TimeEntry) stop(s *xorm.Session) (err error) {
+ now := time.Now()
+ te.EndTime = &now
+ _, err = s.ID(te.ID).Update(te)
+ return err
+}
+
+// --- Permissions ---
+
+// Returns the loaded entry rather than mutating te, so Update keeps its payload.
+func (te *TimeEntry) canDoTimeEntry(s *xorm.Session, a web.Auth, fetch bool) (*TimeEntry, bool, int, error) {
+ entry := &TimeEntry{TaskID: te.TaskID, ProjectID: te.ProjectID}
+ if fetch {
+ var err error
+ entry, err = getTimeEntryByID(s, te.ID)
+ if err != nil {
+ return nil, false, -1, err
+ }
+ }
+
+ switch {
+ case entry.TaskID != 0:
+ task, err := GetTaskByIDSimple(s, entry.TaskID)
+ if err != nil {
+ return entry, false, -1, err
+ }
+ can, maxPerm, err := task.CanRead(s, a)
+ return entry, can, maxPerm, err
+ case entry.ProjectID != 0:
+ project, _, err := getProjectSimple(s, builder.Eq{"id": entry.ProjectID})
+ if err != nil {
+ return entry, false, -1, err
+ }
+ can, maxPerm, err := project.CanRead(s, a)
+ return entry, can, maxPerm, err
+ default:
+ return entry, false, 0, nil
+ }
+}
+
+func (te *TimeEntry) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
+ if _, isShareAuth := a.(*LinkSharing); isShareAuth {
+ return false, 0, nil
+ }
+
+ entry, can, maxPerm, err := te.canDoTimeEntry(s, a, true)
+ if err != nil {
+ return false, maxPerm, err
+ }
+ *te = *entry // ReadOne is a no-op; populate te here
+ return can, maxPerm, nil
+}
+
+// validateContainer enforces the XOR invariant: exactly one of task or project.
+func (te *TimeEntry) validateContainer() error {
+ if (te.TaskID == 0) == (te.ProjectID == 0) {
+ return ErrTimeEntryInvalidContainer{TaskID: te.TaskID, ProjectID: te.ProjectID}
+ }
+ return nil
+}
+
+// validateTimes rejects a completed entry whose end precedes its start (a
+// negative interval). A null end is a running timer and is always valid; an end
+// equal to the start is allowed (a zero-length entry).
+func (te *TimeEntry) validateTimes() error {
+ if te.EndTime != nil && te.EndTime.Before(te.StartTime) {
+ return ErrTimeEntryEndBeforeStart{TimeEntryID: te.ID}
+ }
+ return nil
+}
+
+func (te *TimeEntry) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
+ if _, isShareAuth := a.(*LinkSharing); isShareAuth {
+ return false, nil
+ }
+
+ if err := te.validateContainer(); err != nil {
+ return false, err
+ }
+
+ _, can, _, err := te.canDoTimeEntry(s, a, false)
+ return can, err
+}
+
+// CanUpdate allows the author to edit their entry, including moving it between
+// task / project: on top of the author check it validates the (possibly new)
+// container (XOR) and requires read access to it, mirroring create.
+func (te *TimeEntry) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
+ if _, isShareAuth := a.(*LinkSharing); isShareAuth {
+ return false, nil
+ }
+
+ existing, err := getTimeEntryByID(s, te.ID)
+ if err != nil {
+ return false, err
+ }
+ if existing.UserID != a.GetID() {
+ return false, nil
+ }
+
+ // A request that omits the container keeps the existing one — an entry
+ // always has exactly one, so "clearing" it is never valid.
+ if te.TaskID == 0 && te.ProjectID == 0 {
+ te.TaskID = existing.TaskID
+ te.ProjectID = existing.ProjectID
+ }
+ if err := te.validateContainer(); err != nil {
+ return false, err
+ }
+
+ _, canReadContainer, _, err := te.canDoTimeEntry(s, a, false)
+ return canReadContainer, err
+}
+
+func (te *TimeEntry) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
+ return te.canModify(s, a)
+}
+
+// canModify gates delete: read access to the container plus being the author.
+func (te *TimeEntry) canModify(s *xorm.Session, a web.Auth) (bool, error) {
+ if _, isShareAuth := a.(*LinkSharing); isShareAuth {
+ return false, nil
+ }
+
+ entry, canRead, _, err := te.canDoTimeEntry(s, a, true)
+ if err != nil {
+ return false, err
+ }
+ if !canRead {
+ return false, nil
+ }
+ return entry.UserID == a.GetID(), nil
+}
+
+// addTimeEntriesCountToTasks attaches each task's time-entry count for the
+// `time_entries_count` expand. Mirrors addCommentCountToTasks, but follows the
+// same gates as the time-entry endpoints: the count is left unset (absent) for
+// link shares or when the feature is unlicensed, so it can't leak that way.
+func addTimeEntriesCountToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map[int64]*Task) error {
+ if _, isShare := a.(*LinkSharing); isShare {
+ return nil
+ }
+ if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
+ return nil
+ }
+ if len(taskIDs) == 0 {
+ return nil
+ }
+
+ zero := int64(0)
+ for _, taskID := range taskIDs {
+ if task, ok := taskMap[taskID]; ok {
+ task.TimeEntriesCount = &zero
+ }
+ }
+
+ type timeEntriesCount struct {
+ TaskID int64 `xorm:"task_id"`
+ Count int64 `xorm:"count"`
+ }
+
+ counts := []timeEntriesCount{}
+ if err := s.
+ Select("task_id, COUNT(*) as count").
+ Where(builder.In("task_id", taskIDs)).
+ GroupBy("task_id").
+ Table("time_entries").
+ Find(&counts); err != nil {
+ return err
+ }
+
+ for _, c := range counts {
+ if task, ok := taskMap[c.TaskID]; ok {
+ task.TimeEntriesCount = &c.Count
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/models/time_tracking_filter.go b/pkg/models/time_tracking_filter.go
new file mode 100644
index 000000000..ec4c7b68e
--- /dev/null
+++ b/pkg/models/time_tracking_filter.go
@@ -0,0 +1,204 @@
+// 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 models
+
+import (
+ "strconv"
+ "strings"
+ "time"
+
+ "code.vikunja.io/api/pkg/config"
+
+ "github.com/ganigeorgiev/fexpr"
+ "github.com/jszwedko/go-datemath"
+ "xorm.io/builder"
+)
+
+// entriesForProjectCond matches time entries belonging to a project given a
+// predicate over a project_id column: standalone entries whose own project_id
+// matches, plus task-attached entries whose task currently lives in a matching
+// project. Tasks move between projects, so the project is resolved via the task
+// at query time rather than denormalized. Used for both permission scoping and
+// the project_id filter.
+func entriesForProjectCond(projectIDCond builder.Cond) builder.Cond {
+ return builder.Or(
+ projectIDCond,
+ builder.In("task_id",
+ builder.Select("id").From("tasks").Where(projectIDCond),
+ ),
+ )
+}
+
+// timeEntryFilterCond parses a task-style filter string into a condition over
+// the time_entries table, or nil for an empty filter. Filterable fields:
+// user_id, task_id, project_id (ints / in-lists), start_time, end_time (dates,
+// datemath, or the literal null for running timers). comment is deliberately
+// not filterable — text matching belongs to search.
+func timeEntryFilterCond(filter, filterTimezone string) (builder.Cond, error) {
+ if filter == "" {
+ return nil, nil
+ }
+
+ parsed, err := fexpr.Parse(preprocessFilterString(filter))
+ if err != nil {
+ return nil, &ErrInvalidFilterExpression{Expression: filter, ExpressionError: err}
+ }
+
+ loc := config.GetTimeZone()
+ if filterTimezone != "" {
+ loc, err = time.LoadLocation(filterTimezone)
+ if err != nil {
+ return nil, &ErrInvalidTimezone{Name: filterTimezone, LoadError: err}
+ }
+ }
+
+ return buildTimeEntryFilterCond(parsed, loc)
+}
+
+func buildTimeEntryFilterCond(groups []fexpr.ExprGroup, loc *time.Location) (builder.Cond, error) {
+ conds := make([]builder.Cond, 0, len(groups))
+ joins := make([]taskFilterConcatinator, 0, len(groups))
+
+ for _, g := range groups {
+ join := filterConcatAnd
+ if g.Join == fexpr.JoinOr {
+ join = filterConcatOr
+ }
+
+ var (
+ cond builder.Cond
+ err error
+ )
+ switch item := g.Item.(type) {
+ case []fexpr.ExprGroup: // a parenthesized sub-expression
+ cond, err = buildTimeEntryFilterCond(item, loc)
+ case fexpr.Expr:
+ var comparator taskFilterComparator
+ comparator, err = getFilterComparatorFromOp(item.Op)
+ if err == nil {
+ cond, err = resolveTimeEntryFilter(item.Left.Literal, comparator, item.Right.Literal, loc)
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ conds = append(conds, cond)
+ joins = append(joins, join)
+ }
+
+ if len(conds) == 0 {
+ return nil, nil
+ }
+ result := conds[0]
+ for i := 1; i < len(conds); i++ {
+ if joins[i] == filterConcatOr {
+ result = builder.Or(result, conds[i])
+ continue
+ }
+ result = builder.And(result, conds[i])
+ }
+ return result, nil
+}
+
+func resolveTimeEntryFilter(field string, comparator taskFilterComparator, raw string, loc *time.Location) (builder.Cond, error) {
+ switch field {
+ case "user_id", "task_id":
+ value, err := timeEntryIntFilterValue(raw, comparator)
+ if err != nil {
+ return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw}
+ }
+ return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator, isNumeric: true}, false)
+
+ case "project", "project_id":
+ value, err := timeEntryIntFilterValue(raw, comparator)
+ if err != nil {
+ return nil, ErrInvalidTimeEntryFilterValue{Field: "project_id", Value: raw}
+ }
+ // Build membership positively (standalone-in-project OR task-in-project)
+ // and negate the whole set for != / not in. Negating project_id alone would
+ // wrongly match task-attached entries, whose own project_id is 0.
+ positive, negate := comparator, false
+ if comparator == taskFilterComparatorNotEquals {
+ positive, negate = taskFilterComparatorEquals, true
+ }
+ if comparator == taskFilterComparatorNotIn {
+ positive, negate = taskFilterComparatorIn, true
+ }
+ inner, err := getFilterCond(&taskFilter{field: "project_id", value: value, comparator: positive, isNumeric: true}, false)
+ if err != nil {
+ return nil, err
+ }
+ cond := entriesForProjectCond(inner)
+ if negate {
+ cond = builder.Not{cond}
+ }
+ return cond, nil
+
+ case "start_time", "end_time":
+ if raw == "null" {
+ return nullTimeFilterCond(field, comparator)
+ }
+ value, err := timeEntryTimeFilterValue(raw, loc)
+ if err != nil {
+ return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw}
+ }
+ return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator}, false)
+
+ default:
+ return nil, ErrInvalidTimeEntryFilterField{Field: field}
+ }
+}
+
+// nullTimeFilterCond handles `end_time = null` (running timers) and its negation.
+func nullTimeFilterCond(field string, comparator taskFilterComparator) (builder.Cond, error) {
+ if comparator == taskFilterComparatorEquals {
+ return &builder.IsNull{field}, nil
+ }
+ if comparator == taskFilterComparatorNotEquals {
+ return &builder.NotNull{field}, nil
+ }
+ return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: "null"}
+}
+
+func timeEntryIntFilterValue(raw string, comparator taskFilterComparator) (any, error) {
+ if comparator == taskFilterComparatorIn || comparator == taskFilterComparatorNotIn {
+ parts := strings.Split(raw, ",")
+ values := make([]int64, 0, len(parts))
+ for _, part := range parts {
+ v, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ values = append(values, v)
+ }
+ return values, nil
+ }
+ return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+}
+
+// timeEntryTimeFilterValue mirrors the task filter's date handling: datemath
+// (now, now-7d) first, then explicit date formats.
+func timeEntryTimeFilterValue(raw string, loc *time.Location) (time.Time, error) {
+ if loc == nil {
+ loc = config.GetTimeZone()
+ }
+ if expr, err := safeDatemathParse(raw); err == nil {
+ t := expr.Time(datemath.WithLocation(loc)).In(config.GetTimeZone())
+ return adjustDateForMysql(t), nil
+ }
+ return parseTimeFromUserInput(raw, loc)
+}
diff --git a/pkg/models/time_tracking_test.go b/pkg/models/time_tracking_test.go
new file mode 100644
index 000000000..6e5391d51
--- /dev/null
+++ b/pkg/models/time_tracking_test.go
@@ -0,0 +1,730 @@
+// 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 models
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func timePtr(t time.Time) *time.Time { return &t }
+
+// Fixture access graph (pkg/db/fixtures): project 1 is owned by user1 only
+// (everyone else a stranger); task 1 lives in project 1. Project 3 is owned by
+// user3, with user1 and user2 granted read. user4 has access to neither.
+// Entries: 1 = user1 on task 1, 2 = user1 on project 1, 3 = user3 on project 3.
+
+func TestTimeEntry_CanRead(t *testing.T) {
+ tests := []struct {
+ name string
+ entryID int64
+ auth web.Auth
+ wantCan bool
+ wantErr func(error) bool
+ }{
+ {"owner reads task entry", 1, &user.User{ID: 1}, true, nil},
+ {"owner reads project entry", 2, &user.User{ID: 1}, true, nil},
+ {"reader reads other user's entry on a shared project", 3, &user.User{ID: 1}, true, nil},
+ {"stranger denied on owned project", 1, &user.User{ID: 4}, false, nil},
+ {"stranger denied on shared project", 3, &user.User{ID: 4}, false, nil},
+ {"link share denied", 1, &LinkSharing{ID: 1, ProjectID: 1}, false, nil},
+ {"missing entry is a 404", 999, &user.User{ID: 1}, false, IsErrTimeEntryDoesNotExist},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ can, _, err := (&TimeEntry{ID: tt.entryID}).CanRead(s, tt.auth)
+ if tt.wantErr != nil {
+ require.Error(t, err)
+ assert.True(t, tt.wantErr(err), "unexpected error type: %v", err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantCan, can)
+ })
+ }
+}
+
+func TestTimeEntry_CanCreate(t *testing.T) {
+ tests := []struct {
+ name string
+ entry *TimeEntry
+ auth web.Auth
+ wantCan bool
+ wantErr func(error) bool
+ }{
+ {"on a task in an owned project", &TimeEntry{TaskID: 1}, &user.User{ID: 1}, true, nil},
+ {"on an owned project", &TimeEntry{ProjectID: 1}, &user.User{ID: 1}, true, nil},
+ {"on a readable project", &TimeEntry{ProjectID: 3}, &user.User{ID: 1}, true, nil},
+ {"stranger denied", &TimeEntry{ProjectID: 1}, &user.User{ID: 4}, false, nil},
+ {"both task and project is invalid", &TimeEntry{TaskID: 1, ProjectID: 1}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer},
+ {"neither task nor project is invalid", &TimeEntry{}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer},
+ {"link share denied", &TimeEntry{ProjectID: 1}, &LinkSharing{ID: 1, ProjectID: 1}, false, nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ can, err := tt.entry.CanCreate(s, tt.auth)
+ if tt.wantErr != nil {
+ require.Error(t, err)
+ assert.True(t, tt.wantErr(err), "unexpected error type: %v", err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantCan, can)
+ })
+ }
+}
+
+// Entry 3 is authored by user3; user1 can read project 3 but is not the author,
+// so it can read but not modify.
+func TestTimeEntry_CanModify(t *testing.T) {
+ tests := []struct {
+ name string
+ entryID int64
+ auth web.Auth
+ wantCan bool
+ }{
+ {"author modifies own entry", 1, &user.User{ID: 1}, true},
+ {"author modifies own entry on shared project", 3, &user.User{ID: 3}, true},
+ {"reader who is not author cannot modify", 3, &user.User{ID: 1}, false},
+ {"stranger cannot modify", 3, &user.User{ID: 4}, false},
+ {"link share cannot modify", 1, &LinkSharing{ID: 1, ProjectID: 1}, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ canUpdate, err := (&TimeEntry{ID: tt.entryID}).CanUpdate(s, tt.auth)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantCan, canUpdate, "CanUpdate")
+
+ canDelete, err := (&TimeEntry{ID: tt.entryID}).CanDelete(s, tt.auth)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantCan, canDelete, "CanDelete")
+ })
+ }
+}
+
+// Guards the data leak: ReadAll must return only entries on tasks/projects the
+// caller can read, since DoReadAll runs no permission check.
+func TestTimeEntry_ReadAll(t *testing.T) {
+ tests := []struct {
+ name string
+ auth web.Auth
+ wantIDs []int64
+ }{
+ {"user sees every readable entry", &user.User{ID: 1}, []int64{1, 2, 3, 4}},
+ {"user sees only entries on projects they can read", &user.User{ID: 2}, []int64{3}},
+ {"stranger sees nothing", &user.User{ID: 4}, []int64{}},
+ {"link share sees nothing", &LinkSharing{ID: 1, ProjectID: 1}, []int64{}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ result, count, total, err := (&TimeEntry{}).ReadAll(s, tt.auth, "", 1, 50)
+ require.NoError(t, err)
+ entries, ok := result.([]*TimeEntry)
+ require.True(t, ok)
+
+ gotIDs := make([]int64, 0, len(entries))
+ for _, e := range entries {
+ gotIDs = append(gotIDs, e.ID)
+ }
+ assert.ElementsMatch(t, tt.wantIDs, gotIDs)
+ assert.Equal(t, len(tt.wantIDs), count)
+ assert.Equal(t, int64(len(tt.wantIDs)), total)
+ })
+ }
+}
+
+// Filtering reuses the task filter grammar. user1 can read entries 1,2,4
+// (project 1) and 3 (project 3, shared) — the filter only narrows that set.
+func TestTimeEntry_ReadAll_Filter(t *testing.T) {
+ tests := []struct {
+ name string
+ filter string
+ wantIDs []int64
+ wantErr bool
+ }{
+ {"by user", "user_id = 3", []int64{3}, false},
+ {"by task", "task_id = 1", []int64{1, 4}, false},
+ {"by project unions task-attached entries", "project_id = 1", []int64{1, 2, 4}, false},
+ {"by project negated", "project_id != 1", []int64{3}, false},
+ {"by start time", "start_time > '2018-12-01T11:00:00+00:00'", []int64{2, 3, 4}, false},
+ {"running timers via null end_time", "end_time = null", []int64{4}, false},
+ {"compound and", "user_id = 1 && end_time = null", []int64{4}, false},
+ {"compound or", "user_id = 3 || task_id = 1", []int64{1, 3, 4}, false},
+ {"in list", "user_id in 1,3", []int64{1, 2, 3, 4}, false},
+ {"comment is not filterable", "comment = whatever", nil, true},
+ {"unknown field errors", "bogus = 1", nil, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{Filter: tt.filter}
+ result, _, _, err := te.ReadAll(s, &user.User{ID: 1}, "", 1, 50)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ entries, ok := result.([]*TimeEntry)
+ require.True(t, ok)
+ gotIDs := make([]int64, 0, len(entries))
+ for _, e := range entries {
+ gotIDs = append(gotIDs, e.ID)
+ }
+ assert.ElementsMatch(t, tt.wantIDs, gotIDs)
+ })
+ }
+}
+
+// Search matches the entry comment. Comments: 1="Time entry on task 1",
+// 2/3 contain "Standalone", 4="Running timer".
+func TestTimeEntry_ReadAll_Search(t *testing.T) {
+ tests := []struct {
+ name string
+ search string
+ wantIDs []int64
+ }{
+ {"matches a comment", "Running", []int64{4}},
+ {"is case-insensitive", "running", []int64{4}},
+ {"matches several", "Standalone", []int64{2, 3}},
+ {"no match", "nothing matches this", []int64{}},
+ {"empty search returns all readable", "", []int64{1, 2, 3, 4}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ result, _, _, err := (&TimeEntry{}).ReadAll(s, &user.User{ID: 1}, tt.search, 1, 50)
+ require.NoError(t, err)
+ entries, ok := result.([]*TimeEntry)
+ require.True(t, ok)
+ gotIDs := make([]int64, 0, len(entries))
+ for _, e := range entries {
+ gotIDs = append(gotIDs, e.ID)
+ }
+ assert.ElementsMatch(t, tt.wantIDs, gotIDs)
+ })
+ }
+}
+
+func TestTimeEntry_Create(t *testing.T) {
+ t.Run("manual entry keeps its start time and is owned by the caller", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ start := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
+ end := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)
+ te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: &end, Comment: "work"}
+ require.NoError(t, te.Create(s, &user.User{ID: 1}))
+ require.NoError(t, s.Commit())
+
+ assert.Equal(t, int64(1), te.UserID)
+ assert.True(t, te.StartTime.Equal(start))
+ db.AssertExists(t, "time_entries", map[string]interface{}{
+ "id": te.ID,
+ "user_id": 1,
+ "task_id": 1,
+ "comment": "work",
+ }, false)
+ })
+
+ t.Run("defaults the start time to now when none is given", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{TaskID: 1}
+ require.NoError(t, te.Create(s, &user.User{ID: 1}))
+ assert.False(t, te.StartTime.IsZero())
+ })
+
+ t.Run("a completed manual entry leaves a running timer alone", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // entry 4 is user1's running timer
+ manual := &TimeEntry{
+ TaskID: 1,
+ StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC),
+ EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)),
+ }
+ require.NoError(t, manual.Create(s, &user.User{ID: 1}))
+ require.NoError(t, s.Commit())
+
+ running := &TimeEntry{}
+ exists, err := s.Where("id = ?", 4).Get(running)
+ require.NoError(t, err)
+ require.True(t, exists)
+ assert.Nil(t, running.EndTime, "a manual entry must not stop the running timer")
+ })
+
+ t.Run("auto-stops the caller's running timer", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ a := &user.User{ID: 1}
+
+ first := &TimeEntry{TaskID: 1}
+ require.NoError(t, first.Create(s, a))
+ require.Nil(t, first.EndTime, "first timer should be running")
+
+ second := &TimeEntry{TaskID: 1}
+ require.NoError(t, second.Create(s, a))
+ require.NoError(t, s.Commit())
+
+ reloaded := &TimeEntry{}
+ exists, err := s.Where("id = ?", first.ID).Get(reloaded)
+ require.NoError(t, err)
+ require.True(t, exists)
+ assert.NotNil(t, reloaded.EndTime, "first timer should have been auto-stopped")
+ assert.Nil(t, second.EndTime, "second timer should still be running")
+ })
+}
+
+// A running timer (no end) must round-trip as a NULL end_time: found by the
+// null filter and serialized as JSON null, never the 0001-01-01 zero sentinel.
+func TestTimeEntry_RunningTimerEndTimeIsNull(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ a := &user.User{ID: 1}
+
+ te := &TimeEntry{TaskID: 1, StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)}
+ require.NoError(t, te.Create(s, a))
+ require.NoError(t, s.Commit())
+
+ reloaded, err := getTimeEntryByID(s, te.ID)
+ require.NoError(t, err)
+
+ marshalled, err := json.Marshal(reloaded)
+ require.NoError(t, err)
+ assert.Contains(t, string(marshalled), `"end_time":null`)
+ assert.NotContains(t, string(marshalled), "0001-01-01")
+
+ // Stored as NULL, so the null filter matches it (not just the fixtures).
+ found := &TimeEntry{Filter: "end_time = null"}
+ result, _, _, err := found.ReadAll(s, a, "", 1, 50)
+ require.NoError(t, err)
+ ids := []int64{}
+ for _, e := range result.([]*TimeEntry) {
+ ids = append(ids, e.ID)
+ }
+ assert.Contains(t, ids, te.ID)
+}
+
+// Regression guard: the permission check must not clobber the update payload.
+func TestTimeEntry_Update(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ a := &user.User{ID: 1}
+
+ te := &TimeEntry{
+ ID: 1,
+ TaskID: 1,
+ StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC),
+ EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)),
+ Comment: "updated comment",
+ }
+
+ can, err := te.CanUpdate(s, a) // the handler calls this before Update
+ require.NoError(t, err)
+ require.True(t, can)
+ require.NoError(t, te.Update(s, a))
+ require.NoError(t, s.Commit())
+
+ assert.Equal(t, "updated comment", te.Comment)
+ db.AssertExists(t, "time_entries", map[string]interface{}{
+ "id": 1,
+ "comment": "updated comment",
+ }, false)
+}
+
+func TestTimeEntry_UpdateReassignsContainer(t *testing.T) {
+ validTimes := func(te *TimeEntry) {
+ te.StartTime = time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
+ te.EndTime = timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC))
+ }
+
+ t.Run("moves an entry from a task to a project", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ a := &user.User{ID: 1}
+
+ // Entry 1 is on task 1; move it onto project 1 directly.
+ te := &TimeEntry{ID: 1, ProjectID: 1}
+ validTimes(te)
+
+ can, err := te.CanUpdate(s, a)
+ require.NoError(t, err)
+ require.True(t, can)
+ require.NoError(t, te.Update(s, a))
+ require.NoError(t, s.Commit())
+
+ db.AssertExists(t, "time_entries", map[string]interface{}{
+ "id": 1,
+ "task_id": 0,
+ "project_id": 1,
+ }, false)
+ })
+
+ t.Run("rejects an update that sets both task and project", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ _, err := (&TimeEntry{ID: 1, TaskID: 1, ProjectID: 1}).CanUpdate(s, &user.User{ID: 1})
+ require.Error(t, err)
+ assert.True(t, IsErrTimeEntryInvalidContainer(err))
+ })
+
+ t.Run("an omitted container keeps the existing one", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ a := &user.User{ID: 1}
+
+ // Entry 1 is on task 1; update only the comment, no container set.
+ te := &TimeEntry{ID: 1, Comment: "kept on task"}
+ validTimes(te)
+
+ can, err := te.CanUpdate(s, a)
+ require.NoError(t, err)
+ require.True(t, can)
+ require.NoError(t, te.Update(s, a))
+ require.NoError(t, s.Commit())
+
+ db.AssertExists(t, "time_entries", map[string]interface{}{
+ "id": 1,
+ "task_id": 1,
+ "project_id": 0,
+ "comment": "kept on task",
+ }, false)
+ })
+}
+
+func TestTimeEntry_UpdateReopenGuard(t *testing.T) {
+ a := &user.User{ID: 1}
+ someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
+
+ t.Run("rejects clearing the end of a completed entry", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // Entry 1 is completed; a nil end would reopen it as a running timer.
+ te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart} // EndTime nil
+ can, err := te.CanUpdate(s, a)
+ require.NoError(t, err)
+ require.True(t, can)
+
+ err = te.Update(s, a)
+ require.Error(t, err)
+ assert.True(t, IsErrTimeEntryAlreadyEnded(err), "unexpected error type: %v", err)
+ })
+
+ t.Run("allows editing a running entry while it stays running", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // Entry 4 is user1's running timer; keeping it running (nil end) is fine.
+ te := &TimeEntry{ID: 4, TaskID: 1, StartTime: someStart, Comment: "edited"} // EndTime nil
+ can, err := te.CanUpdate(s, a)
+ require.NoError(t, err)
+ require.True(t, can)
+ require.NoError(t, te.Update(s, a))
+ })
+}
+
+func TestTimeEntry_RejectsInvertedInterval(t *testing.T) {
+ a := &user.User{ID: 1}
+ start := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)
+ before := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
+
+ t.Run("create rejects an end before the start", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(before)}
+ err := te.Create(s, a)
+ require.Error(t, err)
+ assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err)
+ })
+
+ t.Run("create allows an end equal to the start", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(start)}
+ require.NoError(t, te.Create(s, a))
+ })
+
+ t.Run("create allows a running timer with no end", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{TaskID: 1, StartTime: start} // EndTime nil
+ require.NoError(t, te.Create(s, a))
+ })
+
+ t.Run("update rejects an end before the start", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // Entry 1 is user1's completed entry.
+ te := &TimeEntry{ID: 1, TaskID: 1, StartTime: start, EndTime: timePtr(before)}
+ can, err := te.CanUpdate(s, a)
+ require.NoError(t, err)
+ require.True(t, can)
+
+ err = te.Update(s, a)
+ require.Error(t, err)
+ assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err)
+ })
+}
+
+func TestTimeEntry_StopRunningTimer(t *testing.T) {
+ t.Run("stops the caller's running timer and returns it", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ entry, err := StopRunningTimer(s, &user.User{ID: 1}) // entry 4
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+
+ assert.Equal(t, int64(4), entry.ID)
+ assert.NotNil(t, entry.EndTime)
+
+ reloaded := &TimeEntry{}
+ _, err = s.Where("id = ?", 4).Get(reloaded)
+ require.NoError(t, err)
+ assert.NotNil(t, reloaded.EndTime, "end time should be persisted")
+ })
+
+ t.Run("errors when no timer is running", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ _, err := StopRunningTimer(s, &user.User{ID: 2}) // user2 has no entries
+ require.Error(t, err)
+ assert.True(t, IsErrNoRunningTimer(err), "unexpected error type: %v", err)
+ })
+
+ t.Run("denies a link share and leaves the matching user's timer running", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // Share id 1 collides with user 1, whose entry 4 is a running timer.
+ _, err := StopRunningTimer(s, &LinkSharing{ID: 1, ProjectID: 1})
+ require.Error(t, err)
+ assert.True(t, IsErrGenericForbidden(err), "unexpected error type: %v", err)
+
+ running := &TimeEntry{}
+ exists, err := s.Where("id = ?", 4).Get(running)
+ require.NoError(t, err)
+ require.True(t, exists)
+ assert.Nil(t, running.EndTime, "the user's timer must not have been stopped by a link share")
+ })
+}
+
+func TestTimeEntry_Events(t *testing.T) {
+ u := &user.User{ID: 1}
+ someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
+ someEnd := timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC))
+
+ t.Run("create dispatches created", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ events.ClearDispatchedEvents()
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
+ require.NoError(t, te.Create(s, u))
+ require.NoError(t, s.Commit())
+ events.DispatchPending(context.Background(), s)
+ events.AssertDispatched(t, &TimeEntryCreatedEvent{})
+ })
+
+ t.Run("update dispatches updated", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ events.ClearDispatchedEvents()
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart, EndTime: someEnd, Comment: "edited"}
+ can, err := te.CanUpdate(s, u)
+ require.NoError(t, err)
+ require.True(t, can)
+ require.NoError(t, te.Update(s, u))
+ require.NoError(t, s.Commit())
+ events.DispatchPending(context.Background(), s)
+ events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
+ })
+
+ t.Run("delete dispatches deleted", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ events.ClearDispatchedEvents()
+ s := db.NewSession()
+ defer s.Close()
+
+ require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u))
+ require.NoError(t, s.Commit())
+ events.DispatchPending(context.Background(), s)
+ events.AssertDispatched(t, &TimeEntryDeletedEvent{})
+ })
+
+ t.Run("starting a timer dispatches created plus updated for the auto-stopped entry", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ events.ClearDispatchedEvents()
+ s := db.NewSession()
+ defer s.Close()
+
+ // entry 4 is user1's running timer; a new running timer auto-stops it
+ require.NoError(t, (&TimeEntry{TaskID: 1}).Create(s, u))
+ require.NoError(t, s.Commit())
+ events.DispatchPending(context.Background(), s)
+ events.AssertDispatched(t, &TimeEntryCreatedEvent{})
+ events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
+ })
+
+ t.Run("a completed manual entry dispatches only created", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ events.ClearDispatchedEvents()
+ s := db.NewSession()
+ defer s.Close()
+
+ te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
+ require.NoError(t, te.Create(s, u))
+ require.NoError(t, s.Commit())
+ events.DispatchPending(context.Background(), s)
+ assert.Equal(t, 1, events.CountDispatchedEvents((&TimeEntryCreatedEvent{}).Name()))
+ assert.Equal(t, 0, events.CountDispatchedEvents((&TimeEntryUpdatedEvent{}).Name()), "a completed manual entry must not auto-stop")
+ })
+
+ t.Run("StopRunningTimer dispatches updated", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ events.ClearDispatchedEvents()
+ s := db.NewSession()
+ defer s.Close()
+
+ _, err := StopRunningTimer(s, u)
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+ events.DispatchPending(context.Background(), s)
+ events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
+ })
+}
+
+func TestTimeEntry_Delete(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, &user.User{ID: 1}))
+ require.NoError(t, s.Commit())
+ db.AssertMissing(t, "time_entries", map[string]interface{}{"id": 1})
+}
+
+func TestTimeEntry_TaskCount(t *testing.T) {
+ u := &user.User{ID: 1}
+
+ t.Run("attaches counts for a licensed, non-share caller", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ task1 := &Task{ID: 1} // fixtures: time entries 1 and 4 are attached to task 1
+ task2 := &Task{ID: 2} // no time entries
+ taskMap := map[int64]*Task{1: task1, 2: task2}
+
+ require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1, 2}, taskMap))
+
+ require.NotNil(t, task1.TimeEntriesCount)
+ assert.Equal(t, int64(2), *task1.TimeEntriesCount)
+ require.NotNil(t, task2.TimeEntriesCount)
+ assert.Equal(t, int64(0), *task2.TimeEntriesCount)
+ })
+
+ t.Run("leaves the count unset for a link share", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ task1 := &Task{ID: 1}
+ taskMap := map[int64]*Task{1: task1}
+ require.NoError(t, addTimeEntriesCountToTasks(s, &LinkSharing{ID: 1}, []int64{1}, taskMap))
+ assert.Nil(t, task1.TimeEntriesCount, "link shares must not learn time-entry counts")
+ })
+
+ t.Run("leaves the count unset when the feature is unlicensed", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+ license.ResetForTests() // feature disabled
+
+ task1 := &Task{ID: 1}
+ taskMap := map[int64]*Task{1: task1}
+ require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1}, taskMap))
+ assert.Nil(t, task1.TimeEntriesCount, "an unlicensed instance must not expose counts")
+ })
+}
diff --git a/pkg/models/user_project.go b/pkg/models/user_project.go
index 34ea85abe..7fe98c772 100644
--- a/pkg/models/user_project.go
+++ b/pkg/models/user_project.go
@@ -18,10 +18,30 @@ package models
import (
"code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web"
"xorm.io/builder"
"xorm.io/xorm"
)
+// SearchUsersForProject performs the per-project user search shared by both API
+// versions: it checks the caller can read the project, then lists the users
+// with access to it. canRead is false (with no error) when the caller lacks
+// read access, so each handler can map that to its own forbidden response.
+func SearchUsersForProject(s *xorm.Session, project *Project, a web.Auth, currentUser *user.User, search string) (users []*user.User, canRead bool, err error) {
+ canRead, _, err = project.CanRead(s, a)
+ if err != nil {
+ return nil, false, err
+ }
+ if !canRead {
+ return nil, false, nil
+ }
+ users, err = ListUsersFromProject(s, project, currentUser, search)
+ if err != nil {
+ return nil, true, err
+ }
+ return users, true, nil
+}
+
// ProjectUIDs hold all kinds of user IDs from accounts who have access to a project
type ProjectUIDs struct {
ProjectOwnerID int64 `xorm:"projectOwner"`
diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go
new file mode 100644
index 000000000..0d905cd1a
--- /dev/null
+++ b/pkg/models/user_settings.go
@@ -0,0 +1,130 @@
+// 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 models
+
+import (
+ "context"
+
+ "code.vikunja.io/api/pkg/modules/avatar"
+ "code.vikunja.io/api/pkg/user"
+
+ "xorm.io/xorm"
+)
+
+// UserGeneralSettings is the single user-settings wire struct shared by v1 and
+// v2 — both the update request body and the nested settings on GET /user. A
+// dedicated struct (not user.User) is required: user.User's settings fields are
+// json:"-" so they don't leak when it is embedded in other responses
+// (assignees, created_by, members …).
+type UserGeneralSettings struct {
+ Name string `json:"name" doc:"The full name of the user."`
+ EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"If enabled, sends email reminders of tasks to the user."`
+ DiscoverableByName bool `json:"discoverable_by_name" doc:"If true, this user can be found by their name or parts of it when searching."`
+ DiscoverableByEmail bool `json:"discoverable_by_email" doc:"If true, the user can be found when searching for their exact email."`
+ OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled" doc:"If enabled, the user gets an email for their overdue tasks each morning."`
+ OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required" doc:"The time the daily overdue-tasks summary is sent, as HH:MM."`
+ DefaultProjectID int64 `json:"default_project_id" doc:"Project a task is filed under when created without an explicit project."`
+ WeekStart int `json:"week_start" valid:"range(0|6)" minimum:"0" maximum:"6" doc:"The day the week starts on: 0=sunday, 1=monday, … 6=saturday."`
+ Language string `json:"language" doc:"The user's language."`
+ Timezone string `json:"timezone" doc:"The user's time zone, used to send task reminders in their local time."`
+ FrontendSettings any `json:"frontend_settings" doc:"Arbitrary settings used only by the frontend. Any JSON value; stored and returned verbatim."`
+ // Server/OpenID-provided; populated on read, ignored on write.
+ ExtraSettingsLinks map[string]any `json:"extra_settings_links" readOnly:"true" doc:"Additional settings links provided by the OpenID provider. Server-controlled."`
+}
+
+// NewUserGeneralSettings projects a user's stored settings into the shared wire
+// struct for GET /user. Used by both the v1 and v2 user-show handlers.
+func NewUserGeneralSettings(u *user.User) *UserGeneralSettings {
+ return &UserGeneralSettings{
+ Name: u.Name,
+ EmailRemindersEnabled: u.EmailRemindersEnabled,
+ DiscoverableByName: u.DiscoverableByName,
+ DiscoverableByEmail: u.DiscoverableByEmail,
+ OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled,
+ OverdueTasksRemindersTime: u.OverdueTasksRemindersTime,
+ DefaultProjectID: u.DefaultProjectID,
+ WeekStart: u.WeekStart,
+ Language: u.Language,
+ Timezone: u.Timezone,
+ FrontendSettings: u.FrontendSettings,
+ ExtraSettingsLinks: u.ExtraSettingsLinks,
+ }
+}
+
+// ChangeUserPassword verifies the old password, sets the new one, and
+// invalidates all of the user's sessions. Lives here (not in pkg/user) because
+// it needs DeleteAllUserSessions, which pkg/user cannot import.
+func ChangeUserPassword(ctx context.Context, s *xorm.Session, u *user.User, oldPassword, newPassword string) error {
+ if oldPassword == "" {
+ return user.ErrEmptyOldPassword{}
+ }
+
+ if _, err := user.CheckUserCredentials(ctx, s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil {
+ return err
+ }
+
+ if err := user.UpdateUserPassword(s, u, newPassword); err != nil {
+ return err
+ }
+
+ return DeleteAllUserSessions(s, u.ID)
+}
+
+// UpdateUserGeneralSettings copies the general settings onto the user, persists
+// them, and flushes the avatar cache when an initials avatar's name changed.
+// Lives here (not in pkg/user) because the avatar flush needs pkg/modules/avatar,
+// which pkg/user cannot import.
+func UpdateUserGeneralSettings(s *xorm.Session, u *user.User, settings *UserGeneralSettings) error {
+ invalidateAvatar := u.AvatarProvider == "initials" && u.Name != settings.Name
+
+ u.Name = settings.Name
+ u.EmailRemindersEnabled = settings.EmailRemindersEnabled
+ u.DiscoverableByEmail = settings.DiscoverableByEmail
+ u.DiscoverableByName = settings.DiscoverableByName
+ u.OverdueTasksRemindersEnabled = settings.OverdueTasksRemindersEnabled
+ u.DefaultProjectID = settings.DefaultProjectID
+ u.WeekStart = settings.WeekStart
+ u.Language = settings.Language
+ u.Timezone = settings.Timezone
+ u.OverdueTasksRemindersTime = settings.OverdueTasksRemindersTime
+ u.FrontendSettings = settings.FrontendSettings
+
+ if _, err := user.UpdateUser(s, u, true); err != nil {
+ return err
+ }
+
+ if invalidateAvatar {
+ avatar.FlushAllCaches(u)
+ }
+ return nil
+}
+
+// UpdateUserAvatarProvider sets the user's avatar provider, persists it, and
+// flushes the avatar cache when the provider changes (or is set to initials).
+func UpdateUserAvatarProvider(s *xorm.Session, u *user.User, provider string) error {
+ oldProvider := u.AvatarProvider
+ u.AvatarProvider = provider
+
+ if _, err := user.UpdateUser(s, u, false); err != nil {
+ return err
+ }
+
+ if u.AvatarProvider == "initials" || oldProvider != u.AvatarProvider {
+ avatar.FlushAllCaches(u)
+ }
+ return nil
+}
diff --git a/pkg/models/users.go b/pkg/models/users.go
index da2b7af97..b5a79f510 100644
--- a/pkg/models/users.go
+++ b/pkg/models/users.go
@@ -22,6 +22,33 @@ import (
"xorm.io/xorm"
)
+// doerFromAuth resolves the authenticated principal into a full user for event payloads. The JWT
+// only carries id + username, so without a re-fetch notifications and emails render the
+// auto-generated username instead of the display name (#2720). Status errors (disabled/locked) are
+// swallowed because their user is still populated and some flows act on behalf of such accounts
+// (e.g. user deletion deletes that user's tasks); the partial principal is used as a last resort.
+func doerFromAuth(s *xorm.Session, a web.Auth) *user.User {
+ if a == nil {
+ return nil
+ }
+
+ doer, err := GetUserOrLinkShareUser(s, a)
+ if err != nil && !user.IsErrUserStatusError(err) {
+ doer = nil
+ }
+ if doer != nil && doer.ID != 0 {
+ return doer
+ }
+
+ if u, is := a.(*user.User); is {
+ return u
+ }
+ if share, is := a.(*LinkSharing); is {
+ return share.toUser()
+ }
+ return &user.User{ID: a.GetID()}
+}
+
// GetUserOrLinkShareUser returns either a user or a link share disguised as a user.
func GetUserOrLinkShareUser(s *xorm.Session, a web.Auth) (uu *user.User, err error) {
if u, is := a.(*user.User); is {
diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go
index fd7ee8e81..b4038bf6c 100644
--- a/pkg/models/webhooks.go
+++ b/pkg/models/webhooks.go
@@ -40,6 +40,7 @@ import (
"code.vikunja.io/api/pkg/version"
"code.vikunja.io/api/pkg/web"
+ "xorm.io/builder"
"xorm.io/xorm"
)
@@ -216,24 +217,36 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 500 {object} models.Message "Internal server error"
// @Router /projects/{id}/webhooks [get]
func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
- p := &Project{ID: w.ProjectID}
- can, _, err := p.CanRead(s, a)
- if err != nil {
- return nil, 0, 0, err
- }
- if !can {
- return nil, 0, 0, ErrGenericForbidden{}
+ // w.UserID set selects the user-level list: a user may only see their own
+ // webhooks. The project list (w.UserID == 0) delegates to the project's read
+ // permission instead.
+ var listCond builder.Cond
+ if w.UserID > 0 {
+ if _, isShareAuth := a.(*LinkSharing); isShareAuth || w.UserID != a.GetID() {
+ return nil, 0, 0, ErrGenericForbidden{}
+ }
+ listCond = builder.Eq{"user_id": w.UserID}
+ } else {
+ p := &Project{ID: w.ProjectID}
+ can, _, cerr := p.CanRead(s, a)
+ if cerr != nil {
+ return nil, 0, 0, cerr
+ }
+ if !can {
+ return nil, 0, 0, ErrGenericForbidden{}
+ }
+ listCond = builder.Eq{"project_id": w.ProjectID}
}
ws := []*Webhook{}
- err = s.Where("project_id = ?", w.ProjectID).
+ err = s.Where(listCond).
Limit(getLimitFromPageIndex(page, perPage)).
Find(&ws)
if err != nil {
return
}
- total, err := s.Where("project_id = ?", w.ProjectID).
+ total, err := s.Where(listCond).
Count(&Webhook{})
if err != nil {
return
diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go
index 61a5f9b13..87c89e5aa 100644
--- a/pkg/modules/auth/auth.go
+++ b/pkg/modules/auth/auth.go
@@ -26,6 +26,8 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/user"
@@ -98,42 +100,77 @@ func ClearRefreshTokenCookie(c *echo.Context) {
SetRefreshTokenCookie(c, "", -1)
}
-// NewUserAuthTokenResponse creates a new user auth token response from a user object.
-func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error {
+// IssuedUserToken bundles a freshly minted access token with the matching
+// refresh token and the cookie max-age both v1 and v2 use to set the
+// HttpOnly refresh cookie.
+type IssuedUserToken struct {
+ AccessToken string
+ RefreshToken string
+ CookieMaxAge int
+}
+
+// IssueUserToken creates a session for the user and mints a JWT access token plus
+// a refresh token for it. It is the transport-agnostic core both v1 (which writes
+// the echo response) and v2 (Huma) call; callers set the refresh cookie and the
+// Cache-Control header themselves via WriteUserAuthCookies. Pass oidc for
+// OpenID Connect logins to store the logout data; nil otherwise.
+func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool, oidc *models.SessionOIDCData) (*IssuedUserToken, error) {
s := db.NewSession()
defer s.Close()
- deviceInfo := c.Request().UserAgent()
- ipAddress := c.RealIP()
-
- session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long)
+ session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long, oidc)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
t, err := NewUserJWTAuthtoken(u, session.ID)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
- return err
+ return nil, err
+ }
+
+ if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil {
+ log.Errorf("Could not dispatch login succeeded event: %s", err)
}
- // Set the refresh token as an HttpOnly cookie. The cookie is path-scoped
- // to the refresh endpoint, so the browser only sends it there. JavaScript
- // never sees the refresh token — this protects it from XSS.
cookieMaxAge := int(config.ServiceJWTTTL.GetInt64())
if long {
cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64())
}
- SetRefreshTokenCookie(c, session.RefreshToken, cookieMaxAge)
+ return &IssuedUserToken{
+ AccessToken: t,
+ RefreshToken: session.RefreshToken,
+ CookieMaxAge: cookieMaxAge,
+ }, nil
+}
+
+// WriteUserAuthCookies sets the HttpOnly refresh-token cookie and the
+// Cache-Control: no-store header on a response. The cookie is path-scoped to the
+// refresh endpoint, so the browser only sends it there; JavaScript never sees the
+// refresh token, which protects it from XSS. Shared by the v1 echo handlers and
+// the v2 Huma handlers (which reach the echo context via humaecho5.Unwrap).
+func WriteUserAuthCookies(c *echo.Context, token *IssuedUserToken) {
+ SetRefreshTokenCookie(c, token.RefreshToken, token.CookieMaxAge)
c.Response().Header().Set("Cache-Control", "no-store")
- return c.JSON(http.StatusOK, Token{Token: t})
+}
+
+// NewUserAuthTokenResponse creates a new user auth token response from a user object.
+// Pass oidc for OpenID Connect logins to store the logout data; nil otherwise.
+func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool, oidc *models.SessionOIDCData) error {
+ token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long, oidc)
+ if err != nil {
+ return err
+ }
+
+ WriteUserAuthCookies(c, token)
+ return c.JSON(http.StatusOK, Token{Token: token.AccessToken})
}
// NewUserJWTAuthtoken generates and signs a new short-lived jwt token for a user.
@@ -386,6 +423,26 @@ func RefreshSession(rawRefreshToken string) (*RefreshResult, error) {
}, nil
}
+// SessionIDFromContext reads the session id (the `sid` claim) off the user JWT
+// in the echo context. It returns "" when there is no user JWT or no sid claim
+// (API tokens and link shares carry no session), which callers treat as a no-op.
+func SessionIDFromContext(c *echo.Context) string {
+ raw := c.Get("user")
+ if raw == nil {
+ return ""
+ }
+ jwtinf, ok := raw.(*jwt.Token)
+ if !ok {
+ return ""
+ }
+ claims, ok := jwtinf.Claims.(jwt.MapClaims)
+ if !ok {
+ return ""
+ }
+ sid, _ := claims["sid"].(string)
+ return sid
+}
+
// GetAuthFromContext retrieves the authenticated web.Auth from a plain
// context.Context, bridging Huma handlers to Vikunja's echo JWT flow. The
// humaecho5 adapter stashes the *echo.Context under EchoContextKey first.
diff --git a/pkg/modules/auth/oauth2server/authorize.go b/pkg/modules/auth/oauth2server/authorize.go
index 873c00900..96afbbad7 100644
--- a/pkg/modules/auth/oauth2server/authorize.go
+++ b/pkg/modules/auth/oauth2server/authorize.go
@@ -26,8 +26,8 @@ import (
"github.com/labstack/echo/v5"
)
-// authorizeRequest represents the JSON body for the authorize endpoint.
-type authorizeRequest struct {
+// AuthorizeRequest represents the body for the authorize endpoint.
+type AuthorizeRequest struct {
ResponseType string `json:"response_type"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
@@ -47,54 +47,66 @@ type AuthorizeResponse struct {
// It validates the OAuth parameters, creates an authorization code, and
// returns it as JSON. Authentication is handled by the token middleware.
func HandleAuthorize(c *echo.Context) error {
- var req authorizeRequest
+ var req AuthorizeRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
}
- // Validate response_type
- if req.ResponseType != "code" {
- return echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'")
- }
-
- // Validate redirect_uri
- if !ValidateRedirectURI(req.RedirectURI) {
- return &models.ErrOAuthInvalidRedirectURI{}
- }
-
- // Validate PKCE (required)
- if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" {
- return &models.ErrOAuthMissingPKCE{}
- }
-
// Get the authenticated user from the middleware
u, err := user.GetCurrentUser(c)
if err != nil {
return err
}
+ resp, err := Authorize(&req, u.ID)
+ if err != nil {
+ return err
+ }
+
+ return c.JSON(http.StatusOK, resp)
+}
+
+// Authorize validates the OAuth authorization parameters for the given
+// authenticated user and creates a single-use authorization code, independent
+// of the HTTP layer. Callers own request binding and resolving the user.
+func Authorize(req *AuthorizeRequest, userID int64) (*AuthorizeResponse, error) {
+ // Validate response_type
+ if req.ResponseType != "code" {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'")
+ }
+
+ // Validate redirect_uri
+ if !ValidateRedirectURI(req.RedirectURI) {
+ return nil, &models.ErrOAuthInvalidRedirectURI{}
+ }
+
+ // Validate PKCE (required)
+ if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" {
+ return nil, &models.ErrOAuthMissingPKCE{}
+ }
+
s := db.NewSession()
defer s.Close()
- fullUser, err := user.GetUserByID(s, u.ID)
+ fullUser, err := user.GetUserByID(s, userID)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
code, err := models.CreateOAuthCode(s, fullUser.ID, req.ClientID, req.RedirectURI, req.CodeChallenge, req.CodeChallengeMethod)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
if err := s.Commit(); err != nil {
- return err
+ return nil, err
}
- return c.JSON(http.StatusOK, AuthorizeResponse{
+ return &AuthorizeResponse{
Code: code,
RedirectURI: req.RedirectURI,
State: req.State,
- })
+ }, nil
}
diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go
index 2725b988d..97978c0bf 100644
--- a/pkg/modules/auth/oauth2server/token.go
+++ b/pkg/modules/auth/oauth2server/token.go
@@ -17,10 +17,14 @@
package oauth2server
import (
+ "context"
+
"net/http"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/user"
@@ -36,35 +40,51 @@ type TokenResponse struct {
RefreshToken string `json:"refresh_token"`
}
-// tokenRequest holds the JSON body of a POST /oauth/token request.
-type tokenRequest struct {
- GrantType string `json:"grant_type"`
- Code string `json:"code"`
- ClientID string `json:"client_id"`
- RedirectURI string `json:"redirect_uri"`
- CodeVerifier string `json:"code_verifier"`
- RefreshToken string `json:"refresh_token"`
+// TokenRequest holds the parameters of a POST /oauth/token request. v1 binds it
+// from JSON; v2 accepts spec-compliant application/x-www-form-urlencoded as well
+// (form tags mirror the json names).
+type TokenRequest struct {
+ GrantType string `json:"grant_type" form:"grant_type"`
+ Code string `json:"code" form:"code"`
+ ClientID string `json:"client_id" form:"client_id"`
+ RedirectURI string `json:"redirect_uri" form:"redirect_uri"`
+ CodeVerifier string `json:"code_verifier" form:"code_verifier"`
+ RefreshToken string `json:"refresh_token" form:"refresh_token"`
}
// HandleToken handles POST /oauth/token.
// Supports grant_type=authorization_code and grant_type=refresh_token.
func HandleToken(c *echo.Context) error {
- var req tokenRequest
+ var req TokenRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
}
+ resp, err := ExchangeToken(c.Request().Context(), &req, c.Request().UserAgent(), c.RealIP())
+ if err != nil {
+ return err
+ }
+
+ c.Response().Header().Set("Cache-Control", "no-store")
+ return c.JSON(http.StatusOK, resp)
+}
+
+// ExchangeToken runs the grant-type dispatch and token issuance for the OAuth
+// token endpoint, independent of the HTTP layer. Callers own request binding and
+// the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded
+// on the session created for the authorization_code grant.
+func ExchangeToken(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) {
switch req.GrantType {
case "authorization_code":
- return handleAuthorizationCodeGrant(c, &req)
+ return exchangeAuthorizationCode(ctx, req, deviceInfo, ipAddress)
case "refresh_token":
- return handleRefreshTokenGrant(c, &req)
+ return exchangeRefreshToken(req)
default:
- return &models.ErrOAuthInvalidGrantType{}
+ return nil, &models.ErrOAuthInvalidGrantType{}
}
}
-func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error {
+func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) {
s := db.NewSession()
defer s.Close()
@@ -72,73 +92,75 @@ func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error {
oauthCode, err := models.GetAndDeleteOAuthCode(s, req.Code)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
// Validate client_id matches
if oauthCode.ClientID != req.ClientID {
_ = s.Rollback()
- return &models.ErrOAuthClientNotFound{}
+ return nil, &models.ErrOAuthClientNotFound{}
}
// Validate redirect_uri matches
if oauthCode.RedirectURI != req.RedirectURI {
_ = s.Rollback()
- return &models.ErrOAuthInvalidRedirectURI{}
+ return nil, &models.ErrOAuthInvalidRedirectURI{}
}
// Verify PKCE
if !VerifyPKCE(req.CodeVerifier, oauthCode.CodeChallenge, oauthCode.CodeChallengeMethod) {
_ = s.Rollback()
- return &models.ErrOAuthPKCEVerifyFailed{}
+ return nil, &models.ErrOAuthPKCEVerifyFailed{}
}
// Create a session (reuses existing session infrastructure)
- deviceInfo := c.Request().UserAgent()
- ipAddress := c.RealIP()
- session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false)
+ session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false, nil)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
u, err := user.GetUserByID(s, oauthCode.UserID)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
// Generate JWT
accessToken, err := auth.NewUserJWTAuthtoken(u, session.ID)
if err != nil {
_ = s.Rollback()
- return err
+ return nil, err
}
if err := s.Commit(); err != nil {
- return err
+ return nil, err
}
- c.Response().Header().Set("Cache-Control", "no-store")
- return c.JSON(http.StatusOK, TokenResponse{
+ // The code exchange mints a fresh session, so it is a login for the
+ // audit trail, same as NewUserAuthTokenResponse.
+ if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil {
+ log.Errorf("Could not dispatch login succeeded event: %s", err)
+ }
+
+ return &TokenResponse{
AccessToken: accessToken,
TokenType: "bearer",
ExpiresIn: config.ServiceJWTTTLShort.GetInt64(),
RefreshToken: session.RefreshToken,
- })
+ }, nil
}
-func handleRefreshTokenGrant(c *echo.Context, req *tokenRequest) error {
+func exchangeRefreshToken(req *TokenRequest) (*TokenResponse, error) {
result, err := auth.RefreshSession(req.RefreshToken)
if err != nil {
- return err
+ return nil, err
}
- c.Response().Header().Set("Cache-Control", "no-store")
- return c.JSON(http.StatusOK, TokenResponse{
+ return &TokenResponse{
AccessToken: result.AccessToken,
TokenType: "bearer",
ExpiresIn: result.ExpiresIn,
RefreshToken: result.NewRefreshToken,
- })
+ }, nil
}
diff --git a/pkg/modules/auth/openid/logout.go b/pkg/modules/auth/openid/logout.go
new file mode 100644
index 000000000..958ea8765
--- /dev/null
+++ b/pkg/modules/auth/openid/logout.go
@@ -0,0 +1,110 @@
+// 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 openid
+
+import (
+ "net/url"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/models"
+)
+
+// EndSessionEndpoint returns the provider's RP-Initiated Logout endpoint
+// (discovery's end_session_endpoint, cached at init), falling back to the static
+// logouturl. Never triggers discovery so logout stays responsive when the OP is
+// unreachable.
+func (p *Provider) EndSessionEndpoint() string {
+ if p.EndSessionURL != "" {
+ return p.EndSessionURL
+ }
+ return p.LogoutURL
+}
+
+// discoveredEndSessionEndpoint reads end_session_endpoint from the discovery
+// document already cached on the *oidc.Provider, so Claims unmarshals in memory
+// without a request.
+func (p *Provider) discoveredEndSessionEndpoint() string {
+ if p.openIDProvider == nil {
+ return ""
+ }
+
+ var meta struct {
+ EndSessionEndpoint string `json:"end_session_endpoint"`
+ }
+ if err := p.openIDProvider.Claims(&meta); err != nil {
+ log.Debugf("Could not read end_session_endpoint for provider %s: %v", p.Key, err)
+ return ""
+ }
+ return meta.EndSessionEndpoint
+}
+
+// BuildEndSessionURL builds an OpenID Connect RP-Initiated Logout 1.0 request URL
+// (id_token_hint + post_logout_redirect_uri + client_id; see RP-Initiated Logout
+// 1.0 §2). post_logout_redirect_uri defaults to service.publicurl, and the OP
+// only honors it when id_token_hint is present. Returns "" when neither an
+// end_session_endpoint nor a static logouturl is configured.
+func BuildEndSessionURL(providerKey string, oidc *models.SessionOIDCData) (string, error) {
+ // GetProvider would trigger OIDC discovery (a live HTTP GET that blocks when
+ // the OP is down); the cached static fields are all logout needs.
+ provider, err := getCachedProvider(providerKey)
+ if err != nil {
+ return "", err
+ }
+ if provider == nil {
+ return "", nil
+ }
+
+ idToken := ""
+ if oidc != nil {
+ idToken = oidc.IDToken
+ }
+
+ return buildEndSessionURL(
+ provider.EndSessionEndpoint(),
+ provider.ClientID,
+ idToken,
+ config.ServicePublicURL.GetString(),
+ )
+}
+
+// buildEndSessionURL appends the logout query params onto endpoint, omitting
+// empty ones, and returns "" for an empty endpoint.
+func buildEndSessionURL(endpoint, clientID, idToken, postLogoutRedirectURI string) (string, error) {
+ if endpoint == "" {
+ return "", nil
+ }
+
+ u, err := url.Parse(endpoint)
+ if err != nil {
+ return "", err
+ }
+
+ q := u.Query()
+ if clientID != "" {
+ q.Set("client_id", clientID)
+ }
+ if idToken != "" {
+ q.Set("id_token_hint", idToken)
+ }
+ if postLogoutRedirectURI != "" {
+ q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
+ }
+ u.RawQuery = q.Encode()
+
+ return u.String(), nil
+}
diff --git a/pkg/modules/auth/openid/logout_test.go b/pkg/modules/auth/openid/logout_test.go
new file mode 100644
index 000000000..57e3ea6a2
--- /dev/null
+++ b/pkg/modules/auth/openid/logout_test.go
@@ -0,0 +1,234 @@
+// 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 openid
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/keyvalue"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// newMockOIDCServerWithEndSession publishes a discovery document with an
+// end_session_endpoint.
+func newMockOIDCServerWithEndSession() *httptest.Server {
+ var server *httptest.Server
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
+ discovery := map[string]interface{}{
+ "issuer": server.URL,
+ "authorization_endpoint": server.URL + "/auth",
+ "token_endpoint": server.URL + "/token",
+ "jwks_uri": server.URL + "/jwks",
+ "end_session_endpoint": server.URL + "/logout",
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(discovery)
+ })
+ server = httptest.NewServer(mux)
+ return server
+}
+
+func TestBuildEndSessionURLAssembly(t *testing.T) {
+ t.Run("all params", func(t *testing.T) {
+ got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "the-id-token", "https://vikunja.example.com/")
+ require.NoError(t, err)
+
+ u, err := url.Parse(got)
+ require.NoError(t, err)
+ q := u.Query()
+ assert.Equal(t, "https", u.Scheme)
+ assert.Equal(t, "op.example.com", u.Host)
+ assert.Equal(t, "/logout", u.Path)
+ assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
+ assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
+ assert.Equal(t, "my-client", q.Get("client_id"))
+ })
+
+ t.Run("preserves existing endpoint query params", func(t *testing.T) {
+ got, err := buildEndSessionURL("https://op.example.com/logout?foo=bar", "my-client", "the-id-token", "https://vikunja.example.com/")
+ require.NoError(t, err)
+
+ u, err := url.Parse(got)
+ require.NoError(t, err)
+ q := u.Query()
+ assert.Equal(t, "bar", q.Get("foo"))
+ assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
+ })
+
+ t.Run("omits id_token_hint when no token", func(t *testing.T) {
+ got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "", "https://vikunja.example.com/")
+ require.NoError(t, err)
+
+ u, err := url.Parse(got)
+ require.NoError(t, err)
+ q := u.Query()
+ assert.False(t, q.Has("id_token_hint"))
+ assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
+ assert.Equal(t, "my-client", q.Get("client_id"))
+ })
+
+ t.Run("empty endpoint returns empty", func(t *testing.T) {
+ got, err := buildEndSessionURL("", "my-client", "the-id-token", "https://vikunja.example.com/")
+ require.NoError(t, err)
+ assert.Empty(t, got)
+ })
+}
+
+func TestBuildEndSessionURLFromDiscovery(t *testing.T) {
+ defer CleanupSavedOpenIDProviders()
+
+ server := newMockOIDCServerWithEndSession()
+ defer server.Close()
+
+ config.AuthOpenIDEnabled.Set(true)
+ config.ServicePublicURL.Set("https://vikunja.example.com/")
+ config.AuthOpenIDProviders.Set(map[string]interface{}{
+ "provider1": map[string]interface{}{
+ "name": "Provider One",
+ "authurl": server.URL,
+ "clientid": "client1",
+ "clientsecret": "secret1",
+ },
+ })
+ _ = keyvalue.Del("openid_providers")
+ _ = keyvalue.Del("openid_provider_provider1")
+
+ got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
+ IDToken: "raw-id-token",
+ ProviderKey: "provider1",
+ })
+ require.NoError(t, err)
+
+ u, err := url.Parse(got)
+ require.NoError(t, err)
+ q := u.Query()
+ assert.Equal(t, server.URL+"/logout", u.Scheme+"://"+u.Host+u.Path)
+ assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
+ assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
+ assert.Equal(t, "client1", q.Get("client_id"))
+}
+
+func TestBuildEndSessionURLFromCachedProviderWithoutLiveObject(t *testing.T) {
+ defer CleanupSavedOpenIDProviders()
+
+ config.AuthOpenIDEnabled.Set(true)
+ config.ServicePublicURL.Set("https://vikunja.example.com/")
+
+ // Seed only the cached static fields (no live openIDProvider), mimicking a
+ // provider restored from keyvalue whose OP is unreachable.
+ _ = keyvalue.Del("openid_providers")
+ require.NoError(t, keyvalue.Put("openid_provider_provider1", &Provider{
+ Key: "provider1",
+ ClientID: "client1",
+ EndSessionURL: "https://op.example.com/end-session",
+ }))
+
+ got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
+ IDToken: "raw-id-token",
+ ProviderKey: "provider1",
+ })
+ require.NoError(t, err)
+
+ u, err := url.Parse(got)
+ require.NoError(t, err)
+ q := u.Query()
+ assert.Equal(t, "https://op.example.com/end-session", u.Scheme+"://"+u.Host+u.Path)
+ assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
+ assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
+ assert.Equal(t, "client1", q.Get("client_id"))
+}
+
+func TestEndSessionEndpointUsesCachedURLWithoutDiscovery(t *testing.T) {
+ // A nil openIDProvider models a provider restored from cache (or an
+ // unreachable OP): EndSessionEndpoint must answer from the cached URL.
+ p := &Provider{
+ Key: "provider1",
+ LogoutURL: "https://op.example.com/static-logout",
+ EndSessionURL: "https://op.example.com/end-session",
+ }
+ assert.Equal(t, "https://op.example.com/end-session", p.EndSessionEndpoint())
+}
+
+func TestEndSessionEndpointFallsBackToLogoutURLWhenNotCached(t *testing.T) {
+ p := &Provider{
+ Key: "provider1",
+ LogoutURL: "https://op.example.com/static-logout",
+ }
+ assert.Equal(t, "https://op.example.com/static-logout", p.EndSessionEndpoint())
+}
+
+func TestEndSessionEndpointCachedFromDiscoveryOnInit(t *testing.T) {
+ defer CleanupSavedOpenIDProviders()
+
+ server := newMockOIDCServerWithEndSession()
+ defer server.Close()
+
+ config.AuthOpenIDEnabled.Set(true)
+ config.AuthOpenIDProviders.Set(map[string]interface{}{
+ "provider1": map[string]interface{}{
+ "name": "Provider One",
+ "authurl": server.URL,
+ "clientid": "client1",
+ "clientsecret": "secret1",
+ },
+ })
+ _ = keyvalue.Del("openid_providers")
+ _ = keyvalue.Del("openid_provider_provider1")
+
+ provider, err := GetProvider("provider1")
+ require.NoError(t, err)
+ require.NotNil(t, provider)
+
+ assert.Equal(t, server.URL+"/logout", provider.EndSessionURL)
+ assert.Equal(t, server.URL+"/logout", provider.EndSessionEndpoint())
+}
+
+func TestEndSessionEndpointFallsBackToStaticLogoutURL(t *testing.T) {
+ defer CleanupSavedOpenIDProviders()
+
+ // newMockOIDCServer publishes no end_session_endpoint, forcing the logouturl fallback.
+ server := newMockOIDCServer()
+ defer server.Close()
+
+ config.AuthOpenIDEnabled.Set(true)
+ config.AuthOpenIDProviders.Set(map[string]interface{}{
+ "provider1": map[string]interface{}{
+ "name": "Provider One",
+ "authurl": server.URL,
+ "clientid": "client1",
+ "clientsecret": "secret1",
+ "logouturl": "https://op.example.com/static-logout",
+ },
+ })
+ _ = keyvalue.Del("openid_providers")
+ _ = keyvalue.Del("openid_provider_provider1")
+
+ provider, err := GetProvider("provider1")
+ require.NoError(t, err)
+ require.NotNil(t, provider)
+
+ assert.Equal(t, "https://op.example.com/static-logout", provider.EndSessionEndpoint())
+}
diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go
index 381570f42..3fd7a6cfa 100644
--- a/pkg/modules/auth/openid/openid.go
+++ b/pkg/modules/auth/openid/openid.go
@@ -27,6 +27,7 @@ import (
"strings"
"code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@@ -68,8 +69,12 @@ type Provider struct {
ForceUserInfo bool `json:"force_user_info"`
RequireAvailability bool `json:"-"`
ClientSecret string `json:"-"`
- openIDProvider *oidc.Provider
- Oauth2Config *oauth2.Config `json:"-"`
+ // RP-Initiated Logout endpoint, cached at init so logout never fetches.
+ // Exported so it survives the gob keyvalue round-trip (gob skips unexported
+ // fields like openIDProvider); json:"-" keeps it out of /info.
+ EndSessionURL string `json:"-"`
+ openIDProvider *oidc.Provider
+ Oauth2Config *oauth2.Config `json:"-"`
}
type claims struct {
@@ -167,8 +172,12 @@ func enforceTOTPIfRequired(s *xorm.Session, u *user.User, totpPasscode string) e
// @Failure 500 {object} models.Message "Internal error"
// @Router /auth/openid/{provider}/callback [post]
func HandleCallback(c *echo.Context) error {
+ cb := &Callback{}
+ if err := c.Bind(cb); err != nil {
+ return &models.ErrOpenIDBadRequest{Message: "Bad data"}
+ }
- provider, cb, oauthToken, idToken, err := getProviderAndOidcTokens(c)
+ u, oidcData, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider"))
if err != nil {
var detailedErr *models.ErrOpenIDBadRequestWithDetails
if errors.As(err, &detailedErr) {
@@ -180,29 +189,58 @@ func HandleCallback(c *echo.Context) error {
return err
}
- cl, err := getClaims(provider, oauthToken, idToken)
+ // Create token
+ return auth.NewUserAuthTokenResponse(u, c, false, oidcData)
+}
+
+// AuthenticateCallback resolves an OpenID Connect callback to an authenticated
+// user: it exchanges the auth code, verifies the ID token, creates or updates the
+// matching local user, enforces the account-status and TOTP gates, and syncs the
+// user's external teams. It is the transport-agnostic core shared by the v1 echo
+// handler and the v2 Huma handler; the caller issues the auth token. The
+// ErrOpenIDBadRequestWithDetails error keeps its provider detail so v1 can render
+// its bespoke body and v2 can map it to RFC 9457.
+func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, *models.SessionOIDCData, error) {
+ // ctx is threaded through only to dispatch the login event; the OIDC token
+ // exchange, claim verification and user/avatar sync run on their own
+ // background contexts, exactly as the v1 callback always did.
+ provider, oauthToken, idToken, rawIDToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck
if err != nil {
- return err
+ return nil, nil, err
+ }
+
+ // Stored so logout can replay it as id_token_hint in an RP-Initiated Logout.
+ oidcData := &models.SessionOIDCData{
+ IDToken: rawIDToken,
+ ProviderKey: providerKey,
+ }
+
+ cl, err := getClaims(provider, oauthToken, idToken) //nolint:contextcheck
+ if err != nil {
+ return nil, nil, err
}
s := db.NewSession()
defer s.Close()
+ // Discards events queued during a rolled-back transaction (e.g. user
+ // creation); a no-op once DispatchPending has run.
+ defer events.CleanupPending(s)
// Check if we have seen this user before
- u, err := getOrCreateUser(s, cl, provider, idToken)
+ u, err := getOrCreateUser(s, cl, provider, idToken) //nolint:contextcheck
if err != nil {
_ = s.Rollback()
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
- return err
+ return nil, nil, err
}
if u.Status == user.StatusDisabled {
_ = s.Rollback()
- return &user.ErrAccountDisabled{UserID: u.ID}
+ return nil, nil, &user.ErrAccountDisabled{UserID: u.ID}
}
if u.Status == user.StatusAccountLocked {
_ = s.Rollback()
- return &user.ErrAccountLocked{UserID: u.ID}
+ return nil, nil, &user.ErrAccountLocked{UserID: u.ID}
}
// Must run before team sync so a failed 2FA attempt cannot mutate team
@@ -212,29 +250,33 @@ func HandleCallback(c *echo.Context) error {
if err := enforceTOTPIfRequired(s, u, cb.TOTPPasscode); err != nil {
if commitErr := s.Commit(); commitErr != nil {
log.Errorf("Error committing session after failed OIDC TOTP attempt for user %d: %v", u.ID, commitErr)
+ } else {
+ // The user creation above was committed, so its events are real.
+ events.DispatchPending(ctx, s)
}
if user.IsErrInvalidTOTPPasscode(err) {
user.HandleFailedTOTPAuth(u)
}
- return err
+ return nil, nil, err
}
teamData := getTeamDataFromToken(cl.VikunjaGroups, provider)
err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name)
if err != nil {
- return err
+ return nil, nil, err
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
log.Errorf("Error creating new team for provider %s: %v", provider.Name, err)
- return err
+ return nil, nil, err
}
- // Create token
- return auth.NewUserAuthTokenResponse(u, c, false)
+ events.DispatchPending(ctx, s)
+
+ return u, oidcData, nil
}
func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) {
@@ -335,6 +377,46 @@ func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string)
return nil
}
+// fallbackSearchUsers builds the ordered list of local-user lookups used to link an OIDC
+// login to an existing account when the provider has email and/or username fallback enabled.
+// GetUserWithEmail ANDs all non-zero fields, so the email (when set) is combined with each
+// username candidate.
+func fallbackSearchUsers(cl *claims, provider *Provider, idToken *oidc.IDToken) []*user.User {
+ fallbackEmail := ""
+ if provider.EmailFallback {
+ // Used alone, allow for someone to connect from various provider to the same account.
+ // Discouraged for untrusted providers where someone can set email without verification.
+ // Note: mapping on email prevents auto-updating the user email.
+ fallbackEmail = cl.Email
+ }
+
+ // Try the subject first (keeps working for IdPs where sub == username), then the
+ // preferred_username. The latter lets providers with an opaque sub (e.g. a random
+ // UUID, like PocketID) still link to an existing local account.
+ var searches []*user.User
+ if provider.UsernameFallback {
+ // Skip empty username candidates: GetUserWithEmail ANDs only non-zero fields, so a
+ // {Issuer, Username:"", Email:""} would degenerate to an issuer-only lookup and link
+ // an arbitrary local user. idToken.Subject is non-empty per OIDC, but guard anyway.
+ if idToken.Subject != "" {
+ searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: idToken.Subject, Email: fallbackEmail})
+ }
+ preferred := strings.ReplaceAll(cl.PreferredUsername, " ", "-")
+ if preferred != "" && preferred != idToken.Subject {
+ searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: preferred, Email: fallbackEmail})
+ }
+ }
+ // EmailFallback without UsernameFallback: a single email-only lookup (the caller only
+ // runs this when at least one fallback is enabled, so EmailFallback is guaranteed here).
+ // Only add it when there is a real email — an empty email would degenerate to an
+ // issuer-only lookup and link an arbitrary local user.
+ if len(searches) == 0 && cl.Email != "" {
+ searches = append(searches, &user.User{Issuer: user.IssuerLocal, Email: cl.Email})
+ }
+
+ return searches
+}
+
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
// set defaults
@@ -360,33 +442,21 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) {
- // try finding the user on fallback mappingproperties
+ // try finding the user on fallback mapping properties
+ for _, searchUser := range fallbackSearchUsers(cl, provider, idToken) {
+ u, err = user.GetUserWithEmail(s, searchUser)
+ if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
+ return nil, err
+ }
+ fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
- searchUser := &user.User{
- Issuer: user.IssuerLocal,
- }
- if provider.UsernameFallback {
- // Match oidc subject on username as each is unique identifier in its own referential
- // Discouraged if multiple account providers are used.
- searchUser.Username = idToken.Subject
- }
- if provider.EmailFallback {
- // Used alone, allow for someone to connect from various provider to the same account
- // Discouraged for untrusted provider where someone can set email without verification
- // Note : mapping on email prevent from auto-updating user email
- searchUser.Email = cl.Email
- }
-
- // Check if the user exists for the given fallback matching options
- u, err = user.GetUserWithEmail(s, searchUser)
- if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
- return nil, err
- }
- fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
-
- // Same as above: disabled/locked user found via fallback — return early.
- if fallbackMatchFound && user.IsErrUserStatusError(err) {
- return u, nil
+ // Same as above: disabled/locked user found via fallback — return early.
+ if fallbackMatchFound && user.IsErrUserStatusError(err) {
+ return u, nil
+ }
+ if fallbackMatchFound {
+ break
+ }
}
}
@@ -507,21 +577,17 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo
return cl, nil
}
-func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.Token, *oidc.IDToken, error) {
-
- cb := &Callback{}
- if err := c.Bind(cb); err != nil {
- return nil, nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Bad data"}
- }
-
- // Check if the provider exists
- providerKey := c.Param("provider")
+// exchangeOidcTokens resolves the provider, exchanges the callback's auth code,
+// and verifies the returned ID token. It takes an already-bound Callback so it
+// can be shared by the v1 echo handler (which binds from the request) and the v2
+// Huma handler (which binds via its typed body).
+func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, string, error) {
provider, err := GetProvider(providerKey)
if err != nil {
- return nil, cb, nil, nil, err
+ return nil, nil, nil, "", err
}
if provider == nil {
- return nil, cb, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
+ return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
}
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
@@ -537,25 +603,25 @@ func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.To
if err := json.Unmarshal(rerr.Body, &details); err != nil {
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
log.Debugf("Raw token value is %s", rerr.Body)
- return nil, cb, nil, nil, err
+ return nil, nil, nil, "", err
}
log.Errorf("Error retrieving token: %s", err)
log.Debugf("Raw token value is %s", rerr.Body)
- return nil, cb, nil, nil, &models.ErrOpenIDBadRequestWithDetails{
+ return nil, nil, nil, "", &models.ErrOpenIDBadRequestWithDetails{
Message: "Could not authenticate against third party.",
Details: details,
}
}
- return nil, cb, nil, nil, err
+ return nil, nil, nil, "", err
}
// Extract the ID Token from OAuth2 token.
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
log.Debugf("Could not get id_token, raw token is %v", oauth2Token)
- return nil, cb, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"}
+ return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Missing token"}
}
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
@@ -564,8 +630,8 @@ func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.To
idToken, err := verifier.Verify(context.Background(), rawIDToken)
if err != nil {
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
- return nil, cb, nil, nil, err
+ return nil, nil, nil, "", err
}
- return provider, cb, oauth2Token, idToken, nil
+ return provider, oauth2Token, idToken, rawIDToken, nil
}
diff --git a/pkg/modules/auth/openid/openid_test.go b/pkg/modules/auth/openid/openid_test.go
index 05ade6745..35bb27547 100644
--- a/pkg/modules/auth/openid/openid_test.go
+++ b/pkg/modules/auth/openid/openid_test.go
@@ -254,11 +254,61 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
+ t.Run("ProviderFallback: Match to existing local user on preferred_username when sub differs", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ cl := &claims{
+ PreferredUsername: "user11",
+ }
+ provider := &Provider{
+ UsernameFallback: true,
+ }
+ // PocketID-style: the subject is an opaque UUID that does not match any local username.
+ idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "c0ffee00-dead-beef-cafe-000000000011"}
+
+ u, err := getOrCreateUser(s, cl, provider, idToken)
+ require.NoError(t, err)
+ err = s.Commit()
+ require.NoError(t, err)
+
+ assert.Equal(t, "user11", u.Username, "should link to the local user matching preferred_username")
+ assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
+ assert.Equal(t, 11, int(u.ID), "user id 11 expected")
+
+ // No duplicate user must be created for the opaque subject.
+ db.AssertMissing(t, "users", map[string]interface{}{
+ "subject": idToken.Subject,
+ })
+ })
+ t.Run("ProviderFallback: Falls back to sub when preferred_username is empty", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ cl := &claims{
+ PreferredUsername: "",
+ }
+ provider := &Provider{
+ UsernameFallback: true,
+ }
+ idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "user11"}
+
+ u, err := getOrCreateUser(s, cl, provider, idToken)
+ require.NoError(t, err)
+ assert.Equal(t, idToken.Subject, u.Username, "subject should match username")
+ assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
+ assert.Equal(t, 11, int(u.ID), "user id 11 expected")
+ })
t.Run("ProviderFallback: Match to existing local user on email", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
+ usersBefore, err := s.Count(&user.User{})
+ require.NoError(t, err)
+
cl := &claims{
Email: "user11@example.com",
}
@@ -272,6 +322,42 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, cl.Email, u.Email, "email should match")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
+
+ // The email-only fallback must link the existing user, not create a duplicate.
+ usersAfter, err := s.Count(&user.User{})
+ require.NoError(t, err)
+ assert.Equal(t, usersBefore, usersAfter, "no new user should have been created")
+ })
+ t.Run("ProviderFallback: empty email claim does not link to an arbitrary local user", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ usersBefore, err := s.Count(&user.User{})
+ require.NoError(t, err)
+
+ // EmailFallback on, no username fallback, and the IdP sent no email claim. The
+ // email-only search must not degenerate to an issuer-only lookup matching an
+ // arbitrary local user. With no email there is nothing safe to match on, so the
+ // flow falls through to user creation (which then errors because an email is
+ // required) rather than silently linking an existing local account.
+ cl := &claims{
+ Email: "",
+ PreferredUsername: "brandNewOidcUser",
+ }
+ provider := &Provider{
+ EmailFallback: true,
+ }
+ idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "opaque-subject-no-email"}
+
+ u, err := getOrCreateUser(s, cl, provider, idToken)
+ // Must not have linked an existing local user.
+ require.Error(t, err, "an empty email must not silently link an existing local user")
+ assert.Nil(t, u, "no existing local user should be returned for an empty email claim")
+
+ usersAfter, err := s.Count(&user.User{})
+ require.NoError(t, err)
+ assert.Equal(t, usersBefore, usersAfter, "no user should have been linked or created from an empty email claim")
})
t.Run("ProviderFallback: Match to existing local user on username and email", func(t *testing.T) {
diff --git a/pkg/modules/auth/openid/providers.go b/pkg/modules/auth/openid/providers.go
index 30f534ad9..710870b7d 100644
--- a/pkg/modules/auth/openid/providers.go
+++ b/pkg/modules/auth/openid/providers.go
@@ -180,6 +180,29 @@ func GetProvider(key string) (provider *Provider, err error) {
return
}
+// getCachedProvider returns the provider from keyvalue without re-establishing
+// the live OIDC connection, so the logout path never blocks on an unreachable OP.
+func getCachedProvider(key string) (provider *Provider, err error) {
+ provider = &Provider{}
+ exists, err := keyvalue.GetWithValue("openid_provider_"+key, provider)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ _, err = GetAllProviders() // This will put all providers in cache
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = keyvalue.GetWithValue("openid_provider_"+key, provider)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return provider, nil
+}
+
// parseBoolField reads a boolean-valued config field from a provider map,
// tolerating both native bools (from YAML/JSON) and strings (from env vars or
// the GetConfigValueFromFile path, which always return strings). Missing or
@@ -313,6 +336,8 @@ func getProviderFromMap(pi map[string]interface{}, key string) (provider *Provid
provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL
+ provider.EndSessionURL = provider.discoveredEndSessionEndpoint()
+
return
}
diff --git a/pkg/modules/background/background.go b/pkg/modules/background/background.go
index 161485bfe..7e48d2d16 100644
--- a/pkg/modules/background/background.go
+++ b/pkg/modules/background/background.go
@@ -24,12 +24,12 @@ import (
// Image represents an image which can be used as a project background
type Image struct {
- ID string `json:"id"`
- URL string `json:"url"`
- Thumb string `json:"thumb,omitempty"`
- BlurHash string `json:"blur_hash"`
+ ID string `json:"id" doc:"The provider-specific id of the image; pass this back to set it as a background."`
+ URL string `json:"url" doc:"The full-size URL of the image."`
+ Thumb string `json:"thumb,omitempty" doc:"A thumbnail URL of the image, if the provider supplies one."`
+ BlurHash string `json:"blur_hash" doc:"A BlurHash placeholder for the image."`
// This can be used to supply extra information from an image provider to clients
- Info interface{} `json:"info,omitempty"`
+ Info interface{} `json:"info,omitempty" doc:"Provider-specific extra information about the image (e.g. the Unsplash author for attribution)."`
}
const MaxBackgroundImageHeight = 3840
diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go
index a89784ee9..da9d0e522 100644
--- a/pkg/modules/background/handler/background.go
+++ b/pkg/modules/background/handler/background.go
@@ -31,6 +31,7 @@ import (
"image"
"io"
"net/http"
+ "os"
"strconv"
"strings"
@@ -43,6 +44,7 @@ import (
"code.vikunja.io/api/pkg/modules/background/unsplash"
"code.vikunja.io/api/pkg/modules/background/upload"
"code.vikunja.io/api/pkg/web"
+ webfiles "code.vikunja.io/api/pkg/web/files"
"github.com/bbrks/go-blurhash"
"github.com/gabriel-vasile/mimetype"
@@ -204,44 +206,17 @@ func (bp *BackgroundProvider) UploadBackground(c *echo.Context) error {
}
defer srcf.Close()
- // Validate we're dealing with an image
- mime, err := mimetype.DetectReader(srcf)
- if err != nil {
+ if err := ValidateAndSaveBackgroundUpload(s, auth, project, srcf, file.Filename, uint64(file.Size)); err != nil {
_ = s.Rollback()
- return err
- }
- if !strings.HasPrefix(mime.String(), "image") {
- _ = s.Rollback()
- return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."})
- }
- supported := false
- for _, m := range allowedImageMimes {
- if mime.Is(m) {
- supported = true
- break
+ if IsErrFileIsNoImage(err) {
+ return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."})
}
- }
- if !supported {
- _ = s.Rollback()
- return c.JSON(http.StatusBadRequest, models.Message{Message: "Unsupported image format. Allowed: " + strings.Join(allowedImageMimes, ",")})
- }
-
- err = SaveBackgroundFile(s, auth, project, srcf, file.Filename, uint64(file.Size))
- if err != nil {
- _ = s.Rollback()
if files.IsErrFileIsTooLarge(err) {
return echo.ErrBadRequest
}
if IsErrFileUnsupportedImageFormat(err) {
return c.JSON(http.StatusBadRequest, models.Message{Message: "Unsupported image format. Allowed: " + strings.Join(allowedImageMimes, ",")})
}
-
- return err
- }
-
- err = project.ReadOne(s, auth)
- if err != nil {
- _ = s.Rollback()
return err
}
@@ -253,6 +228,41 @@ func (bp *BackgroundProvider) UploadBackground(c *echo.Context) error {
return c.JSON(http.StatusOK, project)
}
+// ValidateAndSaveBackgroundUpload validates that srcf is a decodable image of an
+// allowed type, stores it as the project's background and reloads the project so
+// callers get the updated background metadata. It is the shared body of the v1 and
+// v2 upload handlers; the multipart parsing and error-to-HTTP mapping stay in each
+// handler. project must already be loaded and the caller must have verified write
+// permission. On a non-image it returns ErrFileIsNoImage; on a recognized but
+// undecodable format ErrFileUnsupportedImageFormat.
+func ValidateAndSaveBackgroundUpload(s *xorm.Session, auth web.Auth, project *models.Project, srcf io.ReadSeeker, filename string, filesize uint64) error {
+ mime, err := mimetype.DetectReader(srcf)
+ if err != nil {
+ return err
+ }
+ if !strings.HasPrefix(mime.String(), "image") {
+ return ErrFileIsNoImage{Mime: mime.String()}
+ }
+ supported := false
+ for _, m := range allowedImageMimes {
+ if mime.Is(m) {
+ supported = true
+ break
+ }
+ }
+ if !supported {
+ return ErrFileUnsupportedImageFormat{Mime: mime.String()}
+ }
+
+ // DetectReader consumed the head of the reader; SaveBackgroundFile seeks back to
+ // the start itself, so no rewind is needed here.
+ if err := SaveBackgroundFile(s, auth, project, srcf, filename, filesize); err != nil {
+ return err
+ }
+
+ return project.ReadOne(s, auth)
+}
+
func SaveBackgroundFile(s *xorm.Session, auth web.Auth, project *models.Project, srcf io.ReadSeeker, filename string, filesize uint64) (err error) {
mime, _ := mimetype.DetectReader(srcf)
_, _ = srcf.Seek(0, io.SeekStart)
@@ -377,54 +387,47 @@ func GetProjectBackground(c *echo.Context) error {
return err
}
- if project.BackgroundFileID == 0 {
- _ = s.Rollback()
- return echo.NewHTTPError(http.StatusNotFound, "Project background not found")
- }
-
- // Get the file
- bgFile := &files.File{
- ID: project.BackgroundFileID,
- }
- if err := bgFile.LoadFileByID(); err != nil {
- _ = s.Rollback()
- return err
- }
- stat, err := files.FileStat(bgFile)
+ bgFile, stat, err := LoadProjectBackgroundForDownload(s, project)
if err != nil {
_ = s.Rollback()
+ if models.IsErrProjectHasNoBackground(err) {
+ return echo.NewHTTPError(http.StatusNotFound, "Project background not found")
+ }
return err
}
- // Unsplash requires pingbacks as per their api usage guidelines.
- // To do this in a privacy-preserving manner, we do the ping from inside of Vikunja to not expose any user details.
- // FIXME: This should use an event once we have events
- unsplash.Pingback(s, bgFile)
-
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
- // Override the global no-store directive so browsers can cache background images.
- // no-cache allows caching but requires revalidation via If-Modified-Since.
- c.Response().Header().Set("Cache-Control", "no-cache")
+ webfiles.WriteProjectBackground(c.Response(), c.Request(), bgFile, stat)
+ return nil
+}
- // Set Last-Modified header if we have the file stat, so clients can decide whether to use cached files
- if stat != nil {
- modTime := stat.ModTime().UTC()
- c.Response().Header().Set(echo.HeaderLastModified, modTime.Format(http.TimeFormat))
-
- // Check If-Modified-Since and return 304 if the file hasn't changed
- if ifModSince := c.Request().Header.Get("If-Modified-Since"); ifModSince != "" {
- if t, err := http.ParseTime(ifModSince); err == nil && !modTime.After(t) {
- return c.NoContent(http.StatusNotModified)
- }
- }
+// LoadProjectBackgroundForDownload opens the project's background file (bytes ready to
+// read) and stats it for the modtime the download uses for caching. It also fires the
+// Unsplash pingback side effect, required by Unsplash's API guidelines and done
+// server-side so no user details are exposed. Returns ErrProjectHasNoBackground when the
+// project has none; the caller owns committing the session and closing bgFile.File.
+func LoadProjectBackgroundForDownload(s *xorm.Session, project *models.Project) (bgFile *files.File, stat os.FileInfo, err error) {
+ if project.BackgroundFileID == 0 {
+ return nil, nil, &models.ErrProjectHasNoBackground{ProjectID: project.ID}
}
- // Serve the file
- return c.Stream(http.StatusOK, "image/jpg", bgFile.File)
+ bgFile = &files.File{ID: project.BackgroundFileID}
+ if err := bgFile.LoadFileByID(); err != nil {
+ return nil, nil, err
+ }
+ stat, err = files.FileStat(bgFile)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // FIXME: This should use an event once we have events
+ unsplash.Pingback(s, bgFile)
+
+ return bgFile, stat, nil
}
// RemoveProjectBackground removes a project background, no matter the background provider
diff --git a/pkg/modules/background/handler/errors.go b/pkg/modules/background/handler/errors.go
index beaf46657..dcddf1687 100644
--- a/pkg/modules/background/handler/errors.go
+++ b/pkg/modules/background/handler/errors.go
@@ -40,3 +40,22 @@ func IsErrFileUnsupportedImageFormat(err error) bool {
ok := errors.As(err, &errFileUnsupportedImageFormat)
return ok
}
+
+// ErrFileIsNoImage is returned when an uploaded background does not sniff as an
+// image at all (its detected mime type does not start with "image"). It is
+// distinct from ErrFileUnsupportedImageFormat, which is a recognized image type
+// the imaging library can't decode.
+type ErrFileIsNoImage struct {
+ Mime string
+}
+
+// Error is the error implementation of ErrFileIsNoImage
+func (err ErrFileIsNoImage) Error() string {
+ return fmt.Sprintf("uploaded file is not an image [Mime: %s]", err.Mime)
+}
+
+// IsErrFileIsNoImage checks if an error is ErrFileIsNoImage
+func IsErrFileIsNoImage(err error) bool {
+ var errFileIsNoImage ErrFileIsNoImage
+ return errors.As(err, &errFileIsNoImage)
+}
diff --git a/pkg/modules/background/unsplash/proxy.go b/pkg/modules/background/unsplash/proxy.go
index 69fb23716..1d0b11607 100644
--- a/pkg/modules/background/unsplash/proxy.go
+++ b/pkg/modules/background/unsplash/proxy.go
@@ -18,32 +18,95 @@ package unsplash
import (
"context"
+ "errors"
+ "io"
"net/http"
"strings"
"code.vikunja.io/api/pkg/utils"
+ "code.vikunja.io/api/pkg/web"
"github.com/labstack/echo/v5"
)
-func unsplashImage(url string, c *echo.Context) error {
+// ErrUnsplashImageDoesNotExist is returned when Unsplash answers an image proxy fetch
+// with a non-success status, mirroring v1's echo.ErrNotFound. It satisfies
+// web.HTTPErrorProcessor so the v2 error bridge maps it to a 404.
+type ErrUnsplashImageDoesNotExist struct{}
+
+// IsErrUnsplashImageDoesNotExist checks if an error is ErrUnsplashImageDoesNotExist.
+func IsErrUnsplashImageDoesNotExist(err error) bool {
+ var target *ErrUnsplashImageDoesNotExist
+ return errors.As(err, &target)
+}
+
+func (err *ErrUnsplashImageDoesNotExist) Error() string {
+ return "Unsplash image does not exist"
+}
+
+// HTTPError holds the http error description.
+func (err *ErrUnsplashImageDoesNotExist) HTTPError() web.HTTPError {
+ return web.HTTPError{HTTPCode: http.StatusNotFound, Message: "Not Found"}
+}
+
+// fetchUnsplashImage fetches an image from Unsplash through the SSRF-safe client and
+// returns its still-open response body for the caller to stream and close. The url is
+// rebased onto the hardcoded images.unsplash.com host (stripping any client-supplied
+// host) so the proxy can only ever reach Unsplash. It returns
+// ErrUnsplashImageDoesNotExist on a non-success upstream status.
+func fetchUnsplashImage(url string) (io.ReadCloser, error) {
// Replacing and appending the url for security reasons
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://images.unsplash.com/"+strings.Replace(url, "https://images.unsplash.com/", "", 1), nil)
if err != nil {
- return err
+ return nil, err
}
resp, err := utils.NewSSRFSafeHTTPClient().Do(req) //nolint:gosec // SSRF protection is handled by the SSRF-safe client
if err != nil {
- return err
+ return nil, err
}
- defer resp.Body.Close()
if resp.StatusCode > 399 {
- return echo.ErrNotFound
+ _ = resp.Body.Close()
+ return nil, &ErrUnsplashImageDoesNotExist{}
}
- return c.Stream(http.StatusOK, "image/jpg", resp.Body)
+ return resp.Body, nil
}
-// ProxyUnsplashImage proxies a thumbnail from unsplash for privacy reasons.
+// FetchUnsplashImageByID resolves an Unsplash image by id, fires the required pingback,
+// and returns the full-resolution image body for the caller to stream and close.
+func FetchUnsplashImageByID(imageID string) (io.ReadCloser, error) {
+ photo, err := getUnsplashPhotoInfoByID(imageID)
+ if err != nil {
+ return nil, err
+ }
+ pingbackByPhotoID(photo.ID)
+ return fetchUnsplashImage(photo.Urls.Raw)
+}
+
+// FetchUnsplashThumbByID resolves an Unsplash image by id, fires the required pingback,
+// and returns a thumbnail (max width 200px) body for the caller to stream and close.
+func FetchUnsplashThumbByID(imageID string) (io.ReadCloser, error) {
+ photo, err := getUnsplashPhotoInfoByID(imageID)
+ if err != nil {
+ return nil, err
+ }
+ pingbackByPhotoID(photo.ID)
+ return fetchUnsplashImage("https://images.unsplash.com/" + getImageID(photo.Urls.Raw) + "?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ")
+}
+
+// streamUnsplashImage streams a fetched image body to the v1 echo response, mapping the
+// not-found sentinel back to echo.ErrNotFound so v1's wire response is unchanged.
+func streamUnsplashImage(body io.ReadCloser, err error, c *echo.Context) error {
+ if err != nil {
+ if IsErrUnsplashImageDoesNotExist(err) {
+ return echo.ErrNotFound
+ }
+ return err
+ }
+ defer body.Close()
+ return c.Stream(http.StatusOK, "image/jpg", body)
+}
+
+// ProxyUnsplashImage proxies an image from unsplash for privacy reasons.
// @Summary Get an unsplash image
// @Description Get an unsplash image. **Returns json on error.**
// @tags project
@@ -55,12 +118,8 @@ func unsplashImage(url string, c *echo.Context) error {
// @Failure 500 {object} models.Message "Internal error"
// @Router /backgrounds/unsplash/image/{image} [get]
func ProxyUnsplashImage(c *echo.Context) error {
- photo, err := getUnsplashPhotoInfoByID(c.Param("image"))
- if err != nil {
- return err
- }
- pingbackByPhotoID(photo.ID)
- return unsplashImage(photo.Urls.Raw, c)
+ body, err := FetchUnsplashImageByID(c.Param("image"))
+ return streamUnsplashImage(body, err, c)
}
// ProxyUnsplashThumb proxies a thumbnail from unsplash for privacy reasons.
@@ -75,10 +134,6 @@ func ProxyUnsplashImage(c *echo.Context) error {
// @Failure 500 {object} models.Message "Internal error"
// @Router /backgrounds/unsplash/image/{image}/thumb [get]
func ProxyUnsplashThumb(c *echo.Context) error {
- photo, err := getUnsplashPhotoInfoByID(c.Param("image"))
- if err != nil {
- return err
- }
- pingbackByPhotoID(photo.ID)
- return unsplashImage("https://images.unsplash.com/"+getImageID(photo.Urls.Raw)+"?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ", c)
+ body, err := FetchUnsplashThumbByID(c.Param("image"))
+ return streamUnsplashImage(body, err, c)
}
diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go
index 0e9c9b942..d59dd6946 100644
--- a/pkg/modules/migration/create_from_structure.go
+++ b/pkg/modules/migration/create_from_structure.go
@@ -18,6 +18,7 @@ package migration
import (
"bytes"
+ "context"
"xorm.io/xorm"
@@ -50,7 +51,7 @@ func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.Us
return err
}
- events.DispatchPending(s)
+ events.DispatchPending(context.Background(), s)
return nil
}
diff --git a/pkg/modules/migration/csv/csv.go b/pkg/modules/migration/csv/csv.go
index a0bee4f08..6d9ded38d 100644
--- a/pkg/modules/migration/csv/csv.go
+++ b/pkg/modules/migration/csv/csv.go
@@ -107,28 +107,28 @@ var AllTaskAttributes = []TaskAttribute{
// ColumnMapping represents a mapping from a CSV column to a task attribute
type ColumnMapping struct {
- ColumnIndex int `json:"column_index"`
- ColumnName string `json:"column_name"`
- Attribute TaskAttribute `json:"attribute"`
+ ColumnIndex int `json:"column_index" doc:"The zero-based index of the CSV column this mapping applies to."`
+ ColumnName string `json:"column_name" doc:"The header name of the CSV column, for display."`
+ Attribute TaskAttribute `json:"attribute" enum:"title,description,due_date,start_date,end_date,done,priority,labels,project,reminder,ignore" doc:"The task attribute the column maps to. Use \"ignore\" to drop the column."`
}
// DetectionResult contains the auto-detected CSV structure
type DetectionResult struct {
- Columns []string `json:"columns"`
- Delimiter string `json:"delimiter"`
- QuoteChar string `json:"quote_char"`
- DateFormat string `json:"date_format"`
- SuggestedMapping []ColumnMapping `json:"suggested_mapping"`
- PreviewRows [][]string `json:"preview_rows"`
+ Columns []string `json:"columns" doc:"The detected column header names, in order."`
+ Delimiter string `json:"delimiter" doc:"The detected field delimiter (one of \",\", \";\", tab, \"|\")."`
+ QuoteChar string `json:"quote_char" doc:"The detected quote character."`
+ DateFormat string `json:"date_format" doc:"The detected Go reference date layout used to parse date columns."`
+ SuggestedMapping []ColumnMapping `json:"suggested_mapping" doc:"A best-guess column-to-attribute mapping; the client may edit it before previewing or migrating."`
+ PreviewRows [][]string `json:"preview_rows" doc:"The first few raw rows of the file, for the client to render a preview."`
}
// ImportConfig contains the configuration for CSV import
type ImportConfig struct {
- Delimiter string `json:"delimiter"`
- QuoteChar string `json:"quote_char"`
- DateFormat string `json:"date_format"`
- SkipRows int `json:"skip_rows"`
- Mapping []ColumnMapping `json:"mapping"`
+ Delimiter string `json:"delimiter" doc:"The field delimiter to parse with. Defaults to comma when empty."`
+ QuoteChar string `json:"quote_char" doc:"The quote character to parse with."`
+ DateFormat string `json:"date_format" doc:"The Go reference date layout used to parse date columns."`
+ SkipRows int `json:"skip_rows" doc:"Number of leading rows to skip (e.g. a header row) before importing."`
+ Mapping []ColumnMapping `json:"mapping" doc:"The column-to-attribute mappings that drive the import."`
}
// PreviewTask represents a task preview before import
@@ -146,8 +146,8 @@ type PreviewTask struct {
// PreviewResult contains preview data before import
type PreviewResult struct {
- Tasks []PreviewTask `json:"tasks"`
- TotalRows int `json:"total_rows"`
+ Tasks []PreviewTask `json:"tasks" doc:"The first few tasks that would be imported with the given config."`
+ TotalRows int `json:"total_rows" doc:"The total number of data rows in the file."`
}
// stripBOM removes the UTF-8 BOM from the beginning of a reader
@@ -557,6 +557,22 @@ func (m *Migrator) Migrate(_ *user.User, _ io.ReaderAt, _ int64) error {
return &migration.ErrCSVConfigRequired{}
}
+// RunMigration records the migration's start, imports the CSV with the given
+// config and records its finish. Shared by the v1 and v2 HTTP layers so the
+// status bookkeeping around MigrateWithConfig lives in one place.
+func RunMigration(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error {
+ status, err := migration.StartMigration(&Migrator{}, u)
+ if err != nil {
+ return err
+ }
+
+ if err := MigrateWithConfig(u, file, size, config); err != nil {
+ return err
+ }
+
+ return migration.FinishMigration(status)
+}
+
// MigrateWithConfig imports CSV data into Vikunja with the provided configuration
func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error {
if size == 0 {
diff --git a/pkg/modules/migration/csv/handler.go b/pkg/modules/migration/csv/handler.go
index 389c13573..1a99c342d 100644
--- a/pkg/modules/migration/csv/handler.go
+++ b/pkg/modules/migration/csv/handler.go
@@ -186,19 +186,7 @@ func (c *MigratorWeb) Migrate(ctx *echo.Context) error {
}
defer src.Close()
- m := &Migrator{}
- status, err := migration.StartMigration(m, u)
- if err != nil {
- return err
- }
-
- err = MigrateWithConfig(u, src, file.Size, &config)
- if err != nil {
- return err
- }
-
- err = migration.FinishMigration(status)
- if err != nil {
+ if err := RunMigration(u, src, file.Size, &config); err != nil {
return err
}
diff --git a/pkg/modules/migration/errors.go b/pkg/modules/migration/errors.go
index 3129c5da2..eef789c39 100644
--- a/pkg/modules/migration/errors.go
+++ b/pkg/modules/migration/errors.go
@@ -18,10 +18,33 @@ package migration
import (
"net/http"
+ "time"
"code.vikunja.io/api/pkg/web"
)
+// ErrMigrationAlreadyRunning is returned when a migration is started for a user
+// who already has one in progress (started but not yet finished).
+type ErrMigrationAlreadyRunning struct {
+ StartedAt time.Time
+}
+
+func (err *ErrMigrationAlreadyRunning) Error() string {
+ return "Migration already running"
+}
+
+// ErrCodeMigrationAlreadyRunning holds the unique world-error code of this error
+const ErrCodeMigrationAlreadyRunning = 14005
+
+// HTTPError holds the http error description
+func (err *ErrMigrationAlreadyRunning) HTTPError() web.HTTPError {
+ return web.HTTPError{
+ HTTPCode: http.StatusPreconditionFailed,
+ Code: ErrCodeMigrationAlreadyRunning,
+ Message: "Migration already running",
+ }
+}
+
// ErrNotAZipFile represents a "ErrNotAZipFile" kind of error.
type ErrNotAZipFile struct{}
diff --git a/pkg/modules/migration/handler/handler.go b/pkg/modules/migration/handler/handler.go
index 840dbacd6..5ef52d747 100644
--- a/pkg/modules/migration/handler/handler.go
+++ b/pkg/modules/migration/handler/handler.go
@@ -39,7 +39,7 @@ type MigrationWeb struct {
// AuthURL is returned to the user when requesting the auth url
type AuthURL struct {
- URL string `json:"url"`
+ URL string `json:"url" readOnly:"true" doc:"The OAuth authorization url the client should redirect the user to. After authorizing, the obtained code is passed back to the migrate endpoint."`
}
// RegisterMigrator registers all routes for migration
@@ -57,6 +57,28 @@ func (mw *MigrationWeb) AuthURL(c *echo.Context) error {
return c.JSON(http.StatusOK, &AuthURL{URL: ms.AuthURL()})
}
+// StartMigration kicks off a migration for the given user: it refuses with
+// migration.ErrMigrationAlreadyRunning if one is already in progress, then
+// dispatches the MigrationRequestedEvent that runs the migration asynchronously.
+// The migrator must already carry its request payload (e.g. the OAuth code).
+// Shared by the v1 and v2 HTTP layers so the orchestration lives in one place.
+func StartMigration(ms migration.Migrator, u *user2.User) error {
+ stats, err := migration.GetMigrationStatus(ms, u)
+ if err != nil {
+ return err
+ }
+
+ if !stats.StartedAt.IsZero() && stats.FinishedAt.IsZero() {
+ return &migration.ErrMigrationAlreadyRunning{StartedAt: stats.StartedAt}
+ }
+
+ return events.Dispatch(&MigrationRequestedEvent{
+ Migrator: ms,
+ MigratorKind: ms.Name(),
+ User: u,
+ })
+}
+
// Migrate calls the migration method
func (mw *MigrationWeb) Migrate(c *echo.Context) error {
ms := mw.MigrationStruct()
@@ -85,12 +107,7 @@ func (mw *MigrationWeb) Migrate(c *echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "No or invalid model provided: "+err.Error()).Wrap(err)
}
- err = events.Dispatch(&MigrationRequestedEvent{
- Migrator: ms,
- MigratorKind: ms.Name(),
- User: user,
- })
- if err != nil {
+ if err := StartMigration(ms, user); err != nil {
return err
}
diff --git a/pkg/modules/migration/handler/handler_file.go b/pkg/modules/migration/handler/handler_file.go
index 8fae1d775..76b7f4d13 100644
--- a/pkg/modules/migration/handler/handler_file.go
+++ b/pkg/modules/migration/handler/handler_file.go
@@ -17,6 +17,7 @@
package handler
import (
+ "io"
"net/http"
"code.vikunja.io/api/pkg/models"
@@ -36,6 +37,22 @@ func (fw *FileMigratorWeb) RegisterRoutes(g *echo.Group) {
g.PUT("/"+ms.Name()+"/migrate", fw.Migrate)
}
+// RunFileMigration records the migration's start, runs the file migrator and
+// records its finish. Shared by the v1 and v2 HTTP layers so the orchestration
+// lives in one place; the caller supplies the already-opened upload.
+func RunFileMigration(ms migration.FileMigrator, u *user2.User, file io.ReaderAt, size int64) error {
+ m, err := migration.StartMigration(ms, u)
+ if err != nil {
+ return err
+ }
+
+ if err := ms.Migrate(u, file, size); err != nil {
+ return err
+ }
+
+ return migration.FinishMigration(m)
+}
+
// Migrate calls the migration method
func (fw *FileMigratorWeb) Migrate(c *echo.Context) error {
ms := fw.MigrationStruct()
@@ -56,19 +73,7 @@ func (fw *FileMigratorWeb) Migrate(c *echo.Context) error {
}
defer src.Close()
- m, err := migration.StartMigration(ms, user)
- if err != nil {
- return err
- }
-
- // Do the migration
- err = ms.Migrate(user, src, file.Size)
- if err != nil {
- return err
- }
-
- err = migration.FinishMigration(m)
- if err != nil {
+ if err := RunFileMigration(ms, user, src, file.Size); err != nil {
return err
}
diff --git a/pkg/modules/migration/migration_status.go b/pkg/modules/migration/migration_status.go
index a967c950c..419ac880c 100644
--- a/pkg/modules/migration/migration_status.go
+++ b/pkg/modules/migration/migration_status.go
@@ -25,11 +25,11 @@ import (
// Status represents this migration status
type Status struct {
- ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
+ ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this migration status."`
UserID int64 `xorm:"bigint not null" json:"-"`
- MigratorName string `xorm:"varchar(255)" json:"migrator_name"`
- StartedAt time.Time `xorm:"not null" json:"started_at"`
- FinishedAt time.Time `xorm:"null" json:"finished_at"`
+ MigratorName string `xorm:"varchar(255)" json:"migrator_name" readOnly:"true" doc:"The name of the migrator this status belongs to, e.g. \"todoist\"."`
+ StartedAt time.Time `xorm:"not null" json:"started_at" readOnly:"true" doc:"When the last migration started. Zero value if the user never migrated from this service."`
+ FinishedAt time.Time `xorm:"null" json:"finished_at" readOnly:"true" doc:"When the last migration finished. Zero value while a migration is still running or was never run."`
}
// TableName holds the table name for the migration status table
diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go
index 292927c80..7749e1d9c 100644
--- a/pkg/notifications/mail_render.go
+++ b/pkg/notifications/mail_render.go
@@ -20,6 +20,7 @@ import (
"bytes"
"embed"
templatehtml "html/template"
+ "net/url"
"regexp"
"strings"
templatetext "text/template"
@@ -187,16 +188,26 @@ const mailTemplateConversationalHTML = `
//go:embed logo.png
var logo embed.FS
-func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
+// newNotificationSanitizer builds the bluemonday policy for all HTML in notification
+// emails. Only inline data-URI images (avatars) are allowed: RewriteSrc blanks any
+// remote image src so a user-controlled task title, comment or description can't
+// smuggle a tracking pixel into a recipient's inbox.
+func newNotificationSanitizer() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
- // Allow data URI images for inline avatars in mentions
p.AllowDataURIImages()
- // Allow style attribute on img and div elements for avatar and layout styling
p.AllowAttrs("style").OnElements("img", "div")
- // Allow specific CSS properties for avatar styling
p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
- // Allow padding styles on div elements for content spacing
p.AllowStyles("padding-top", "margin-bottom").OnElements("div")
+ p.RewriteSrc(func(u *url.URL) {
+ if u.Scheme != "data" {
+ *u = url.URL{}
+ }
+ })
+ return p
+}
+
+func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
+ p := newNotificationSanitizer()
for _, line := range lines {
if line.isHTML {
@@ -225,11 +236,7 @@ func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err e
// sanitizeLinesToHTML sanitizes lines without wrapping in
tags or adding margins.
// Used for footer lines and other content that should not have paragraph styling.
func sanitizeLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
- p := bluemonday.UGCPolicy()
- p.AllowDataURIImages()
- p.AllowAttrs("style").OnElements("img", "div")
- p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
- p.AllowStyles("padding-top", "margin-bottom").OnElements("div")
+ p := newNotificationSanitizer()
for _, line := range lines {
if line.isHTML {
@@ -366,12 +373,8 @@ func RenderMail(m *Mail, lang string) (mailOpts *mail.Opts, err error) {
data["CopyURLText"] = i18n.T(lang, "notifications.common.copy_url")
if m.headerLine != nil {
- p := bluemonday.UGCPolicy()
- p.AllowDataURIImages()
- p.AllowAttrs("style").OnElements("img", "div")
- p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
// #nosec G203 -- the html is sanitized
- data["HeaderLineHTML"] = templatehtml.HTML(p.Sanitize(m.headerLine.Text))
+ data["HeaderLineHTML"] = templatehtml.HTML(newNotificationSanitizer().Sanitize(m.headerLine.Text))
}
data["IntroLinesHTML"], err = convertLinesToHTML(m.introLines)
diff --git a/pkg/notifications/mail_test.go b/pkg/notifications/mail_test.go
index fca5c6447..d8f5db2e4 100644
--- a/pkg/notifications/mail_test.go
+++ b/pkg/notifications/mail_test.go
@@ -711,3 +711,55 @@ func TestConversationalMail(t *testing.T) {
assert.Contains(t, headerLine1, "(Project > Task) #1")
})
}
+
+// Regression test for GHSA-2vr2-r3qw-rjvq: a user-controlled task title (or comment
+// or description) must not be able to smuggle a remote image into a notification
+// email, where it would act as a tracking pixel. Inline data-URI avatars and normal
+// links must keep working.
+func TestNotificationEmailStripsRemoteImages(t *testing.T) {
+ const remoteSrc = "https://attacker.example/track.png?u=victim"
+
+ t.Run("remote image injected via task title in header is stripped", func(t *testing.T) {
+ payloadTitle := `
normal title`
+ header := CreateConversationalHeader("", "attacker left a comment", "https://example.com/task/1", "Project", "#1", payloadTitle)
+
+ mailOpts, err := RenderMail(NewMail().
+ Conversational().
+ Subject("Test").
+ HeaderLine(header).
+ Action("View Task", "https://example.com/task/1"), "en")
+ require.NoError(t, err)
+
+ assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc)
+ assert.NotContains(t, mailOpts.HTMLMessage, "attacker.example")
+ // The benign text is still delivered, and the legitimate task link survives.
+ assert.Contains(t, mailOpts.HTMLMessage, "normal title")
+ assert.Contains(t, mailOpts.HTMLMessage, `href="https://example.com/task/1"`)
+ })
+
+ t.Run("remote image in body content is stripped", func(t *testing.T) {
+ mailOpts, err := RenderMail(NewMail().
+ Conversational().
+ Subject("Test").
+ HTML(`hi
`).
+ Action("View Task", "https://example.com/task/1"), "en")
+ require.NoError(t, err)
+
+ assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc)
+ assert.Contains(t, mailOpts.HTMLMessage, "hi")
+ })
+
+ t.Run("inline data-URI avatar is preserved", func(t *testing.T) {
+ const avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+ header := CreateConversationalHeader(avatar, "alice left a comment", "https://example.com/task/1", "Project", "#1", "Task")
+
+ mailOpts, err := RenderMail(NewMail().
+ Conversational().
+ Subject("Test").
+ HeaderLine(header).
+ Action("View Task", "https://example.com/task/1"), "en")
+ require.NoError(t, err)
+
+ assert.Contains(t, mailOpts.HTMLMessage, "data:image/png;base64,")
+ })
+}
diff --git a/pkg/routes/api/shared/admin_user.go b/pkg/routes/api/shared/admin_user.go
new file mode 100644
index 000000000..4459f2698
--- /dev/null
+++ b/pkg/routes/api/shared/admin_user.go
@@ -0,0 +1,64 @@
+// 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 shared
+
+import (
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/user"
+)
+
+// AdminUser re-exposes fields hidden by the default user.User JSON view.
+type AdminUser struct {
+ *user.User
+ IsAdmin bool `json:"is_admin" readOnly:"true" doc:"Whether the user is an instance admin."`
+ Status user.Status `json:"status" readOnly:"true" doc:"Account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked)."`
+ Issuer string `json:"issuer" readOnly:"true" doc:"Authentication issuer; empty or 'local' for local accounts."`
+ Subject string `json:"subject,omitempty" readOnly:"true" doc:"External subject identifier, for non-local accounts."`
+ AuthProvider string `json:"auth_provider,omitempty" readOnly:"true" doc:"Resolved auth provider name (e.g. 'LDAP' or an OIDC provider), empty for local accounts."`
+}
+
+// NewAdminUser builds the admin-facing user view, resolving the auth-provider
+// display name from the configured OIDC providers.
+func NewAdminUser(u *user.User, providers []*openid.Provider) *AdminUser {
+ return &AdminUser{
+ User: u,
+ IsAdmin: u.IsAdmin,
+ Status: u.Status,
+ Issuer: u.Issuer,
+ Subject: u.Subject,
+ AuthProvider: resolveAuthProvider(u, providers),
+ }
+}
+
+func resolveAuthProvider(u *user.User, providers []*openid.Provider) string {
+ switch u.Issuer {
+ case "", user.IssuerLocal:
+ return ""
+ case user.IssuerLDAP:
+ return "LDAP"
+ }
+ for _, provider := range providers {
+ issuerURL, err := provider.Issuer()
+ if err != nil {
+ continue
+ }
+ if issuerURL == u.Issuer {
+ return provider.Name
+ }
+ }
+ return u.Issuer
+}
diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go
new file mode 100644
index 000000000..560ae0f47
--- /dev/null
+++ b/pkg/routes/api/shared/auth.go
@@ -0,0 +1,343 @@
+// 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 shared
+
+import (
+ "context"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/metrics"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/modules/auth/ldap"
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/modules/keyvalue"
+ "code.vikunja.io/api/pkg/user"
+
+ "xorm.io/xorm"
+)
+
+// UserRegister carries the fields accepted by the public registration endpoint:
+// username, password and email (from APIUserPassword) plus the new user's
+// preferred language.
+type UserRegister struct {
+ // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.
+ Language string `json:"language" valid:"language" doc:"The language of the new user as an IETF BCP 47 code (e.g. en, de-DE)."`
+ user.APIUserPassword
+}
+
+// RegisterUser creates a new local user account from the registration input and
+// busts the cached user-count metric so the registration shows up immediately.
+// The caller is responsible for the registration-enabled gate and input
+// validation; both v1 and v2 share this body.
+func RegisterUser(ctx context.Context, in *UserRegister) (*user.User, error) {
+ s := db.NewSession()
+ defer s.Close()
+ // Discards events queued during a rolled-back transaction; a no-op once
+ // DispatchPending has run.
+ defer events.CleanupPending(s)
+
+ newUser, err := models.RegisterUser(s, &user.User{
+ Username: in.Username,
+ Password: in.Password,
+ Email: in.Email,
+ Language: in.Language,
+ })
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ events.DispatchPending(ctx, s)
+
+ // Bust the cached user count so the new registration shows up in metrics
+ // immediately instead of after the regular cache expiry.
+ if config.MetricsEnabled.GetBool() {
+ if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil {
+ log.Errorf("Could not invalidate user count metric: %s", err)
+ }
+ }
+
+ return newUser, nil
+}
+
+// AuthenticateUserCredentials verifies a login against local (and, if configured,
+// LDAP) credentials and enforces the account-status and TOTP gates, returning the
+// authenticated user on success. It is the transport-agnostic core of the login
+// flow shared by v1 and v2; the caller issues the token and sets the cookie. The
+// returned errors carry their own HTTP semantics (wrong credentials, disabled
+// account, missing/invalid TOTP) so both APIs surface them identically.
+func AuthenticateUserCredentials(ctx context.Context, login *user.Login) (*user.User, error) {
+ s := db.NewSession()
+ defer s.Close()
+ // Discards events queued during a rolled-back transaction (e.g. LDAP user
+ // creation); a no-op once DispatchPending has run.
+ defer events.CleanupPending(s)
+
+ u, err := resolveLoginUser(ctx, s, login)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if u.Status == user.StatusDisabled {
+ _ = s.Rollback()
+ return nil, &user.ErrAccountDisabled{UserID: u.ID}
+ }
+ if u.Status == user.StatusAccountLocked {
+ _ = s.Rollback()
+ return nil, &user.ErrAccountLocked{UserID: u.ID}
+ }
+
+ if err := enforceLoginTOTP(s, u, login.TOTPPasscode); err != nil {
+ return nil, err
+ }
+
+ if err := keyvalue.Del(u.GetFailedTOTPAttemptsKey()); err != nil {
+ return nil, err
+ }
+ if err := keyvalue.Del(u.GetFailedPasswordAttemptsKey()); err != nil {
+ return nil, err
+ }
+
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ events.DispatchPending(ctx, s)
+
+ return u, nil
+}
+
+// resolveLoginUser authenticates the credentials against LDAP (when enabled) and
+// then against local accounts, mirroring v1's order so local users keep working
+// alongside LDAP. Bots are rejected before bcrypt runs because they have no
+// password hash.
+func resolveLoginUser(ctx context.Context, s *xorm.Session, login *user.Login) (*user.User, error) {
+ if config.AuthLdapEnabled.GetBool() {
+ u, err := ldap.AuthenticateUserInLDAP(s, login.Username, login.Password, config.AuthLdapGroupSyncEnabled.GetBool(), config.AuthLdapAvatarSyncAttribute.GetString())
+ if err != nil && !user.IsErrWrongUsernameOrPassword(err) {
+ return nil, err
+ }
+ if u != nil {
+ return u, nil
+ }
+ }
+
+ existingUser, lookupErr := user.GetUserByUsername(s, login.Username)
+ if lookupErr == nil && existingUser.IsBot() {
+ return nil, &user.ErrAccountIsBot{UserID: existingUser.ID}
+ }
+
+ return user.CheckUserCredentials(ctx, s, login)
+}
+
+// enforceLoginTOTP runs the TOTP gate for users who have it enabled, mirroring
+// v1: a missing passcode is rejected, and a wrong one trips the failed-attempt
+// lockout via HandleFailedTOTPAuth. The session is rolled back before
+// HandleFailedTOTPAuth so its dedicated session can acquire a write lock on
+// SQLite shared-cache (the lockout write is decoupled from this transaction —
+// see GHSA-fgfv-pv97-6cmj).
+func enforceLoginTOTP(s *xorm.Session, u *user.User, passcode string) error {
+ totpEnabled, err := user.TOTPEnabledForUser(s, u)
+ if err != nil {
+ _ = s.Rollback()
+ return err
+ }
+ if !totpEnabled {
+ return nil
+ }
+
+ if passcode == "" {
+ _ = s.Rollback()
+ return user.ErrInvalidTOTPPasscode{}
+ }
+
+ _, err = user.ValidateTOTPPasscode(s, &user.TOTPPasscode{User: u, Passcode: passcode})
+ if err != nil {
+ _ = s.Rollback()
+ if user.IsErrInvalidTOTPPasscode(err) {
+ user.HandleFailedTOTPAuth(u)
+ }
+ return err
+ }
+
+ return nil
+}
+
+// DeleteSession removes the session with the given id, logging the user out
+// server-side. An empty sid is a no-op (the token carried no session, e.g. an
+// API token or a link share), matching v1. Shared by v1 and v2; the caller is
+// responsible for clearing the refresh cookie.
+func DeleteSession(sid string) error {
+ _, err := LogoutSession(sid)
+ return err
+}
+
+// LogoutSession deletes the session and returns its OIDC RP-Initiated Logout URL
+// for the frontend to redirect to (empty for non-OIDC sessions or when no logout
+// endpoint is configured). An empty sid is a no-op. The caller clears the refresh
+// cookie.
+func LogoutSession(sid string) (endSessionURL string, err error) {
+ if sid == "" {
+ return "", nil
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ // Read before deleting so the stored id_token survives for the logout URL.
+ // A missing session just means there is nothing to log out.
+ session, err := models.GetSessionByID(s, sid)
+ if err != nil && !models.IsErrSessionNotFound(err) {
+ _ = s.Rollback()
+ return "", err
+ }
+ if session != nil && session.OIDCProviderKey != "" {
+ url, buildErr := openid.BuildEndSessionURL(session.OIDCProviderKey, &models.SessionOIDCData{
+ IDToken: session.OIDCIDToken,
+ ProviderKey: session.OIDCProviderKey,
+ })
+ if buildErr != nil {
+ // A failed URL build must not block logout; the session is still deleted below.
+ log.Errorf("Could not build OIDC end-session URL for session %s: %v", sid, buildErr)
+ } else {
+ endSessionURL = url
+ }
+ }
+
+ if _, err := s.Where("id = ?", sid).Delete(&models.Session{}); err != nil {
+ _ = s.Rollback()
+ return "", err
+ }
+
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ return "", err
+ }
+
+ return endSessionURL, nil
+}
+
+// ResetPassword resets a user's password from a previously issued reset token
+// and invalidates all of that user's sessions, so a leaked password cannot be
+// used after a reset. Shared by v1 and v2.
+func ResetPassword(reset *user.PasswordReset) error {
+ s := db.NewSession()
+ defer s.Close()
+
+ userID, err := user.ResetPassword(s, reset)
+ if err != nil {
+ _ = s.Rollback()
+ return err
+ }
+
+ if err := models.DeleteAllUserSessions(s, userID); err != nil {
+ _ = s.Rollback()
+ return err
+ }
+
+ return s.Commit()
+}
+
+// RequestPasswordResetToken issues a password-reset token for the account with
+// the given email and sends it via email. Shared by v1 and v2.
+func RequestPasswordResetToken(req *user.PasswordTokenRequest) error {
+ s := db.NewSession()
+ defer s.Close()
+
+ if err := user.RequestUserPasswordResetTokenByEmail(s, req); err != nil {
+ _ = s.Rollback()
+ return err
+ }
+
+ return s.Commit()
+}
+
+// ConfirmEmail confirms a newly registered user's email from the token sent to
+// them. Shared by v1 and v2.
+func ConfirmEmail(confirm *user.EmailConfirm) error {
+ s := db.NewSession()
+ defer s.Close()
+
+ if err := user.ConfirmEmail(s, confirm); err != nil {
+ _ = s.Rollback()
+ return err
+ }
+
+ return s.Commit()
+}
+
+// LinkShareToken is the response for the link-share auth endpoint. It embeds the
+// authenticated share alongside the issued JWT and re-exposes the project id
+// (which LinkSharing hides with json:"-"). The embedded share's write-only
+// Password is blanked by AuthenticateLinkShare before this is returned.
+type LinkShareToken struct {
+ auth.Token
+ *models.LinkSharing
+ ProjectID int64 `json:"project_id" readOnly:"true" doc:"The id of the project this share grants access to."`
+}
+
+// AuthenticateLinkShare resolves a link share by its public hash, verifies the
+// password for password-protected shares, and issues a JWT auth token for it.
+// The returned token's embedded share has its password blanked. Shared by v1
+// and v2.
+func AuthenticateLinkShare(hash, password string) (*LinkShareToken, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ share, err := models.GetLinkShareByHash(s, hash)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if share.SharingType == models.SharingTypeWithPassword {
+ if err := models.VerifyLinkSharePassword(share, password); err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+ }
+
+ t, err := auth.NewLinkShareJWTAuthtoken(share)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ share.Password = ""
+
+ return &LinkShareToken{
+ Token: auth.Token{Token: t},
+ LinkSharing: share,
+ ProjectID: share.ProjectID,
+ }, nil
+}
diff --git a/pkg/routes/api/shared/auth_provider.go b/pkg/routes/api/shared/auth_provider.go
new file mode 100644
index 000000000..042a5567d
--- /dev/null
+++ b/pkg/routes/api/shared/auth_provider.go
@@ -0,0 +1,54 @@
+// 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 shared holds helpers used by both the v1 and v2 route packages. It
+// sits above the auth/user modules in the import graph, so it can combine them
+// without creating a cycle.
+package shared
+
+import (
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/user"
+)
+
+// GetAuthProviderName resolves the human-readable name of the source a user
+// authenticated with: "local"/"ldap" for those issuers, otherwise the
+// configured OpenID provider whose issuer URL matches the user's. Returns ""
+// when no provider matches.
+func GetAuthProviderName(u *user.User) (string, error) {
+ switch u.Issuer {
+ case user.IssuerLocal:
+ return "local", nil
+ case user.IssuerLDAP:
+ return "ldap", nil
+ }
+
+ providers, err := openid.GetAllProviders()
+ if err != nil {
+ return "", err
+ }
+ for _, provider := range providers {
+ issuerURL, err := provider.Issuer()
+ if err != nil {
+ return "", err
+ }
+ if issuerURL == u.Issuer {
+ return provider.Name, nil
+ }
+ }
+
+ return "", nil
+}
diff --git a/pkg/routes/api/shared/info.go b/pkg/routes/api/shared/info.go
new file mode 100644
index 000000000..48cbb00a5
--- /dev/null
+++ b/pkg/routes/api/shared/info.go
@@ -0,0 +1,167 @@
+// 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 shared
+
+import (
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv"
+ microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo"
+ "code.vikunja.io/api/pkg/modules/migration/ticktick"
+ "code.vikunja.io/api/pkg/modules/migration/todoist"
+ "code.vikunja.io/api/pkg/modules/migration/trello"
+ vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file"
+ "code.vikunja.io/api/pkg/modules/migration/wekan"
+ "code.vikunja.io/api/pkg/version"
+)
+
+// VikunjaInfos holds public information about this Vikunja instance.
+type VikunjaInfos struct {
+ Version string `json:"version" doc:"The Vikunja version this instance runs."`
+ FrontendURL string `json:"frontend_url" doc:"The publicly configured frontend URL of this instance."`
+ Motd string `json:"motd" doc:"The message of the day, shown to all users."`
+ LinkSharingEnabled bool `json:"link_sharing_enabled" doc:"Whether sharing projects via public links is enabled."`
+ MaxFileSize string `json:"max_file_size" doc:"The maximum allowed upload size, as a human-readable string (e.g. 20MB)."`
+ MaxItemsPerPage int `json:"max_items_per_page" doc:"The maximum number of items a paginated endpoint returns per page."`
+ AvailableMigrators []string `json:"available_migrators" doc:"The migrators enabled on this instance."`
+ TaskAttachmentsEnabled bool `json:"task_attachments_enabled" doc:"Whether task attachments are enabled."`
+ EnabledBackgroundProviders []string `json:"enabled_background_providers" doc:"The project-background providers enabled on this instance (e.g. upload, unsplash)."`
+ TotpEnabled bool `json:"totp_enabled" doc:"Whether TOTP two-factor authentication is enabled."`
+ Legal LegalInfo `json:"legal" doc:"Links to the instance's legal documents."`
+ CaldavEnabled bool `json:"caldav_enabled" doc:"Whether the CalDAV interface is enabled."`
+ AuthInfo AuthInfo `json:"auth" doc:"The authentication methods enabled on this instance."`
+ EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"Whether email reminders are enabled."`
+ UserDeletionEnabled bool `json:"user_deletion_enabled" doc:"Whether users may delete their own account."`
+ TaskCommentsEnabled bool `json:"task_comments_enabled" doc:"Whether task comments are enabled."`
+ DemoModeEnabled bool `json:"demo_mode_enabled" doc:"Whether this instance runs in demo mode (data is periodically reset)."`
+ WebhooksEnabled bool `json:"webhooks_enabled" doc:"Whether webhooks are enabled."`
+ PublicTeamsEnabled bool `json:"public_teams_enabled" doc:"Whether public teams are enabled."`
+ AllowIconChanges bool `json:"allow_icon_changes" doc:"Whether users may change project icons."`
+ EnabledProFeatures []license.Feature `json:"enabled_pro_features" doc:"The licensed pro features enabled on this instance."`
+ // ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.
+ ConcurrentWrites bool `json:"concurrent_writes" doc:"Whether the configured database supports concurrent writes. False on SQLite; clients should serialize batched writes when this is false."`
+}
+
+// AuthInfo describes the authentication methods enabled on this instance.
+type AuthInfo struct {
+ Local LocalAuthInfo `json:"local"`
+ Ldap LdapAuthInfo `json:"ldap"`
+ OpenIDConnect OpenIDAuthInfo `json:"openid_connect"`
+}
+
+// LocalAuthInfo describes the local (username/password) authentication method.
+type LocalAuthInfo struct {
+ Enabled bool `json:"enabled"`
+ RegistrationEnabled bool `json:"registration_enabled"`
+}
+
+// LdapAuthInfo describes the LDAP authentication method.
+type LdapAuthInfo struct {
+ Enabled bool `json:"enabled"`
+}
+
+// OpenIDAuthInfo describes the OpenID Connect authentication method.
+type OpenIDAuthInfo struct {
+ Enabled bool `json:"enabled"`
+ Providers []*openid.Provider `json:"providers"`
+}
+
+// LegalInfo holds links to the instance's legal documents.
+type LegalInfo struct {
+ ImprintURL string `json:"imprint_url"`
+ PrivacyPolicyURL string `json:"privacy_policy_url"`
+}
+
+// BuildInfo assembles the public instance information returned by GET /info on
+// both API versions.
+func BuildInfo() VikunjaInfos {
+ info := VikunjaInfos{
+ Version: version.Version,
+ FrontendURL: config.ServicePublicURL.GetString(),
+ Motd: config.ServiceMotd.GetString(),
+ LinkSharingEnabled: config.ServiceEnableLinkSharing.GetBool(),
+ MaxFileSize: config.FilesMaxSize.GetString(),
+ MaxItemsPerPage: config.ServiceMaxItemsPerPage.GetInt(),
+ TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(),
+ TotpEnabled: config.ServiceEnableTotp.GetBool(),
+ CaldavEnabled: config.ServiceEnableCaldav.GetBool(),
+ EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(),
+ UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(),
+ TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(),
+ DemoModeEnabled: config.ServiceDemoMode.GetBool(),
+ WebhooksEnabled: config.WebhooksEnabled.GetBool(),
+ PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(),
+ AllowIconChanges: config.ServiceAllowIconChanges.GetBool(),
+ ConcurrentWrites: config.DatabaseType.GetString() != "sqlite",
+ EnabledProFeatures: license.EnabledProFeatures(),
+ AvailableMigrators: []string{
+ (&vikunja_file.FileMigrator{}).Name(),
+ (&ticktick.Migrator{}).Name(),
+ (&wekan.Migrator{}).Name(),
+ (&csvmigrator.Migrator{}).Name(),
+ },
+ Legal: LegalInfo{
+ ImprintURL: config.LegalImprintURL.GetString(),
+ PrivacyPolicyURL: config.LegalPrivacyURL.GetString(),
+ },
+ AuthInfo: AuthInfo{
+ Local: LocalAuthInfo{
+ Enabled: config.AuthLocalEnabled.GetBool(),
+ RegistrationEnabled: config.AuthLocalEnabled.GetBool() && config.ServiceEnableRegistration.GetBool(),
+ },
+ Ldap: LdapAuthInfo{
+ Enabled: config.AuthLdapEnabled.GetBool(),
+ },
+ OpenIDConnect: OpenIDAuthInfo{
+ Enabled: config.AuthOpenIDEnabled.GetBool(),
+ },
+ },
+ }
+
+ providers, err := openid.GetAllProviders()
+ if err != nil {
+ log.Errorf("Error while getting openid providers for /info: %s", err)
+ // No return here to not break /info
+ }
+ info.AuthInfo.OpenIDConnect.Providers = providers
+
+ if config.MigrationTodoistEnable.GetBool() {
+ m := &todoist.Migration{}
+ info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
+ }
+ if config.MigrationTrelloEnable.GetBool() {
+ m := &trello.Migration{}
+ info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
+ }
+ if config.MigrationMicrosoftTodoEnable.GetBool() {
+ m := µsofttodo.Migration{}
+ info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
+ }
+
+ if config.BackgroundsEnabled.GetBool() {
+ if config.BackgroundsUploadEnabled.GetBool() {
+ info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "upload")
+ }
+ if config.BackgroundsUnsplashEnabled.GetBool() {
+ info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash")
+ }
+ }
+
+ return info
+}
diff --git a/pkg/routes/api/shared/testing.go b/pkg/routes/api/shared/testing.go
new file mode 100644
index 000000000..ba9118e5a
--- /dev/null
+++ b/pkg/routes/api/shared/testing.go
@@ -0,0 +1,92 @@
+// 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 shared
+
+import (
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/log"
+)
+
+// dependentTestingTables lists tables that reference a reset table by ID and
+// must be truncated alongside it. Without foreign key cascades, stale rows
+// would persist and pollute subsequent tests that reuse the same
+// auto-increment IDs.
+var dependentTestingTables = map[string][]string{
+ "users": {"notifications"},
+}
+
+// ReplaceTableContents resets a single table to the provided rows for the e2e
+// testing endpoint and returns the table's resulting contents. When truncate is
+// true the table (and any dependent tables) is emptied first; otherwise the rows
+// are restored on top of existing data. Callers must already have verified the
+// testing token.
+func ReplaceTableContents(table string, content []map[string]interface{}, truncate bool) ([]map[string]interface{}, error) {
+ // Wait for all async event handlers from the previous test to complete
+ // before modifying the database. Without this, handlers hold SQLite
+ // connections and starve this request's truncate/insert operations.
+ events.WaitForPendingHandlers()
+
+ var err error
+ if truncate {
+ for _, dep := range dependentTestingTables[table] {
+ if err = db.RestoreAndTruncate(dep, nil); err != nil {
+ return nil, err
+ }
+ }
+ err = db.RestoreAndTruncate(table, content)
+ } else {
+ err = db.Restore(table, content)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ // License state is cached at startup; re-apply so tests take effect without a restart.
+ if table == "license_status" {
+ if err := license.ReloadFromCache(); err != nil {
+ return nil, err
+ }
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+ data := []map[string]interface{}{}
+ if err := s.Table(table).Find(&data); err != nil {
+ return nil, err
+ }
+ return data, nil
+}
+
+// TruncateAllTestingTables empties every Vikunja table for the e2e testing
+// endpoint. Callers must already have verified the testing token.
+func TruncateAllTestingTables() error {
+ events.WaitForPendingHandlers()
+
+ if err := db.TruncateAllTables(); err != nil {
+ return err
+ }
+
+ // Reload after truncate; otherwise features enabled by a prior test outlive
+ // the now-empty license_status table. A reload failure here is non-fatal —
+ // the truncate already succeeded — so it is logged and swallowed.
+ if err := license.ReloadFromCache(); err != nil {
+ log.Errorf("Error reloading license after truncate: %v", err)
+ }
+ return nil
+}
diff --git a/pkg/routes/api/v1/admin/overview.go b/pkg/routes/api/v1/admin/overview.go
index 3911e31be..6c5b71858 100644
--- a/pkg/routes/api/v1/admin/overview.go
+++ b/pkg/routes/api/v1/admin/overview.go
@@ -20,77 +20,27 @@ import (
"net/http"
"code.vikunja.io/api/pkg/db"
- "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/models"
+
"github.com/labstack/echo/v5"
)
-type ShareCounts struct {
- LinkShares int64 `json:"link_shares"`
- TeamShares int64 `json:"team_shares"`
- UserShares int64 `json:"user_shares"`
-}
-
-type Overview struct {
- Users int64 `json:"users"`
- Projects int64 `json:"projects"`
- Tasks int64 `json:"tasks"`
- Teams int64 `json:"teams"`
- Shares ShareCounts `json:"shares"`
- License license.Info `json:"license"`
-}
-
// GetOverview returns aggregate instance counts and metadata.
// @Summary Admin overview
// @Description Returns per-instance counts (users, projects, shares) plus version and license info. Instance-admin only, gated by the admin_panel feature.
// @tags admin
// @Produce json
// @Security JWTKeyAuth
-// @Success 200 {object} admin.Overview
+// @Success 200 {object} models.Overview
// @Failure 404 {object} web.HTTPError
// @Router /admin/overview [get]
func GetOverview(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- users, err := s.Table("users").Count()
+ overview, err := models.BuildOverview(s)
if err != nil {
return err
}
- projects, err := s.Table("projects").Count()
- if err != nil {
- return err
- }
- tasks, err := s.Table("tasks").Count()
- if err != nil {
- return err
- }
- teams, err := s.Table("teams").Count()
- if err != nil {
- return err
- }
- linkShares, err := s.Table("link_shares").Count()
- if err != nil {
- return err
- }
- teamShares, err := s.Table("team_projects").Count()
- if err != nil {
- return err
- }
- userShares, err := s.Table("users_projects").Count()
- if err != nil {
- return err
- }
-
- return c.JSON(http.StatusOK, Overview{
- Users: users,
- Projects: projects,
- Tasks: tasks,
- Teams: teams,
- Shares: ShareCounts{
- LinkShares: linkShares,
- TeamShares: teamShares,
- UserShares: userShares,
- },
- License: license.CurrentInfo(),
- })
+ return c.JSON(http.StatusOK, overview)
}
diff --git a/pkg/routes/api/v1/admin/user_create.go b/pkg/routes/api/v1/admin/user_create.go
index 5ba455579..bedddef58 100644
--- a/pkg/routes/api/v1/admin/user_create.go
+++ b/pkg/routes/api/v1/admin/user_create.go
@@ -20,28 +20,14 @@ import (
"errors"
"net/http"
- "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth/openid"
- "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"github.com/labstack/echo/v5"
)
-// CreateUserBody wraps user.APIUserPassword with admin-only fields.
-type CreateUserBody struct {
- // The full name of the new user. Optional.
- Name string `json:"name"`
- // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.
- Language string `json:"language" valid:"language"`
- user.APIUserPassword
- // Mark the new user as an instance admin.
- IsAdmin bool `json:"is_admin"`
- // Activate the new user immediately without email confirmation.
- SkipEmailConfirm bool `json:"skip_email_confirm"`
-}
-
// CreateUser provisions a new account on behalf of an instance admin.
// @Summary Create a user (admin)
// @Description Create a new local user account. Respects the admin-only fields `is_admin` and `skip_email_confirm`. The public registration toggle is bypassed.
@@ -49,12 +35,12 @@ type CreateUserBody struct {
// @Accept json
// @Produce json
// @Security JWTKeyAuth
-// @Param body body admin.CreateUserBody true "The user to create"
-// @Success 200 {object} admin.User
+// @Param body body models.CreateUserBody true "The user to create"
+// @Success 200 {object} shared.AdminUser
// @Failure 400 {object} web.HTTPError
// @Router /admin/users [post]
func CreateUser(c *echo.Context) error {
- body := &CreateUserBody{}
+ body := &models.CreateUserBody{}
if err := c.Bind(body); err != nil {
return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."})
}
@@ -69,52 +55,15 @@ func CreateUser(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- newUser, err := models.RegisterUser(s, &user.User{
- Username: body.Username,
- Password: body.Password,
- Email: body.Email,
- Name: body.Name,
- Language: body.Language,
- })
+ newUser, err := models.CreateUserAsAdmin(s, body)
if err != nil {
_ = s.Rollback()
return err
}
- if body.IsAdmin {
- if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil {
- _ = s.Rollback()
- return err
- }
- newUser.IsAdmin = true
- }
-
- // Force Active when the admin asked to skip, or when no mailer exists to send the confirmation.
- if body.SkipEmailConfirm || !config.MailerEnabled.GetBool() {
- if err := user.SetUserStatus(s, newUser, user.StatusActive); err != nil {
- _ = s.Rollback()
- return err
- }
- newUser.Status = user.StatusActive
- }
-
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
- return err
- }
-
- // Reload the user so the returned status reflects what was actually persisted
- // (e.g. StatusEmailConfirmationRequired on mail-enabled instances).
- rs := db.NewSession()
- defer rs.Close()
- newUser, err = user.GetUserByID(rs, newUser.ID)
- if err != nil {
- return err
- }
-
providers, err := openid.GetAllProviders()
if err != nil {
return err
}
- return c.JSON(http.StatusOK, newAdminUser(newUser, providers))
+ return c.JSON(http.StatusOK, shared.NewAdminUser(newUser, providers))
}
diff --git a/pkg/routes/api/v1/admin/users.go b/pkg/routes/api/v1/admin/users.go
index f9392b772..5117b9e46 100644
--- a/pkg/routes/api/v1/admin/users.go
+++ b/pkg/routes/api/v1/admin/users.go
@@ -18,52 +18,13 @@ package admin
import (
"code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"xorm.io/xorm"
)
-// User re-exposes fields hidden by the default user.User JSON view.
-type User struct {
- *user.User
- IsAdmin bool `json:"is_admin"`
- Status user.Status `json:"status"`
- Issuer string `json:"issuer"`
- Subject string `json:"subject,omitempty"`
- AuthProvider string `json:"auth_provider,omitempty"`
-}
-
-func newAdminUser(u *user.User, providers []*openid.Provider) *User {
- return &User{
- User: u,
- IsAdmin: u.IsAdmin,
- Status: u.Status,
- Issuer: u.Issuer,
- Subject: u.Subject,
- AuthProvider: resolveAuthProvider(u, providers),
- }
-}
-
-func resolveAuthProvider(u *user.User, providers []*openid.Provider) string {
- switch u.Issuer {
- case "", user.IssuerLocal:
- return ""
- case user.IssuerLDAP:
- return "LDAP"
- }
- for _, provider := range providers {
- issuerURL, err := provider.Issuer()
- if err != nil {
- continue
- }
- if issuerURL == u.Issuer {
- return provider.Name
- }
- }
- return u.Issuer
-}
-
// UserList backs the admin list-users route via handler.ReadAllWeb; only ReadAll is used.
type UserList struct {
web.CRUDable `xorm:"-" json:"-"`
@@ -79,7 +40,7 @@ type UserList struct {
// @Param s query string false "Search string matched against username and email."
// @Param page query int false "Page number, defaults to 1."
// @Param per_page query int false "Items per page, defaults to the service setting."
-// @Success 200 {array} admin.User
+// @Success 200 {array} shared.AdminUser
// @Failure 404 {object} web.HTTPError
// @Router /admin/users [get]
func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPage int) (interface{}, int, int64, error) {
@@ -106,9 +67,9 @@ func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPa
return nil, 0, 0, err
}
- out := make([]*User, 0, len(users))
+ out := make([]*shared.AdminUser, 0, len(users))
for _, u := range users {
- out = append(out, newAdminUser(u, providers))
+ out = append(out, shared.NewAdminUser(u, providers))
}
return out, len(out), totalCount, nil
}
diff --git a/pkg/routes/api/v1/admin/users_admin.go b/pkg/routes/api/v1/admin/users_admin.go
index 5f31b269f..195bb2092 100644
--- a/pkg/routes/api/v1/admin/users_admin.go
+++ b/pkg/routes/api/v1/admin/users_admin.go
@@ -23,7 +23,9 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
+
"github.com/labstack/echo/v5"
)
@@ -41,7 +43,7 @@ type IsAdminPatch struct {
// @Security JWTKeyAuth
// @Param id path int true "User ID"
// @Param body body admin.IsAdminPatch true "New admin value"
-// @Success 200 {object} admin.User
+// @Success 200 {object} shared.AdminUser
// @Failure 400 {object} web.HTTPError
// @Failure 404 {object} web.HTTPError
// @Router /admin/users/{id}/admin [patch]
@@ -63,24 +65,8 @@ func PatchAdmin(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- target := &user.User{ID: id}
- has, err := s.Get(target)
+ target, err := models.SetUserAdminFlag(s, id, *body.IsAdmin)
if err != nil {
- return err
- }
- if !has {
- return user.ErrUserDoesNotExist{UserID: id}
- }
-
- if !*body.IsAdmin {
- if err := user.GuardLastAdmin(s, target); err != nil {
- _ = s.Rollback()
- return err
- }
- }
-
- target.IsAdmin = *body.IsAdmin
- if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil {
_ = s.Rollback()
return err
}
@@ -92,5 +78,5 @@ func PatchAdmin(c *echo.Context) error {
if err != nil {
return err
}
- return c.JSON(http.StatusOK, newAdminUser(target, providers))
+ return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers))
}
diff --git a/pkg/routes/api/v1/admin/users_mgmt.go b/pkg/routes/api/v1/admin/users_mgmt.go
index 2e95d88b5..1e72fa173 100644
--- a/pkg/routes/api/v1/admin/users_mgmt.go
+++ b/pkg/routes/api/v1/admin/users_mgmt.go
@@ -23,7 +23,9 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
+
"github.com/labstack/echo/v5"
)
@@ -41,7 +43,7 @@ type StatusPatch struct {
// @Security JWTKeyAuth
// @Param id path int true "User ID"
// @Param body body admin.StatusPatch true "Status"
-// @Success 200 {object} admin.User
+// @Success 200 {object} shared.AdminUser
// @Failure 400 {object} web.HTTPError
// @Failure 404 {object} web.HTTPError
// @Router /admin/users/{id}/status [patch]
@@ -65,24 +67,8 @@ func PatchStatus(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- target := &user.User{ID: id}
- has, err := s.Get(target)
+ target, err := models.SetUserStatusAsAdmin(s, id, newStatus)
if err != nil {
- return err
- }
- if !has {
- return user.ErrUserDoesNotExist{UserID: id}
- }
-
- // Any non-Active status blocks login, so moving an admin out of Active is equivalent to demotion.
- if target.IsAdmin && newStatus != user.StatusActive {
- if err := user.GuardLastAdmin(s, target); err != nil {
- _ = s.Rollback()
- return err
- }
- }
-
- if err := user.SetUserStatus(s, target, newStatus); err != nil {
_ = s.Rollback()
return err
}
@@ -90,13 +76,11 @@ func PatchStatus(c *echo.Context) error {
return err
}
- // Refresh locally since GetUserByID refuses disabled accounts.
- target.Status = newStatus
providers, err := openid.GetAllProviders()
if err != nil {
return err
}
- return c.JSON(http.StatusOK, newAdminUser(target, providers))
+ return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers))
}
// DeleteUser removes a user either immediately or through the self-deletion flow.
@@ -128,32 +112,10 @@ func DeleteUser(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- target := &user.User{ID: id}
- has, err := s.Get(target)
- if err != nil {
- return err
- }
- if !has {
- return user.ErrUserDoesNotExist{UserID: id}
- }
-
- if err := user.GuardLastAdmin(s, target); err != nil {
+ if err := models.DeleteUserAsAdmin(s, id, mode); err != nil {
_ = s.Rollback()
return err
}
-
- if mode == "now" {
- if err := models.DeleteUser(s, target); err != nil {
- _ = s.Rollback()
- return err
- }
- } else {
- if err := user.RequestDeletion(s, target); err != nil {
- _ = s.Rollback()
- return err
- }
- }
-
if err := s.Commit(); err != nil {
return err
}
diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go
index 0e0a64ff2..87891ff15 100644
--- a/pkg/routes/api/v1/info.go
+++ b/pkg/routes/api/v1/info.go
@@ -19,151 +19,18 @@ package v1
import (
"net/http"
- "code.vikunja.io/api/pkg/config"
- "code.vikunja.io/api/pkg/license"
- "code.vikunja.io/api/pkg/log"
- "code.vikunja.io/api/pkg/modules/auth/openid"
- csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv"
- microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo"
- "code.vikunja.io/api/pkg/modules/migration/ticktick"
- "code.vikunja.io/api/pkg/modules/migration/todoist"
- "code.vikunja.io/api/pkg/modules/migration/trello"
- vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file"
- "code.vikunja.io/api/pkg/modules/migration/wekan"
- "code.vikunja.io/api/pkg/version"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"github.com/labstack/echo/v5"
)
-type vikunjaInfos struct {
- Version string `json:"version"`
- FrontendURL string `json:"frontend_url"`
- Motd string `json:"motd"`
- LinkSharingEnabled bool `json:"link_sharing_enabled"`
- MaxFileSize string `json:"max_file_size"`
- MaxItemsPerPage int `json:"max_items_per_page"`
- AvailableMigrators []string `json:"available_migrators"`
- TaskAttachmentsEnabled bool `json:"task_attachments_enabled"`
- EnabledBackgroundProviders []string `json:"enabled_background_providers"`
- TotpEnabled bool `json:"totp_enabled"`
- Legal legalInfo `json:"legal"`
- CaldavEnabled bool `json:"caldav_enabled"`
- AuthInfo authInfo `json:"auth"`
- EmailRemindersEnabled bool `json:"email_reminders_enabled"`
- UserDeletionEnabled bool `json:"user_deletion_enabled"`
- TaskCommentsEnabled bool `json:"task_comments_enabled"`
- DemoModeEnabled bool `json:"demo_mode_enabled"`
- WebhooksEnabled bool `json:"webhooks_enabled"`
- PublicTeamsEnabled bool `json:"public_teams_enabled"`
- AllowIconChanges bool `json:"allow_icon_changes"`
- EnabledProFeatures []license.Feature `json:"enabled_pro_features"`
-}
-
-type authInfo struct {
- Local localAuthInfo `json:"local"`
- Ldap ldapAuthInfo `json:"ldap"`
- OpenIDConnect openIDAuthInfo `json:"openid_connect"`
-}
-
-type localAuthInfo struct {
- Enabled bool `json:"enabled"`
- RegistrationEnabled bool `json:"registration_enabled"`
-}
-
-type ldapAuthInfo struct {
- Enabled bool `json:"enabled"`
-}
-
-type openIDAuthInfo struct {
- Enabled bool `json:"enabled"`
- Providers []*openid.Provider `json:"providers"`
-}
-
-type legalInfo struct {
- ImprintURL string `json:"imprint_url"`
- PrivacyPolicyURL string `json:"privacy_policy_url"`
-}
-
// Info is the handler to get infos about this vikunja instance
// @Summary Info
// @Description Returns the version, frontendurl, motd and various settings of Vikunja
// @tags service
// @Produce json
-// @Success 200 {object} v1.vikunjaInfos
+// @Success 200 {object} shared.VikunjaInfos
// @Router /info [get]
func Info(c *echo.Context) error {
- info := vikunjaInfos{
- Version: version.Version,
- FrontendURL: config.ServicePublicURL.GetString(),
- Motd: config.ServiceMotd.GetString(),
- LinkSharingEnabled: config.ServiceEnableLinkSharing.GetBool(),
- MaxFileSize: config.FilesMaxSize.GetString(),
- MaxItemsPerPage: config.ServiceMaxItemsPerPage.GetInt(),
- TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(),
- TotpEnabled: config.ServiceEnableTotp.GetBool(),
- CaldavEnabled: config.ServiceEnableCaldav.GetBool(),
- EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(),
- UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(),
- TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(),
- DemoModeEnabled: config.ServiceDemoMode.GetBool(),
- WebhooksEnabled: config.WebhooksEnabled.GetBool(),
- PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(),
- AllowIconChanges: config.ServiceAllowIconChanges.GetBool(),
- EnabledProFeatures: license.EnabledProFeatures(),
- AvailableMigrators: []string{
- (&vikunja_file.FileMigrator{}).Name(),
- (&ticktick.Migrator{}).Name(),
- (&wekan.Migrator{}).Name(),
- (&csvmigrator.Migrator{}).Name(),
- },
- Legal: legalInfo{
- ImprintURL: config.LegalImprintURL.GetString(),
- PrivacyPolicyURL: config.LegalPrivacyURL.GetString(),
- },
- AuthInfo: authInfo{
- Local: localAuthInfo{
- Enabled: config.AuthLocalEnabled.GetBool(),
- RegistrationEnabled: config.AuthLocalEnabled.GetBool() && config.ServiceEnableRegistration.GetBool(),
- },
- Ldap: ldapAuthInfo{
- Enabled: config.AuthLdapEnabled.GetBool(),
- },
- OpenIDConnect: openIDAuthInfo{
- Enabled: config.AuthOpenIDEnabled.GetBool(),
- },
- },
- }
-
- providers, err := openid.GetAllProviders()
- if err != nil {
- log.Errorf("Error while getting openid providers for /info: %s", err)
- // No return here to not break /info
- }
-
- info.AuthInfo.OpenIDConnect.Providers = providers
-
- // Migrators
- if config.MigrationTodoistEnable.GetBool() {
- m := &todoist.Migration{}
- info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
- }
- if config.MigrationTrelloEnable.GetBool() {
- m := &trello.Migration{}
- info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
- }
- if config.MigrationMicrosoftTodoEnable.GetBool() {
- m := µsofttodo.Migration{}
- info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
- }
-
- if config.BackgroundsEnabled.GetBool() {
- if config.BackgroundsUploadEnabled.GetBool() {
- info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "upload")
- }
- if config.BackgroundsUnsplashEnabled.GetBool() {
- info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash")
- }
- }
-
- return c.JSON(http.StatusOK, info)
+ return c.JSON(http.StatusOK, shared.BuildInfo())
}
diff --git a/pkg/routes/api/v1/link_sharing_auth.go b/pkg/routes/api/v1/link_sharing_auth.go
index 9e20a94f8..f4ca79ed0 100644
--- a/pkg/routes/api/v1/link_sharing_auth.go
+++ b/pkg/routes/api/v1/link_sharing_auth.go
@@ -19,20 +19,11 @@ package v1
import (
"net/http"
- "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/routes/api/shared"
- "code.vikunja.io/api/pkg/models"
- "code.vikunja.io/api/pkg/modules/auth"
"github.com/labstack/echo/v5"
)
-// LinkShareToken represents a link share auth token with extra infos about the actual link share
-type LinkShareToken struct {
- auth.Token
- *models.LinkSharing
- ProjectID int64 `json:"project_id"`
-}
-
// LinkShareAuth represents everything required to authenticate a link share
type LinkShareAuth struct {
Hash string `param:"share" json:"-"`
@@ -53,36 +44,14 @@ type LinkShareAuth struct {
// @Router /shares/{share}/auth [post]
func AuthenticateLinkShare(c *echo.Context) error {
sh := &LinkShareAuth{}
- err := c.Bind(sh)
+ if err := c.Bind(sh); err != nil {
+ return err
+ }
+
+ token, err := shared.AuthenticateLinkShare(sh.Hash, sh.Password)
if err != nil {
return err
}
- s := db.NewSession()
- defer s.Close()
-
- share, err := models.GetLinkShareByHash(s, sh.Hash)
- if err != nil {
- return err
- }
-
- if share.SharingType == models.SharingTypeWithPassword {
- err := models.VerifyLinkSharePassword(share, sh.Password)
- if err != nil {
- return err
- }
- }
-
- t, err := auth.NewLinkShareJWTAuthtoken(share)
- if err != nil {
- return err
- }
-
- share.Password = ""
-
- return c.JSON(http.StatusOK, LinkShareToken{
- Token: auth.Token{Token: t},
- LinkSharing: share,
- ProjectID: share.ProjectID,
- })
+ return c.JSON(http.StatusOK, token)
}
diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go
index eb92945d1..6a4662ae2 100644
--- a/pkg/routes/api/v1/login.go
+++ b/pkg/routes/api/v1/login.go
@@ -21,10 +21,11 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
- "code.vikunja.io/api/pkg/modules/auth/ldap"
- "code.vikunja.io/api/pkg/modules/keyvalue"
+ "code.vikunja.io/api/pkg/routes/api/shared"
user2 "code.vikunja.io/api/pkg/user"
"github.com/golang-jwt/jwt/v5"
@@ -49,84 +50,13 @@ func Login(c *echo.Context) (err error) {
return c.JSON(http.StatusBadRequest, models.Message{Message: "Please provide a username and password."})
}
- s := db.NewSession()
- defer s.Close()
-
- var user *user2.User
- if config.AuthLdapEnabled.GetBool() {
- user, err = ldap.AuthenticateUserInLDAP(s, u.Username, u.Password, config.AuthLdapGroupSyncEnabled.GetBool(), config.AuthLdapAvatarSyncAttribute.GetString())
- if err != nil && !user2.IsErrWrongUsernameOrPassword(err) {
- _ = s.Rollback()
- return err
- }
- }
-
- if user == nil {
- // Check if the user is a bot before attempting password verification,
- // because bots have no password hash and bcrypt would fail with a
- // misleading error.
- existingUser, lookupErr := user2.GetUserByUsername(s, u.Username)
- if lookupErr == nil && existingUser.IsBot() {
- _ = s.Rollback()
- return &user2.ErrAccountIsBot{UserID: existingUser.ID}
- }
-
- // This allows us to still have local users while ldap is enabled
- user, err = user2.CheckUserCredentials(s, &u)
- if err != nil {
- _ = s.Rollback()
- return err
- }
- }
-
- if user.Status == user2.StatusDisabled || user.Status == user2.StatusAccountLocked {
- _ = s.Rollback()
- return &user2.ErrAccountDisabled{UserID: user.ID}
- }
-
- totpEnabled, err := user2.TOTPEnabledForUser(s, user)
+ user, err := shared.AuthenticateUserCredentials(c.Request().Context(), &u)
if err != nil {
- _ = s.Rollback()
- return err
- }
-
- if totpEnabled {
- if u.TOTPPasscode == "" {
- _ = s.Rollback()
- return user2.ErrInvalidTOTPPasscode{}
- }
-
- _, err = user2.ValidateTOTPPasscode(s, &user2.TOTPPasscode{
- User: user,
- Passcode: u.TOTPPasscode,
- })
- if err != nil {
- // Rollback before HandleFailedTOTPAuth so its dedicated session
- // can acquire a write lock on SQLite shared-cache. The lockout
- // write is decoupled from this handler's transaction — see
- // GHSA-fgfv-pv97-6cmj.
- _ = s.Rollback()
- if user2.IsErrInvalidTOTPPasscode(err) {
- user2.HandleFailedTOTPAuth(user)
- }
- return err
- }
- }
-
- if err := keyvalue.Del(user.GetFailedTOTPAttemptsKey()); err != nil {
- return err
- }
- if err := keyvalue.Del(user.GetFailedPasswordAttemptsKey()); err != nil {
- return err
- }
-
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
return err
}
// Create token
- return auth.NewUserAuthTokenResponse(user, c, u.LongToken)
+ return auth.NewUserAuthTokenResponse(user, c, u.LongToken, nil)
}
// RenewToken renews a link share token only. User tokens must use
@@ -220,42 +150,52 @@ func RefreshToken(c *echo.Context) (err error) {
return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken})
}
+type LogoutResponse struct {
+ Message string `json:"message"`
+ // RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.
+ OIDCLogoutURL string `json:"oidc_logout_url,omitempty"`
+}
+
// Logout deletes the current session from the server.
// @Summary Logout
-// @Description Destroys the current session and clears the refresh token cookie.
+// @Description Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too.
// @tags auth
// @Produce json
-// @Success 200 {object} models.Message "Successfully logged out."
+// @Success 200 {object} v1.LogoutResponse "Successfully logged out."
// @Router /user/logout [post]
func Logout(c *echo.Context) (err error) {
auth.ClearRefreshTokenCookie(c)
var sid string
+ var userID int64
if raw := c.Get("user"); raw != nil {
if jwtinf, ok := raw.(*jwt.Token); ok {
if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok {
sid, _ = claims["sid"].(string)
+ // Only user tokens carry a sid, but check the type explicitly
+ // so a link share id can never be logged as a user id.
+ if typ, ok := claims["type"].(float64); ok && int(typ) == auth.AuthTypeUser {
+ if id, ok := claims["id"].(float64); ok {
+ userID = int64(id)
+ }
+ }
}
}
}
- if sid == "" {
- return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
- }
-
- s := db.NewSession()
- defer s.Close()
-
- _, err = s.Where("id = ?", sid).Delete(&models.Session{})
+ oidcLogoutURL, err := shared.LogoutSession(sid)
if err != nil {
- _ = s.Rollback()
return err
}
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
- return err
+ if userID != 0 {
+ if err := events.DispatchWithContext(c.Request().Context(), &user2.LogoutEvent{UserID: userID}); err != nil {
+ log.Errorf("Could not dispatch logout event: %s", err)
+ }
}
- return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
+ return c.JSON(http.StatusOK, LogoutResponse{
+ Message: "Successfully logged out.",
+ OIDCLogoutURL: oidcLogoutURL,
+ })
}
diff --git a/pkg/routes/api/v1/task_attachment.go b/pkg/routes/api/v1/task_attachment.go
index dd6478703..68197a3bf 100644
--- a/pkg/routes/api/v1/task_attachment.go
+++ b/pkg/routes/api/v1/task_attachment.go
@@ -18,43 +18,16 @@ package v1
import (
"errors"
- "io"
- "mime"
"net/http"
- "strconv"
- "strings"
- "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
auth2 "code.vikunja.io/api/pkg/modules/auth"
- "code.vikunja.io/api/pkg/web"
+ webfiles "code.vikunja.io/api/pkg/web/files"
"github.com/labstack/echo/v5"
)
-// attachmentUploadError represents a structured error for attachment upload failures
-type attachmentUploadError struct {
- Code int `json:"code,omitempty"`
- Message string `json:"message"`
-}
-
-// toAttachmentUploadError converts an error to a structured attachmentUploadError
-func toAttachmentUploadError(err error) attachmentUploadError {
- // Try to get structured error info from HTTPErrorProcessor
- if httpErr, ok := err.(web.HTTPErrorProcessor); ok {
- errDetails := httpErr.HTTPError()
- return attachmentUploadError{
- Code: errDetails.Code,
- Message: errDetails.Message,
- }
- }
- // Fall back to just the error message
- return attachmentUploadError{
- Message: err.Error(),
- }
-}
-
// UploadTaskAttachment handles everything needed for the upload of a task attachment
// @Summary Upload a task attachment
// @Description Upload a task attachment. You can pass multiple files with the files form param.
@@ -76,7 +49,6 @@ func UploadTaskAttachment(c *echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err)
}
- // Permissions check
auth, err := auth2.GetAuthFromClaims(c)
if err != nil {
return err
@@ -85,15 +57,6 @@ func UploadTaskAttachment(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- can, err := taskAttachment.CanCreate(s, auth)
- if err != nil {
- _ = s.Rollback()
- return err
- }
- if !can {
- return echo.ErrForbidden
- }
-
// Multipart form
form, err := c.MultipartForm()
if err != nil {
@@ -104,31 +67,23 @@ func UploadTaskAttachment(c *echo.Context) error {
return err
}
- type result struct {
- Errors []attachmentUploadError `json:"errors"`
- Success []*models.TaskAttachment `json:"success"`
- }
- r := &result{}
fileHeaders := form.File["files"]
+ uploads := make([]*models.AttachmentToUpload, 0, len(fileHeaders))
+ var openErrors []error
for _, file := range fileHeaders {
- // We create a new attachment object here to have a clean start
- ta := &models.TaskAttachment{
- TaskID: taskAttachment.TaskID,
- }
-
f, err := file.Open()
if err != nil {
- r.Errors = append(r.Errors, toAttachmentUploadError(err))
+ openErrors = append(openErrors, err)
continue
}
defer f.Close()
+ uploads = append(uploads, &models.AttachmentToUpload{Reader: f, Filename: file.Filename, Size: uint64(file.Size)})
+ }
- err = ta.NewAttachment(s, f, file.Filename, uint64(file.Size), auth)
- if err != nil {
- r.Errors = append(r.Errors, toAttachmentUploadError(err))
- continue
- }
- r.Success = append(r.Success, ta)
+ success, failures, err := models.UploadTaskAttachments(s, auth, taskAttachment.TaskID, uploads)
+ if err != nil {
+ _ = s.Rollback()
+ return err
}
if err := s.Commit(); err != nil {
@@ -136,7 +91,7 @@ func UploadTaskAttachment(c *echo.Context) error {
return err
}
- return c.JSON(http.StatusOK, r)
+ return c.JSON(http.StatusOK, webfiles.BuildUploadResult(success, append(openErrors, failures...)))
}
// GetTaskAttachment returns a task attachment to download for the user
@@ -160,7 +115,6 @@ func GetTaskAttachment(c *echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err)
}
- // Permissions check
auth, err := auth2.GetAuthFromClaims(c)
if err != nil {
return err
@@ -169,36 +123,11 @@ func GetTaskAttachment(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- can, _, err := taskAttachment.CanRead(s, auth)
- if err != nil {
- _ = s.Rollback()
- return err
- }
- if !can {
- return echo.ErrForbidden
- }
-
- // Get the attachment incl file
- err = taskAttachment.ReadOne(s, auth)
- if err != nil {
- _ = s.Rollback()
- return err
- }
-
- // Open the file so its content is available for preview generation and download
- err = taskAttachment.File.LoadFileByID()
- if err != nil {
- _ = s.Rollback()
- return err
- }
-
- // If the preview query parameter is set, get the preview (cached or generate)
previewSize := models.GetPreviewSizeFromString(c.QueryParam("preview_size"))
- if previewSize != models.PreviewSizeUnknown && strings.HasPrefix(taskAttachment.File.Mime, "image") {
- previewFileBytes := taskAttachment.GetPreview(previewSize)
- if previewFileBytes != nil {
- return c.Blob(http.StatusOK, "image/png", previewFileBytes)
- }
+ attachment, preview, err := models.LoadTaskAttachmentForDownload(s, auth, taskAttachment.TaskID, taskAttachment.ID, previewSize)
+ if err != nil {
+ _ = s.Rollback()
+ return err
}
if err := s.Commit(); err != nil {
@@ -206,36 +135,6 @@ func GetTaskAttachment(c *echo.Context) error {
return err
}
- mimeToReturn := taskAttachment.File.Mime
- if mimeToReturn == "" {
- mimeToReturn = "application/octet-stream"
- }
-
- c.Response().Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
- "filename": taskAttachment.File.Name,
- }))
- c.Response().Header().Set("Content-Type", mimeToReturn)
- c.Response().Header().Set("Content-Length", strconv.FormatUint(taskAttachment.File.Size, 10))
- c.Response().Header().Set("Last-Modified", taskAttachment.File.Created.UTC().Format(http.TimeFormat))
- // Override the global no-store directive so browsers can cache attachments.
- // no-cache allows caching but requires revalidation via If-Modified-Since.
- c.Response().Header().Set("Cache-Control", "no-cache")
-
- if config.FilesType.GetString() == "s3" {
- // Check If-Modified-Since and return 304 if the file hasn't changed.
- // http.ServeContent handles this automatically for local files.
- if ifModSince := c.Request().Header.Get("If-Modified-Since"); ifModSince != "" {
- if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !taskAttachment.File.Created.UTC().After(t) {
- return c.NoContent(http.StatusNotModified)
- }
- }
-
- // s3 files cannot use http.ServeContent as it requires a Seekable file
- // so we stream the file content directly to the response
- _, err = io.Copy(c.Response(), taskAttachment.File.File)
- return err
- }
-
- http.ServeContent(c.Response(), c.Request(), taskAttachment.File.Name, taskAttachment.File.Created, taskAttachment.File.File.(io.ReadSeeker))
+ webfiles.WriteAttachmentDownload(c.Response(), c.Request(), attachment, preview)
return nil
}
diff --git a/pkg/routes/api/v1/testing.go b/pkg/routes/api/v1/testing.go
index 98f5aeca1..62d5f5206 100644
--- a/pkg/routes/api/v1/testing.go
+++ b/pkg/routes/api/v1/testing.go
@@ -22,10 +22,8 @@ import (
"net/http"
"code.vikunja.io/api/pkg/config"
- "code.vikunja.io/api/pkg/db"
- "code.vikunja.io/api/pkg/events"
- "code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"github.com/labstack/echo/v5"
)
@@ -63,36 +61,8 @@ func HandleTesting(c *echo.Context) error {
})
}
- // Wait for all async event handlers from the previous test to complete
- // before modifying the database. Without this, handlers hold SQLite
- // connections and starve this request's truncate/insert operations.
- events.WaitForPendingHandlers()
-
truncate := c.QueryParam("truncate")
- if truncate == "true" || truncate == "" {
- // When truncating certain tables, also truncate dependent tables
- // whose rows reference the truncated table by user/entity ID.
- // Without foreign key cascades, stale rows would persist and
- // pollute subsequent tests that reuse the same auto-increment IDs.
- dependentTables := map[string][]string{
- "users": {"notifications"},
- }
- if deps, ok := dependentTables[table]; ok {
- for _, dep := range deps {
- if err = db.RestoreAndTruncate(dep, nil); err != nil {
- log.Errorf("Error truncating dependent table %s: %v", dep, err)
- return c.JSON(http.StatusInternalServerError, map[string]interface{}{
- "error": true,
- "message": err.Error(),
- })
- }
- }
- }
- err = db.RestoreAndTruncate(table, content)
- } else {
- err = db.Restore(table, content)
- }
-
+ data, err := shared.ReplaceTableContents(table, content, truncate == "true" || truncate == "")
if err != nil {
log.Errorf("Error replacing table data: %v", err)
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
@@ -101,29 +71,6 @@ func HandleTesting(c *echo.Context) error {
})
}
- // License state is cached at startup; re-apply so tests take effect without a restart.
- if table == "license_status" {
- if err := license.ReloadFromCache(); err != nil {
- log.Errorf("Error reloading license from seeded cache: %v", err)
- return c.JSON(http.StatusInternalServerError, map[string]interface{}{
- "error": true,
- "message": err.Error(),
- })
- }
- }
-
- s := db.NewSession()
- defer s.Close()
- data := []map[string]interface{}{}
- err = s.Table(table).Find(&data)
- if err != nil {
- log.Errorf("Error fetching table data: %v", err)
- return c.JSON(http.StatusInternalServerError, map[string]interface{}{
- "error": true,
- "message": err.Error(),
- })
- }
-
return c.JSON(http.StatusCreated, data)
}
@@ -142,9 +89,7 @@ func HandleTestingTruncateAll(c *echo.Context) error {
return echo.ErrForbidden
}
- events.WaitForPendingHandlers()
-
- if err := db.TruncateAllTables(); err != nil {
+ if err := shared.TruncateAllTestingTables(); err != nil {
log.Errorf("Error truncating all tables: %v", err)
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"error": true,
@@ -152,11 +97,6 @@ func HandleTestingTruncateAll(c *echo.Context) error {
})
}
- // Reload after truncate; otherwise features enabled by a prior test outlive the now-empty license_status table.
- if err := license.ReloadFromCache(); err != nil {
- log.Errorf("Error reloading license after truncate: %v", err)
- }
-
return c.JSON(http.StatusOK, map[string]string{
"message": "ok",
})
diff --git a/pkg/routes/api/v1/user_confirm_email.go b/pkg/routes/api/v1/user_confirm_email.go
index e01865103..254d4142a 100644
--- a/pkg/routes/api/v1/user_confirm_email.go
+++ b/pkg/routes/api/v1/user_confirm_email.go
@@ -19,9 +19,8 @@ package v1
import (
"net/http"
- "code.vikunja.io/api/pkg/db"
-
"code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5"
)
@@ -44,17 +43,7 @@ func UserConfirmEmail(c *echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "No token provided.").Wrap(err)
}
- s := db.NewSession()
- defer s.Close()
-
- err := user.ConfirmEmail(s, &emailConfirm)
- if err != nil {
- _ = s.Rollback()
- return err
- }
-
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
+ if err := shared.ConfirmEmail(&emailConfirm); err != nil {
return err
}
diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go
index 3c07c9ebc..b01b1fdf3 100644
--- a/pkg/routes/api/v1/user_export.go
+++ b/pkg/routes/api/v1/user_export.go
@@ -19,14 +19,11 @@ package v1
import (
"io"
"net/http"
- "os"
"strconv"
- "time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
- "code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5"
@@ -97,7 +94,7 @@ func RequestUserDataExport(c *echo.Context) error {
return err
}
- events.DispatchPending(s)
+ events.DispatchPending(c.Request().Context(), s)
return c.JSON(http.StatusOK, models.Message{Message: "Successfully requested data export. We will send you an email when it's ready."})
}
@@ -127,28 +124,18 @@ func DownloadUserDataExport(c *echo.Context) error {
return err
}
- // Check if user has an export file
- exportNotFoundError := echo.NewHTTPError(http.StatusNotFound, "No user data export found.")
- if u.ExportFileID == 0 {
- return exportNotFoundError
+ exportFile, err := models.GetUserDataExportFile(u)
+ if err != nil {
+ if models.IsErrUserDataExportDoesNotExist(err) {
+ return echo.NewHTTPError(http.StatusNotFound, "No user data export found.")
+ }
+ return err
}
+ defer func() { _ = exportFile.File.Close() }()
- // Download
- exportFile := &files.File{ID: u.ExportFileID}
- err = exportFile.LoadFileMetaByID()
- if err != nil {
- if files.IsErrFileDoesNotExist(err) {
- return exportNotFoundError
- }
- return err
- }
- err = exportFile.LoadFileByID()
- if err != nil {
- if os.IsNotExist(err) {
- return exportNotFoundError
- }
- return err
- }
+ // Downloads must never be cached; no-cache overrides the global no-store
+ // directive while still allowing revalidation.
+ c.Response().Header().Set("Cache-Control", "no-cache")
if config.FilesType.GetString() == "s3" {
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+exportFile.Name+"\"")
@@ -163,19 +150,12 @@ func DownloadUserDataExport(c *echo.Context) error {
return nil
}
-type UserExportStatus struct {
- ID int64 `json:"id"`
- Size uint64 `json:"size"`
- Created time.Time `json:"created"`
- Expires time.Time `json:"expires"`
-}
-
// GetUserExportStatus returns metadata about the current user export if it exists
// @Summary Get current user data export
// @tags user
// @Produce json
// @Security JWTKeyAuth
-// @Success 200 {object} v1.UserExportStatus
+// @Success 200 {object} models.UserExportStatus
// @Router /user/export [get]
func GetUserExportStatus(c *echo.Context) error {
s := db.NewSession()
@@ -186,20 +166,12 @@ func GetUserExportStatus(c *echo.Context) error {
return err
}
- if u.ExportFileID == 0 {
- return c.JSON(http.StatusOK, struct{}{})
- }
-
- exportFile := &files.File{ID: u.ExportFileID}
- if err := exportFile.LoadFileMetaByID(); err != nil {
+ status, err := models.GetUserDataExportStatus(u)
+ if err != nil {
return err
}
-
- status := UserExportStatus{
- ID: exportFile.ID,
- Size: exportFile.Size,
- Created: exportFile.Created,
- Expires: exportFile.Created.Add(7 * 24 * time.Hour),
+ if status == nil {
+ return c.JSON(http.StatusOK, struct{}{})
}
return c.JSON(http.StatusOK, status)
diff --git a/pkg/routes/api/v1/user_list.go b/pkg/routes/api/v1/user_list.go
index db4a777a0..6bc6e392f 100644
--- a/pkg/routes/api/v1/user_list.go
+++ b/pkg/routes/api/v1/user_list.go
@@ -52,17 +52,12 @@ func UserList(c *echo.Context) error {
return err
}
- users, err := user.ListUsers(s, search, currentUser, nil)
+ users, err := user.SearchUsers(s, search, currentUser)
if err != nil {
_ = s.Rollback()
return err
}
- // Obfuscate the mailadresses
- for in := range users {
- users[in].Email = ""
- }
-
return c.JSON(http.StatusOK, users)
}
@@ -98,15 +93,6 @@ func ListUsersForProject(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- canRead, _, err := project.CanRead(s, auth)
- if err != nil {
- _ = s.Rollback()
- return err
- }
- if !canRead {
- return echo.ErrForbidden
- }
-
currentUser, err := user.GetCurrentUser(c)
if err != nil {
_ = s.Rollback()
@@ -114,11 +100,14 @@ func ListUsersForProject(c *echo.Context) error {
}
search := c.QueryParam("s")
- users, err := models.ListUsersFromProject(s, &project, currentUser, search)
+ users, canRead, err := models.SearchUsersForProject(s, &project, auth, currentUser, search)
if err != nil {
_ = s.Rollback()
return err
}
+ if !canRead {
+ return echo.ErrForbidden
+ }
if err := s.Commit(); err != nil {
_ = s.Rollback()
diff --git a/pkg/routes/api/v1/user_password_reset.go b/pkg/routes/api/v1/user_password_reset.go
index b91a28a7a..6c8090ba0 100644
--- a/pkg/routes/api/v1/user_password_reset.go
+++ b/pkg/routes/api/v1/user_password_reset.go
@@ -19,9 +19,8 @@ package v1
import (
"net/http"
- "code.vikunja.io/api/pkg/db"
-
"code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5"
)
@@ -49,22 +48,7 @@ func UserResetPassword(c *echo.Context) error {
return err
}
- s := db.NewSession()
- defer s.Close()
-
- userID, err := user.ResetPassword(s, &pwReset)
- if err != nil {
- _ = s.Rollback()
- return err
- }
-
- if err := models.DeleteAllUserSessions(s, userID); err != nil {
- _ = s.Rollback()
- return err
- }
-
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
+ if err := shared.ResetPassword(&pwReset); err != nil {
return err
}
@@ -93,17 +77,7 @@ func UserRequestResetPasswordToken(c *echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()).Wrap(err)
}
- s := db.NewSession()
- defer s.Close()
-
- err := user.RequestUserPasswordResetTokenByEmail(s, &pwTokenReset)
- if err != nil {
- _ = s.Rollback()
- return err
- }
-
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
+ if err := shared.RequestPasswordResetToken(&pwTokenReset); err != nil {
return err
}
diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go
index 9db52c88a..e9a90dc2f 100644
--- a/pkg/routes/api/v1/user_register.go
+++ b/pkg/routes/api/v1/user_register.go
@@ -21,20 +21,15 @@ import (
"net/http"
"code.vikunja.io/api/pkg/config"
- "code.vikunja.io/api/pkg/db"
- "code.vikunja.io/api/pkg/log"
- "code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/models"
- "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"github.com/labstack/echo/v5"
)
-type UserRegister struct {
- // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.
- Language string `json:"language" valid:"language"`
- user.APIUserPassword
-}
+// UserRegister is an alias for the shared registration input, kept so the v1
+// swagger annotation and any existing imports still resolve.
+type UserRegister = shared.UserRegister
// RegisterUser is the register handler
// @Summary Register
@@ -68,32 +63,10 @@ func RegisterUser(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."})
}
- s := db.NewSession()
- defer s.Close()
-
- newUser, err := models.RegisterUser(s, &user.User{
- Username: userIn.Username,
- Password: userIn.Password,
- Email: userIn.Email,
- Language: userIn.Language,
- })
+ newUser, err := shared.RegisterUser(c.Request().Context(), userIn)
if err != nil {
- _ = s.Rollback()
return err
}
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
- return err
- }
-
- // Bust the cached user count so the new registration shows up in metrics
- // immediately instead of after the regular cache expiry.
- if config.MetricsEnabled.GetBool() {
- if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil {
- log.Errorf("Could not invalidate user count metric: %s", err)
- }
- }
-
return c.JSON(http.StatusOK, newUser)
}
diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go
index 2efa9c0f0..049330411 100644
--- a/pkg/routes/api/v1/user_settings.go
+++ b/pkg/routes/api/v1/user_settings.go
@@ -26,7 +26,6 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
- "code.vikunja.io/api/pkg/modules/avatar"
user2 "code.vikunja.io/api/pkg/user"
)
@@ -36,35 +35,6 @@ type UserAvatarProvider struct {
AvatarProvider string `json:"avatar_provider"`
}
-// UserSettings holds all user settings
-type UserSettings struct {
- // The new name of the current user.
- Name string `json:"name"`
- // If enabled, sends email reminders of tasks to the user.
- EmailRemindersEnabled bool `json:"email_reminders_enabled"`
- // If true, this user can be found by their name or parts of it when searching for it.
- DiscoverableByName bool `json:"discoverable_by_name"`
- // If true, the user can be found when searching for their exact email.
- DiscoverableByEmail bool `json:"discoverable_by_email"`
- // If enabled, the user will get an email for their overdue tasks each morning.
- OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"`
- // The time when the daily summary of overdue tasks will be sent via email.
- OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"`
- // If a task is created without a specified project this value should be used. Applies
- // to tasks made directly in API and from clients.
- DefaultProjectID int64 `json:"default_project_id"`
- // The day when the week starts for this user. 0 = sunday, 1 = monday, etc.
- WeekStart int `json:"week_start" valid:"range(0|6)"`
- // The user's language
- Language string `json:"language"`
- // The user's time zone. Used to send task reminders in the time zone of the user.
- Timezone string `json:"timezone"`
- // Additional settings only used by the frontend
- FrontendSettings interface{} `json:"frontend_settings"`
- // Additional settings links as provided by openid
- ExtraSettingsLinks map[string]any `json:"extra_settings_links"`
-}
-
// GetUserAvatarProvider returns the currently set user avatar
// @Summary Return user avatar setting
// @Description Returns the current user's avatar setting.
@@ -135,29 +105,16 @@ func ChangeUserAvatarProvider(c *echo.Context) error {
return err
}
- oldProvider := user.AvatarProvider
-
- user.AvatarProvider = uap.AvatarProvider
-
- _, err = user2.UpdateUser(s, user, false)
- if err != nil {
+ if err := models.UpdateUserAvatarProvider(s, user, uap.AvatarProvider); err != nil {
_ = s.Rollback()
return err
}
- if user.AvatarProvider == "initials" {
- avatar.FlushAllCaches(user)
- }
-
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
- if oldProvider != user.AvatarProvider {
- avatar.FlushAllCaches(user)
- }
-
return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."})
}
@@ -167,13 +124,13 @@ func ChangeUserAvatarProvider(c *echo.Context) error {
// @Accept json
// @Produce json
// @Security JWTKeyAuth
-// @Param avatar body UserSettings true "The updated user settings"
+// @Param avatar body models.UserGeneralSettings true "The updated user settings"
// @Success 200 {object} models.Message
// @Failure 400 {object} web.HTTPError "Something's invalid."
// @Failure 500 {object} models.Message "Internal server error."
// @Router /user/settings/general [post]
func UpdateGeneralUserSettings(c *echo.Context) error {
- us := &UserSettings{}
+ us := &models.UserGeneralSettings{}
err := c.Bind(us)
if err != nil {
var he *echo.HTTPError
@@ -202,22 +159,7 @@ func UpdateGeneralUserSettings(c *echo.Context) error {
return err
}
- invalidateAvatar := user.AvatarProvider == "initials" && user.Name != us.Name
-
- user.Name = us.Name
- user.EmailRemindersEnabled = us.EmailRemindersEnabled
- user.DiscoverableByEmail = us.DiscoverableByEmail
- user.DiscoverableByName = us.DiscoverableByName
- user.OverdueTasksRemindersEnabled = us.OverdueTasksRemindersEnabled
- user.DefaultProjectID = us.DefaultProjectID
- user.WeekStart = us.WeekStart
- user.Language = us.Language
- user.Timezone = us.Timezone
- user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime
- user.FrontendSettings = us.FrontendSettings
-
- _, err = user2.UpdateUser(s, user, true)
- if err != nil {
+ if err := models.UpdateUserGeneralSettings(s, user, us); err != nil {
_ = s.Rollback()
return err
}
@@ -227,10 +169,6 @@ func UpdateGeneralUserSettings(c *echo.Context) error {
return err
}
- if invalidateAvatar {
- avatar.FlushAllCaches(user)
- }
-
return c.JSON(http.StatusOK, &models.Message{Message: "The settings were updated successfully."})
}
diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go
index d5a391267..655b0fb5c 100644
--- a/pkg/routes/api/v1/user_show.go
+++ b/pkg/routes/api/v1/user_show.go
@@ -20,7 +20,7 @@ import (
"net/http"
"time"
- "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
@@ -34,11 +34,11 @@ import (
type UserWithSettings struct {
user.User
- Settings *UserSettings `json:"settings"`
- DeletionScheduledAt time.Time `json:"deletion_scheduled_at"`
- IsLocalUser bool `json:"is_local_user"`
- AuthProvider string `json:"auth_provider"`
- IsAdmin bool `json:"is_admin"`
+ Settings *models.UserGeneralSettings `json:"settings"`
+ DeletionScheduledAt time.Time `json:"deletion_scheduled_at"`
+ IsLocalUser bool `json:"is_local_user"`
+ AuthProvider string `json:"auth_provider"`
+ IsAdmin bool `json:"is_admin"`
}
// UserShow gets all information about the current user
@@ -67,57 +67,17 @@ func UserShow(c *echo.Context) error {
}
us := &UserWithSettings{
- User: *u,
- Settings: &UserSettings{
- Name: u.Name,
- EmailRemindersEnabled: u.EmailRemindersEnabled,
- DiscoverableByName: u.DiscoverableByName,
- DiscoverableByEmail: u.DiscoverableByEmail,
- OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled,
- DefaultProjectID: u.DefaultProjectID,
- WeekStart: u.WeekStart,
- Language: u.Language,
- Timezone: u.Timezone,
- OverdueTasksRemindersTime: u.OverdueTasksRemindersTime,
- FrontendSettings: u.FrontendSettings,
- ExtraSettingsLinks: u.ExtraSettingsLinks,
- },
+ User: *u,
+ Settings: models.NewUserGeneralSettings(u),
DeletionScheduledAt: u.DeletionScheduledAt,
IsLocalUser: u.Issuer == user.IssuerLocal,
IsAdmin: u.IsAdmin,
}
- us.AuthProvider, err = getAuthProviderName(u)
+ us.AuthProvider, err = shared.GetAuthProviderName(u)
if err != nil {
return err
}
return c.JSON(http.StatusOK, us)
}
-
-func getAuthProviderName(u *user.User) (name string, err error) {
- if u.Issuer == user.IssuerLocal {
- return "local", nil
- }
-
- if u.Issuer == user.IssuerLDAP {
- return "ldap", nil
- }
-
- providers, err := openid.GetAllProviders()
- if err != nil {
- return "", err
- }
-
- for _, provider := range providers {
- issuerURL, err := provider.Issuer()
- if err != nil {
- return "", err
- }
- if issuerURL == u.Issuer {
- return provider.Name, nil
- }
- }
-
- return
-}
diff --git a/pkg/routes/api/v1/user_totp.go b/pkg/routes/api/v1/user_totp.go
index e3c0ae076..a3c9fc8c4 100644
--- a/pkg/routes/api/v1/user_totp.go
+++ b/pkg/routes/api/v1/user_totp.go
@@ -17,10 +17,8 @@
package v1
import (
- "bytes"
"errors"
"fmt"
- "image/jpeg"
"net/http"
"code.vikunja.io/api/pkg/db"
@@ -202,14 +200,7 @@ func UserTOTPQrCode(c *echo.Context) error {
}
defer s.Close()
- qrcode, err := user.GetTOTPQrCodeForUser(s, u)
- if err != nil {
- _ = s.Rollback()
- return err
- }
-
- buff := &bytes.Buffer{}
- err = jpeg.Encode(buff, qrcode, nil)
+ qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u)
if err != nil {
_ = s.Rollback()
return err
@@ -220,7 +211,7 @@ func UserTOTPQrCode(c *echo.Context) error {
return err
}
- return c.Blob(http.StatusOK, "image/jpeg", buff.Bytes())
+ return c.Blob(http.StatusOK, "image/jpeg", qrcode)
}
// UserTOTP returns the current totp implementation if any is enabled.
diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go
index ea1077075..7e03b250a 100644
--- a/pkg/routes/api/v1/user_update_email.go
+++ b/pkg/routes/api/v1/user_update_email.go
@@ -62,17 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) {
s := db.NewSession()
defer s.Close()
- emailUpdate.User, err = user.CheckUserCredentials(s, &user.Login{
- Username: emailUpdate.User.Username,
- Password: emailUpdate.Password,
- })
- if err != nil {
- _ = s.Rollback()
- return err
- }
-
- err = user.UpdateEmail(s, emailUpdate)
- if err != nil {
+ if err := user.ChangeUserEmail(c.Request().Context(), s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil {
_ = s.Rollback()
return err
}
diff --git a/pkg/routes/api/v1/user_update_password.go b/pkg/routes/api/v1/user_update_password.go
index 0172a21ec..87b372aff 100644
--- a/pkg/routes/api/v1/user_update_password.go
+++ b/pkg/routes/api/v1/user_update_password.go
@@ -63,26 +63,10 @@ func UserChangePassword(c *echo.Context) error {
return err
}
- if newPW.OldPassword == "" {
- return user.ErrEmptyOldPassword{}
- }
-
s := db.NewSession()
defer s.Close()
- // Check the current password
- if _, err = user.CheckUserCredentials(s, &user.Login{Username: doer.Username, Password: newPW.OldPassword}); err != nil {
- _ = s.Rollback()
- return err
- }
-
- // Update the password
- if err = user.UpdateUserPassword(s, doer, newPW.NewPassword); err != nil {
- _ = s.Rollback()
- return err
- }
-
- if err := models.DeleteAllUserSessions(s, doer.ID); err != nil {
+ if err := models.ChangeUserPassword(c.Request().Context(), s, doer, newPW.OldPassword, newPW.NewPassword); err != nil {
_ = s.Rollback()
return err
}
diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go
index 2203ab01d..9d424eb6e 100644
--- a/pkg/routes/api/v2/admin_projects.go
+++ b/pkg/routes/api/v2/admin_projects.go
@@ -21,6 +21,7 @@ import (
"fmt"
"net/http"
+ "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/web/handler"
@@ -31,6 +32,16 @@ type adminProjectListBody struct {
Body Paginated[*models.Project]
}
+type adminProjectBody struct {
+ Body *models.Project
+}
+
+// adminOwnerPatchBody reassigns a project's owner. owner_id is the only field;
+// the regular project-update endpoint refuses owner changes.
+type adminOwnerPatchBody struct {
+ OwnerID int64 `json:"owner_id" minimum:"1" doc:"The numeric ID of the user who should become the project's owner."`
+}
+
// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler.
func RegisterAdminProjectRoutes(api huma.API) {
tags := []string{"admin"}
@@ -43,6 +54,15 @@ func RegisterAdminProjectRoutes(api huma.API) {
Path: "/admin/projects",
Tags: tags,
}, adminProjectsList)
+
+ Register(api, huma.Operation{
+ OperationID: "admin-projects-patch-owner",
+ Summary: "Reassign a project's owner (admin)",
+ Description: "Reassigns a project to a new owner — the admin-only escape hatch the regular update endpoint does not allow. The new owner must be an active account that is not scheduled for deletion. Restricted to instance admins on a licensed instance.",
+ Method: http.MethodPatch,
+ Path: "/admin/projects/{id}/owner",
+ Tags: tags,
+ }, adminProjectsPatchOwner)
}
func init() { AddRouteRegistrar(RegisterAdminProjectRoutes) }
@@ -62,3 +82,28 @@ func adminProjectsList(ctx context.Context, in *ListParams) (*adminProjectListBo
}
return &adminProjectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
}
+
+func adminProjectsPatchOwner(_ context.Context, in *struct {
+ ID int64 `path:"id" doc:"The numeric ID of the project."`
+ Body adminOwnerPatchBody
+}) (*adminProjectBody, error) {
+ if in.ID < 1 {
+ return nil, translateDomainError(models.ErrProjectDoesNotExist{ID: in.ID})
+ }
+ if in.Body.OwnerID < 1 {
+ return nil, translateDomainError(models.ErrInvalidData{Message: "invalid body"})
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ p, err := models.ReassignProjectOwner(s, in.ID, in.Body.OwnerID)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &adminProjectBody{Body: p}, nil
+}
diff --git a/pkg/routes/api/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go
new file mode 100644
index 000000000..2724e433c
--- /dev/null
+++ b/pkg/routes/api/v2/admin_users.go
@@ -0,0 +1,206 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/routes/api/shared"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+ "xorm.io/xorm"
+)
+
+type adminOverviewBody struct {
+ Body *models.Overview
+}
+
+type adminUserBody struct {
+ Body *shared.AdminUser
+}
+
+// adminIsAdminPatchBody uses a pointer so an omitted is_admin leaves the flag unchanged
+// instead of silently demoting.
+type adminIsAdminPatchBody struct {
+ IsAdmin *bool `json:"is_admin" doc:"New admin flag. Omitting it leaves the current value unchanged."`
+}
+
+// adminStatusPatchBody uses a pointer so an omitted status leaves the account unchanged
+// instead of silently reactivating.
+type adminStatusPatchBody struct {
+ Status *user.Status `json:"status" doc:"New account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked). Omitting it leaves the current value unchanged."`
+}
+
+// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler.
+func RegisterAdminUserRoutes(api huma.API) {
+ tags := []string{"admin"}
+
+ Register(api, huma.Operation{
+ OperationID: "admin-overview",
+ Summary: "Admin overview",
+ Description: "Returns per-instance counts (users, projects, tasks, teams, shares) plus the current license snapshot. Restricted to instance admins on a licensed instance; unlicensed or non-admin callers get a 404, making the endpoint indistinguishable from one that is not registered.",
+ Method: http.MethodGet,
+ Path: "/admin/overview",
+ Tags: tags,
+ }, adminOverview)
+
+ Register(api, huma.Operation{
+ OperationID: "admin-users-create",
+ Summary: "Create a user (admin)",
+ Description: "Creates a local user account, bypassing the public-registration toggle. Honours the admin-only is_admin and skip_email_confirm fields. Restricted to instance admins on a licensed instance.",
+ Method: http.MethodPost,
+ Path: "/admin/users",
+ Tags: tags,
+ }, adminUsersCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "admin-users-patch-admin",
+ Summary: "Promote or demote a user (admin)",
+ Description: "Sets a user's instance-admin flag. The body field is a pointer: omitting is_admin leaves the flag unchanged. Demoting the last remaining admin is refused with 400.",
+ Method: http.MethodPatch,
+ Path: "/admin/users/{id}/admin",
+ Tags: tags,
+ }, adminUsersPatchAdmin)
+
+ Register(api, huma.Operation{
+ OperationID: "admin-users-patch-status",
+ Summary: "Set a user's status (admin)",
+ Description: "Changes a user's account status without requiring them to log in. The body field is a pointer: omitting status leaves it unchanged. Moving the last remaining admin out of Active is refused with 400.",
+ Method: http.MethodPatch,
+ Path: "/admin/users/{id}/status",
+ Tags: tags,
+ }, adminUsersPatchStatus)
+
+ Register(api, huma.Operation{
+ OperationID: "admin-users-delete",
+ Summary: "Delete a user (admin)",
+ Description: "Deletes a user. With mode=now the user is removed immediately. With mode=scheduled (the default) the user is scheduled for deletion through the email-confirmation self-deletion flow. Deleting the last remaining admin is refused with 400.",
+ Method: http.MethodDelete,
+ Path: "/admin/users/{id}",
+ Tags: tags,
+ }, adminUsersDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterAdminUserRoutes) }
+
+func adminOverview(_ context.Context, _ *struct{}) (*adminOverviewBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ overview, err := models.BuildOverview(s)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &adminOverviewBody{Body: overview}, nil
+}
+
+func adminUsersCreate(_ context.Context, in *struct{ Body models.CreateUserBody }) (*adminUserBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ newUser, err := models.CreateUserAsAdmin(s, &in.Body)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers.
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &adminUserBody{Body: shared.NewAdminUser(newUser, providers)}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request
+}
+
+func adminUsersPatchAdmin(_ context.Context, in *struct {
+ ID int64 `path:"id" doc:"The numeric ID of the user."`
+ Body adminIsAdminPatchBody
+}) (*adminUserBody, error) {
+ if in.Body.IsAdmin == nil {
+ return nil, translateDomainError(models.ErrInvalidData{Message: "is_admin is required"})
+ }
+ return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser.
+ return models.SetUserAdminFlag(s, in.ID, *in.Body.IsAdmin)
+ })
+}
+
+func adminUsersPatchStatus(_ context.Context, in *struct {
+ ID int64 `path:"id" doc:"The numeric ID of the user."`
+ Body adminStatusPatchBody
+}) (*adminUserBody, error) {
+ if in.Body.Status == nil {
+ return nil, translateDomainError(models.ErrInvalidData{Message: "status is required"})
+ }
+ newStatus := *in.Body.Status
+ if newStatus < user.StatusActive || newStatus > user.StatusAccountLocked {
+ return nil, translateDomainError(models.ErrInvalidData{Message: "invalid status"})
+ }
+ return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser.
+ return models.SetUserStatusAsAdmin(s, in.ID, newStatus)
+ })
+}
+
+func adminUsersDelete(_ context.Context, in *struct {
+ ID int64 `path:"id" doc:"The numeric ID of the user."`
+ Mode string `query:"mode" doc:"'now' deletes immediately; 'scheduled' (the default) triggers the email-confirmation self-deletion flow."`
+}) (*emptyBody, error) {
+ mode := in.Mode
+ if mode == "" {
+ mode = "scheduled"
+ }
+ if mode != "now" && mode != "scheduled" {
+ return nil, translateDomainError(models.ErrInvalidData{Message: "invalid mode, expected 'now' or 'scheduled'"})
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+ if err := models.DeleteUserAsAdmin(s, in.ID, mode); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
+
+// adminCommitUser runs a user-returning admin action in its own transaction and
+// renders the admin user view. The action does the load/guard/mutate against the
+// session (shared with v1 via the models layer); this owns the commit and response.
+func adminCommitUser(action func(s *xorm.Session) (*user.User, error)) (*adminUserBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ target, err := action(s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers.
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &adminUserBody{Body: shared.NewAdminUser(target, providers)}, nil
+}
diff --git a/pkg/routes/api/v2/auth_login.go b/pkg/routes/api/v2/auth_login.go
new file mode 100644
index 000000000..519fcaef1
--- /dev/null
+++ b/pkg/routes/api/v2/auth_login.go
@@ -0,0 +1,132 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/modules/humaecho5"
+ "code.vikunja.io/api/pkg/routes/api/shared"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/labstack/echo/v5"
+)
+
+// authTokenBody wraps the issued user JWT. The token is inlined rather than
+// embedding auth.Token because Huma derives schema names from the bare Go type
+// name and a top-level auth.Token body would collide with user.Token (the
+// caldav-token schema, also named "Token"). The refresh token is delivered out
+// of band as an HttpOnly cookie, so it is intentionally absent from the schema.
+type authTokenBody struct {
+ // Cache-Control: no-store keeps the access token out of any shared cache.
+ CacheControl string `header:"Cache-Control"`
+ Body struct {
+ Token string `json:"token" readOnly:"true" doc:"The short-lived JWT auth token. Send it as a bearer token on subsequent requests."`
+ }
+}
+
+// logoutBody confirms a successful logout.
+type logoutBody struct {
+ Body struct {
+ Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."`
+ OIDCLogoutURL string `json:"oidc_logout_url,omitempty" readOnly:"true" doc:"RP-Initiated Logout URL to redirect to for OpenID Connect sessions; empty otherwise."`
+ }
+}
+
+func init() { AddRouteRegistrar(RegisterLoginRoutes) }
+
+// RegisterLoginRoutes wires the local/LDAP login and logout endpoints. Login is
+// always registered (LDAP-only deployments still log in here); logout inherits
+// the global JWT auth.
+func RegisterLoginRoutes(api huma.API) {
+ tags := []string{"auth"}
+
+ Register(api, huma.Operation{
+ OperationID: "auth-login",
+ Summary: "Login",
+ Description: "Logs a user in with username and password (and a TOTP passcode when 2FA is enabled), returning a short-lived JWT. A long-lived refresh token is set as an HttpOnly cookie scoped to the refresh endpoint.",
+ Method: http.MethodPost,
+ Path: "/login",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ Security: publicSecurity,
+ }, authLogin)
+
+ Register(api, huma.Operation{
+ OperationID: "auth-logout",
+ Summary: "Logout",
+ Description: "Destroys the current session server-side and clears the refresh-token cookie. A no-op for API tokens and link shares, which carry no session.",
+ Method: http.MethodPost,
+ Path: "/logout",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, authLogout)
+}
+
+func authLogin(ctx context.Context, in *struct{ Body user.Login }) (*authTokenBody, error) {
+ u, err := shared.AuthenticateUserCredentials(ctx, &in.Body)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ deviceInfo, ipAddress := requestClientInfo(ctx)
+ token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken, nil)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ if ec := echoContextFromCtx(ctx); ec != nil {
+ auth.WriteUserAuthCookies(ec, token)
+ }
+
+ out := &authTokenBody{CacheControl: "no-store"}
+ out.Body.Token = token.AccessToken
+ return out, nil
+}
+
+func authLogout(ctx context.Context, _ *struct{}) (*logoutBody, error) {
+ var sid string
+ if ec := echoContextFromCtx(ctx); ec != nil {
+ auth.ClearRefreshTokenCookie(ec)
+ sid = auth.SessionIDFromContext(ec)
+ }
+
+ oidcLogoutURL, err := shared.LogoutSession(sid) //nolint:contextcheck // OIDC provider discovery resolves from a cached, context-less map and runs on its own background context, like the OIDC callback.
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ out := &logoutBody{}
+ out.Body.Message = "Successfully logged out."
+ out.Body.OIDCLogoutURL = oidcLogoutURL
+ return out, nil
+}
+
+// echoContextFromCtx pulls the underlying *echo.Context off a Huma request
+// context so a handler can set cookies and headers the OpenAPI schema does not
+// model (the refresh-token cookie). Returns nil when the context carries no echo
+// context (it always does under the humaecho5 adapter).
+func echoContextFromCtx(ctx context.Context) *echo.Context {
+ ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
+ if !ok || ec == nil {
+ return nil
+ }
+ return ec
+}
diff --git a/pkg/routes/api/v2/auth_openid.go b/pkg/routes/api/v2/auth_openid.go
new file mode 100644
index 000000000..5e029a184
--- /dev/null
+++ b/pkg/routes/api/v2/auth_openid.go
@@ -0,0 +1,93 @@
+// 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 apiv2
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+func init() { AddRouteRegistrar(RegisterOpenIDRoutes) }
+
+// RegisterOpenIDRoutes wires the OpenID Connect callback endpoint. It is only
+// registered when OpenID is enabled; individual providers are still resolved per
+// request, so an unknown provider key 404s even when others are configured.
+func RegisterOpenIDRoutes(api huma.API) {
+ if !config.AuthOpenIDEnabled.GetBool() {
+ return
+ }
+
+ Register(api, huma.Operation{
+ OperationID: "auth-openid-callback",
+ Summary: "Authenticate with OpenID Connect",
+ Description: "Exchanges the authorization code returned by an OpenID Connect provider for a Vikunja JWT, creating or updating the matching user. A long-lived refresh token is set as an HttpOnly cookie. When the resolved user has 2FA enabled, the call returns 412 and must be retried with totp_passcode set.",
+ Method: http.MethodPost,
+ Path: "/auth/openid/{provider}/callback",
+ DefaultStatus: http.StatusOK,
+ Tags: []string{"auth"},
+ Security: publicSecurity,
+ }, authOpenIDCallback)
+}
+
+func authOpenIDCallback(ctx context.Context, in *struct {
+ Provider string `path:"provider" doc:"The OpenID Connect provider key as returned by the /info endpoint."`
+ Body openid.Callback `doc:"The provider callback, carrying the authorization code."`
+}) (*authTokenBody, error) {
+ u, oidcData, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback.
+ if err != nil {
+ return nil, translateOpenIDError(err)
+ }
+
+ deviceInfo, ipAddress := requestClientInfo(ctx)
+ // OIDC logins are not "remember me" sessions; v1 always issues a short one.
+ token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false, oidcData)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ if ec := echoContextFromCtx(ctx); ec != nil {
+ auth.WriteUserAuthCookies(ec, token)
+ }
+
+ out := &authTokenBody{CacheControl: "no-store"}
+ out.Body.Token = token.AccessToken
+ return out, nil
+}
+
+// translateOpenIDError maps OIDC callback errors to RFC 9457 responses.
+// ErrOpenIDBadRequestWithDetails carries no HTTP semantics of its own (v1 renders
+// it with a bespoke {message, details} body), so v2 maps it to a 400 with the
+// provider detail attached as a structured error detail rather than porting the
+// bespoke shape. Everything else flows through translateDomainError.
+func translateOpenIDError(err error) error {
+ var detailedErr *models.ErrOpenIDBadRequestWithDetails
+ if errors.As(err, &detailedErr) {
+ return huma.Error400BadRequest(detailedErr.Message, &huma.ErrorDetail{
+ Message: "The identity provider rejected the request.",
+ Value: detailedErr.Details,
+ })
+ }
+ return translateDomainError(err)
+}
diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go
new file mode 100644
index 000000000..c41fb162d
--- /dev/null
+++ b/pkg/routes/api/v2/auth_public.go
@@ -0,0 +1,183 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/routes/api/shared"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// publicSecurity is the empty security requirement that opts an operation out of
+// the globally-applied JWT/API-token auth. The matching Echo path must also be
+// listed in unauthenticatedAPIPaths so the token middleware lets it through.
+var publicSecurity = []map[string][]string{}
+
+// registerUserBody is the response wrapper for the registration endpoint.
+type registerUserBody struct {
+ Body *user.User
+}
+
+// messageBody carries a human-readable confirmation for endpoints that report
+// success without returning a resource (password reset, email confirm).
+type messageBody struct {
+ Body struct {
+ Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."`
+ }
+}
+
+// linkShareTokenBody wraps the issued link-share auth token and its share.
+type linkShareTokenBody struct {
+ Body *shared.LinkShareToken
+}
+
+func init() { AddRouteRegistrar(RegisterPublicAuthRoutes) }
+
+// RegisterPublicAuthRoutes wires the unauthenticated local-account flows
+// (registration, password reset, email confirmation) and the link-share auth
+// endpoint. The local-account flows mirror v1 by only registering when local
+// auth is enabled; the link-share endpoint follows ServiceEnableLinkSharing.
+func RegisterPublicAuthRoutes(api huma.API) {
+ if config.AuthLocalEnabled.GetBool() {
+ registerLocalAuthRoutes(api)
+ }
+
+ if config.ServiceEnableLinkSharing.GetBool() {
+ Register(api, huma.Operation{
+ OperationID: "auth-link-share",
+ Summary: "Get an auth token for a link share",
+ Description: "Exchanges a link share's public hash (and password, for password-protected shares) for a JWT auth token scoped to the shared project.",
+ Method: http.MethodPost,
+ Path: "/shares/{share}/auth",
+ DefaultStatus: http.StatusOK,
+ Tags: []string{"sharing"},
+ Security: publicSecurity,
+ }, authLinkShare)
+ }
+}
+
+func registerLocalAuthRoutes(api huma.API) {
+ authTags := []string{"auth"}
+
+ // Registration is its own static-config gate on top of local auth: when it
+ // is disabled the route simply isn't registered (a request then 404s as an
+ // unknown route), rather than registering it and rejecting per request.
+ if config.ServiceEnableRegistration.GetBool() {
+ Register(api, huma.Operation{
+ OperationID: "auth-register",
+ Summary: "Register",
+ Description: "Creates a new local user account.",
+ Method: http.MethodPost,
+ Path: "/register",
+ Tags: authTags,
+ Security: publicSecurity,
+ }, authRegister)
+ }
+
+ Register(api, huma.Operation{
+ OperationID: "auth-password-token",
+ Summary: "Request a password reset token",
+ Description: "Requests a token to reset the password for the account with the given email. The token is sent to that email; the response is the same whether or not an account exists.",
+ Method: http.MethodPost,
+ Path: "/user/password/token",
+ DefaultStatus: http.StatusOK,
+ Tags: authTags,
+ Security: publicSecurity,
+ }, authRequestPasswordToken)
+
+ Register(api, huma.Operation{
+ OperationID: "auth-password-reset",
+ Summary: "Reset a password",
+ Description: "Sets a new password using a previously issued reset token. All of the user's existing sessions are invalidated.",
+ Method: http.MethodPost,
+ Path: "/user/password/reset",
+ DefaultStatus: http.StatusOK,
+ Tags: authTags,
+ Security: publicSecurity,
+ }, authResetPassword)
+
+ Register(api, huma.Operation{
+ OperationID: "auth-confirm-email",
+ Summary: "Confirm an email address",
+ Description: "Confirms the email address of a newly registered user using the token sent to that email.",
+ Method: http.MethodPost,
+ Path: "/user/confirm",
+ DefaultStatus: http.StatusOK,
+ Tags: authTags,
+ Security: publicSecurity,
+ }, authConfirmEmail)
+}
+
+func authRegister(ctx context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) {
+ newUser, err := shared.RegisterUser(ctx, &in.Body)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return ®isterUserBody{Body: newUser}, nil
+}
+
+func authRequestPasswordToken(_ context.Context, in *struct{ Body user.PasswordTokenRequest }) (*messageBody, error) {
+ if err := shared.RequestPasswordResetToken(&in.Body); err != nil {
+ return nil, translateDomainError(err)
+ }
+ out := &messageBody{}
+ out.Body.Message = "Token was sent."
+ return out, nil
+}
+
+func authResetPassword(_ context.Context, in *struct{ Body user.PasswordReset }) (*messageBody, error) {
+ if err := shared.ResetPassword(&in.Body); err != nil {
+ return nil, translateDomainError(err)
+ }
+ out := &messageBody{}
+ out.Body.Message = "The password was updated successfully."
+ return out, nil
+}
+
+func authConfirmEmail(_ context.Context, in *struct{ Body user.EmailConfirm }) (*messageBody, error) {
+ if err := shared.ConfirmEmail(&in.Body); err != nil {
+ return nil, translateDomainError(err)
+ }
+ out := &messageBody{}
+ out.Body.Message = "The email was confirmed successfully."
+ return out, nil
+}
+
+func authLinkShare(_ context.Context, in *struct {
+ Share string `path:"share" doc:"The public hash of the link share."`
+ // Pointer so the body is optional: shares without a password are
+ // authenticated with no body at all.
+ Body *struct {
+ Password string `json:"password" doc:"The password for password-protected link shares. Ignored for shares without a password."`
+ }
+}) (*linkShareTokenBody, error) {
+ var password string
+ if in.Body != nil {
+ password = in.Body.Password
+ }
+
+ token, err := shared.AuthenticateLinkShare(in.Share, password)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &linkShareTokenBody{Body: token}, nil
+}
diff --git a/pkg/routes/api/v2/auth_refresh.go b/pkg/routes/api/v2/auth_refresh.go
new file mode 100644
index 000000000..d264da7c1
--- /dev/null
+++ b/pkg/routes/api/v2/auth_refresh.go
@@ -0,0 +1,75 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/modules/auth"
+ user2 "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+func init() { AddRouteRegistrar(RegisterRefreshTokenRoutes) }
+
+// RegisterRefreshTokenRoutes wires the refresh-token endpoint. It authenticates
+// via the HttpOnly refresh cookie rather than a JWT, so it is a public operation.
+func RegisterRefreshTokenRoutes(api huma.API) {
+ Register(api, huma.Operation{
+ OperationID: "auth-refresh-token",
+ Summary: "Refresh user token",
+ Description: "Exchanges the refresh-token cookie for a new short-lived JWT. The refresh token is rotated on every call, so the previous one stops working. A new HttpOnly refresh cookie is set on the response.",
+ Method: http.MethodPost,
+ Path: "/user/token/refresh",
+ DefaultStatus: http.StatusOK,
+ Tags: []string{"auth"},
+ Security: publicSecurity,
+ }, authRefreshToken)
+}
+
+func authRefreshToken(ctx context.Context, _ *struct{}) (*authTokenBody, error) {
+ ec := echoContextFromCtx(ctx)
+ if ec == nil {
+ return nil, huma.Error401Unauthorized("No refresh token provided.")
+ }
+
+ cookie, err := ec.Cookie(auth.RefreshTokenCookieName)
+ if err != nil || cookie.Value == "" {
+ return nil, huma.Error401Unauthorized("No refresh token provided.")
+ }
+
+ result, err := auth.RefreshSession(cookie.Value)
+ if err != nil {
+ if user2.IsErrUserStatusError(err) {
+ auth.ClearRefreshTokenCookie(ec)
+ }
+ return nil, translateDomainError(err)
+ }
+
+ cookieMaxAge := int(config.ServiceJWTTTL.GetInt64())
+ if result.IsLongSession {
+ cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64())
+ }
+ auth.SetRefreshTokenCookie(ec, result.NewRefreshToken, cookieMaxAge)
+
+ out := &authTokenBody{CacheControl: "no-store"}
+ out.Body.Token = result.AccessToken
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go
new file mode 100644
index 000000000..4d7b5befe
--- /dev/null
+++ b/pkg/routes/api/v2/backgrounds.go
@@ -0,0 +1,407 @@
+// 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 apiv2
+
+import (
+ "context"
+ "io"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/background"
+ backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler"
+ "code.vikunja.io/api/pkg/modules/background/unsplash"
+ "code.vikunja.io/api/pkg/modules/humaecho5"
+ webfiles "code.vikunja.io/api/pkg/web/files"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+type backgroundSearchBody struct {
+ Body Paginated[*background.Image]
+}
+
+// RegisterBackgroundRoutes wires the project-background actions onto the Huma
+// API. BackgroundsEnabled / BackgroundsUnsplashEnabled are static config, so the
+// registrar early-returns instead of gating per request.
+func RegisterBackgroundRoutes(api huma.API) {
+ if !config.BackgroundsEnabled.GetBool() {
+ return
+ }
+
+ tags := []string{"project"}
+
+ Register(api, huma.Operation{
+ OperationID: "projects-background-delete",
+ Summary: "Remove a project background",
+ Description: "Removes a project's background, whichever provider set it. Succeeds even when the project has no background. Requires write access to the project. Returns the updated project.",
+ Method: http.MethodDelete,
+ Path: "/projects/{project}/background",
+ // Return the updated project with 200, not the wrapper's DELETE default 204.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, backgroundRemove)
+
+ Register(api, huma.Operation{
+ OperationID: "projects-background-get",
+ Summary: "Get a project background",
+ Description: "Streams a project's background image, whichever provider set it. Requires read access to the project. Always served as image/jpeg with a revalidation Last-Modified header, so a conditional If-Modified-Since request gets a 304. Returns 404 when the project has no background.",
+ Method: http.MethodGet,
+ Path: "/projects/{project}/background",
+ Tags: tags,
+ // Spell out the binary response; the default would be modeled as JSON.
+ Responses: map[string]*huma.Response{
+ "200": {
+ Description: "The project background as a jpeg image.",
+ Content: map[string]*huma.MediaType{
+ "image/jpeg": {
+ Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
+ },
+ },
+ },
+ },
+ }, backgroundGet)
+
+ if config.BackgroundsUploadEnabled.GetBool() {
+ Register(api, huma.Operation{
+ OperationID: "projects-background-upload",
+ Summary: "Upload a project background",
+ Description: "Uploads an image via multipart/form-data under the \"background\" field and sets it as the project's background. Requires write access to the project. The image is resized server-side and stored as JPEG; it replaces any previous background (idempotent replace, hence PUT). Returns the updated project.",
+ Method: http.MethodPut,
+ Path: "/projects/{project}/backgrounds/upload",
+ // Return the updated project with 200, the natural code for an idempotent PUT.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes.
+ // #nosec G115 - configured value won't exceed int64 max in practice.
+ MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024,
+ }, backgroundUpload)
+ }
+
+ if config.BackgroundsUnsplashEnabled.GetBool() {
+ Register(api, huma.Operation{
+ OperationID: "backgrounds-unsplash-search",
+ Summary: "Search Unsplash backgrounds",
+ Description: "Searches Unsplash for background images. With an empty query it returns the featured wallpaper collection. Results are paginated by Unsplash; total counts are not available.",
+ Method: http.MethodGet,
+ Path: "/backgrounds/unsplash/search",
+ Tags: tags,
+ }, backgroundUnsplashSearch)
+
+ Register(api, huma.Operation{
+ OperationID: "projects-background-unsplash-set",
+ Summary: "Set an Unsplash image as project background",
+ Description: "Sets a previously searched Unsplash image as the project's background, identified by the image id from the search results. Requires write access to the project.",
+ Method: http.MethodPut,
+ Path: "/projects/{project}/backgrounds/unsplash",
+ Tags: tags,
+ }, backgroundUnsplashSet)
+
+ unsplashProxyResponses := map[string]*huma.Response{
+ "200": {
+ Description: "The proxied Unsplash image as a jpeg image.",
+ Content: map[string]*huma.MediaType{
+ "image/jpeg": {
+ Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
+ },
+ },
+ },
+ }
+
+ Register(api, huma.Operation{
+ OperationID: "backgrounds-unsplash-image",
+ Summary: "Proxy a full-resolution Unsplash image",
+ Description: "Proxies the full-resolution Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.",
+ Method: http.MethodGet,
+ Path: "/backgrounds/unsplash/images/{image}",
+ Tags: tags,
+ // Spell out the binary response; the default would be modeled as JSON.
+ Responses: unsplashProxyResponses,
+ }, backgroundUnsplashImage)
+
+ Register(api, huma.Operation{
+ OperationID: "backgrounds-unsplash-thumb",
+ Summary: "Proxy an Unsplash image thumbnail",
+ Description: "Proxies a thumbnail (max width 200px) of the Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.",
+ Method: http.MethodGet,
+ Path: "/backgrounds/unsplash/images/{image}/thumb",
+ Tags: tags,
+ // Spell out the binary response; the default would be modeled as JSON.
+ Responses: unsplashProxyResponses,
+ }, backgroundUnsplashThumb)
+ }
+}
+
+func init() { AddRouteRegistrar(RegisterBackgroundRoutes) }
+
+func backgroundUnsplashSearch(ctx context.Context, in *struct {
+ Q string `query:"q" doc:"Search query; empty returns the featured wallpaper collection."`
+ Page int64 `query:"page" default:"1" minimum:"1" doc:"1-based page number."`
+}) (*backgroundSearchBody, error) {
+ if _, err := authFromCtx(ctx); err != nil {
+ return nil, err
+ }
+
+ page := in.Page
+ if page < 1 {
+ page = 1
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ p := &unsplash.Provider{}
+ result, err := p.Search(s, in.Q, page)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ // Unsplash paginates server-side and p.Search discards the total, so the
+ // envelope's total is just this page's length (v1 returned a bare array).
+ return &backgroundSearchBody{Body: NewPaginated(result, int64(len(result)), int(page), len(result))}, nil
+}
+
+func backgroundUnsplashSet(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ Body background.Image
+}) (*singleBody[models.Project], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ project := &models.Project{ID: in.ProjectID}
+ can, err := project.CanUpdate(s, a)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if !can {
+ _ = s.Rollback()
+ return nil, huma.Error403Forbidden("forbidden")
+ }
+ project, err = models.GetProjectSimpleByID(s, in.ProjectID)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ p := &unsplash.Provider{}
+ if err := p.Set(s, &in.Body, project, a); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := project.ReadOne(s, a); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[models.Project]{Body: project}, nil
+}
+
+type backgroundUploadInput struct {
+ ProjectID int64 `path:"project" doc:"The id of the project to set the background on."`
+ // Allow-list mirrors the formats background uploads can actually be decoded as
+ // (handler.ValidateAndSaveBackgroundUpload's allowedImageMimes); octet-stream covers
+ // programmatic clients. Huma's MimeTypeValidator rejects the part pre-handler, so the
+ // byte-level image check in the shared function is the real gate.
+ RawBody huma.MultipartFormFiles[struct {
+ Background huma.FormFile `form:"background" contentType:"image/jpeg,image/png,image/gif,image/bmp,image/tiff,image/webp,application/octet-stream" required:"true" doc:"The background image to upload. Must be a decodable raster image (JPEG, PNG, GIF, BMP, TIFF or WebP); it is resized server-side and re-encoded as JPEG."`
+ }]
+}
+
+// backgroundUpload owns auth, the session and the permission check because there is
+// no handler.Do* for multipart uploads (see the api-v2-routes skill's "Non-CRUDable
+// / custom routes" section). It shares its body with v1 via
+// handler.ValidateAndSaveBackgroundUpload.
+func backgroundUpload(ctx context.Context, in *backgroundUploadInput) (*singleBody[models.Project], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ project := &models.Project{ID: in.ProjectID}
+ can, err := project.CanUpdate(s, a)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if !can {
+ _ = s.Rollback()
+ return nil, huma.Error403Forbidden("forbidden")
+ }
+ project, err = models.GetProjectSimpleByID(s, in.ProjectID)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ file := in.RawBody.Data().Background
+ defer func() { _ = file.Close() }()
+
+ if err := backgroundHandler.ValidateAndSaveBackgroundUpload(s, a, project, file, file.Filename, uint64(file.Size)); err != nil {
+ _ = s.Rollback()
+ if backgroundHandler.IsErrFileIsNoImage(err) || backgroundHandler.IsErrFileUnsupportedImageFormat(err) {
+ return nil, huma.Error400BadRequest(err.Error())
+ }
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[models.Project]{Body: project}, nil
+}
+
+// backgroundGet owns auth, the session and the permission check because there is no
+// handler.Do* for a file body. CanRead hydrates the project (including its
+// BackgroundFileID), which the shared loader then needs.
+func backgroundGet(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project" doc:"The id of the project whose background to fetch."`
+}) (*huma.StreamResponse, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ project := &models.Project{ID: in.ProjectID}
+ can, _, err := project.CanRead(s, a)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if !can {
+ _ = s.Rollback()
+ return nil, huma.Error403Forbidden("forbidden")
+ }
+
+ bgFile, stat, err := backgroundHandler.LoadProjectBackgroundForDownload(s, project)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ // The file reader comes from object storage, not the DB session, so it stays
+ // valid after the commit; the StreamResponse callback runs after this returns.
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ // The stream callback (which closes the reader) won't run on this error path.
+ _ = bgFile.File.Close()
+ return nil, translateDomainError(err)
+ }
+
+ return &huma.StreamResponse{Body: func(hctx huma.Context) {
+ defer func() { _ = bgFile.File.Close() }()
+ c := humaecho5.Unwrap(hctx)
+ webfiles.WriteProjectBackground((*c).Response(), (*c).Request(), bgFile, stat)
+ }}, nil
+}
+
+func backgroundUnsplashImage(ctx context.Context, in *struct {
+ ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."`
+}) (*huma.StreamResponse, error) {
+ if _, err := authFromCtx(ctx); err != nil {
+ return nil, err
+ }
+ body, err := unsplash.FetchUnsplashImageByID(in.ImageID)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return streamUnsplashProxy(body), nil
+}
+
+func backgroundUnsplashThumb(ctx context.Context, in *struct {
+ ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."`
+}) (*huma.StreamResponse, error) {
+ if _, err := authFromCtx(ctx); err != nil {
+ return nil, err
+ }
+ body, err := unsplash.FetchUnsplashThumbByID(in.ImageID)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return streamUnsplashProxy(body), nil
+}
+
+// streamUnsplashProxy copies the open upstream Unsplash body to the response as
+// image/jpeg and closes it, mirroring v1's c.Stream.
+func streamUnsplashProxy(body io.ReadCloser) *huma.StreamResponse {
+ return &huma.StreamResponse{Body: func(hctx huma.Context) {
+ defer func() { _ = body.Close() }()
+ c := humaecho5.Unwrap(hctx)
+ resp := (*c).Response()
+ resp.Header().Set("Content-Type", "image/jpg")
+ resp.WriteHeader(http.StatusOK)
+ _, _ = io.Copy(resp, body)
+ }}
+}
+
+func backgroundRemove(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+}) (*singleBody[models.Project], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ project := &models.Project{ID: in.ProjectID}
+ can, err := project.CanUpdate(s, a)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if !can {
+ _ = s.Rollback()
+ return nil, huma.Error403Forbidden("forbidden")
+ }
+
+ if err := project.DeleteBackgroundFileIfExists(s); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := models.ClearProjectBackground(s, project.ID); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[models.Project]{Body: project}, nil
+}
diff --git a/pkg/routes/api/v2/bulk_task.go b/pkg/routes/api/v2/bulk_task.go
new file mode 100644
index 000000000..be5e4b31e
--- /dev/null
+++ b/pkg/routes/api/v2/bulk_task.go
@@ -0,0 +1,61 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterBulkTaskRoutes wires the bulk task update action onto the Huma API.
+//
+// BulkTask is a CRUDable Update, so the handler reuses handler.DoUpdate; its
+// CanUpdate fans the write check out across every project the involved tasks
+// belong to, so a single project the user can't write to rejects the request.
+func RegisterBulkTaskRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-bulk-update",
+ Summary: "Bulk update tasks",
+ Description: "Applies the fields named in `fields` from `values` to every task in `task_ids`. The user needs write access to every project the involved tasks belong to; if write is missing on even one, the whole request is rejected and nothing is changed. Returns the updated tasks.",
+ Method: http.MethodPut,
+ Path: "/tasks/bulk",
+ Tags: tags,
+ }, tasksBulkUpdate)
+}
+
+func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) }
+
+func tasksBulkUpdate(ctx context.Context, in *struct {
+ Body models.BulkTask
+}) (*singleBody[models.BulkTask], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ bt := &in.Body
+ if err := handler.DoUpdate(ctx, bt, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.BulkTask]{Body: bt}, nil
+}
diff --git a/pkg/routes/api/v2/caldav_tokens.go b/pkg/routes/api/v2/caldav_tokens.go
new file mode 100644
index 000000000..b8cfbc19c
--- /dev/null
+++ b/pkg/routes/api/v2/caldav_tokens.go
@@ -0,0 +1,121 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// CalDAV tokens are scoped to the authenticated user, not a CRUDable resource:
+// there is no per-token Can* method, so these handlers own their own user lookup
+// (user.GetFromAuth refuses link shares) and session/commit lives in the user package.
+
+type caldavTokenListBody struct {
+ Body Paginated[*user.Token]
+}
+
+type caldavTokenBody struct {
+ Body *user.Token
+}
+
+// RegisterCalDAVTokenRoutes wires the current user's CalDAV token operations onto the Huma API.
+func RegisterCalDAVTokenRoutes(api huma.API) {
+ tags := []string{"user"}
+
+ Register(api, huma.Operation{
+ OperationID: "caldav-tokens-create",
+ Summary: "Generate a CalDAV token",
+ Description: "Generates a CalDAV token for the authenticated user. The clear-text token is returned only in this response and can never be retrieved again. Link shares cannot have CalDAV tokens.",
+ Method: http.MethodPost,
+ Path: "/user/settings/token/caldav",
+ Tags: tags,
+ }, caldavTokensCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "caldav-tokens-list",
+ Summary: "List CalDAV tokens",
+ Description: "Returns the authenticated user's CalDAV tokens. Only the id and creation date are returned — never the token value, which is shown once on creation.",
+ Method: http.MethodGet,
+ Path: "/user/settings/token/caldav",
+ Tags: tags,
+ }, caldavTokensList)
+
+ Register(api, huma.Operation{
+ OperationID: "caldav-tokens-delete",
+ Summary: "Delete a CalDAV token",
+ Description: "Deletes one of the authenticated user's CalDAV tokens by id. Tokens of other users are out of scope and cannot be deleted.",
+ Method: http.MethodDelete,
+ Path: "/user/settings/token/caldav/{id}",
+ Tags: tags,
+ }, caldavTokensDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterCalDAVTokenRoutes) }
+
+func caldavTokensCreate(ctx context.Context, _ *struct{}) (*caldavTokenBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ token, err := user.GenerateNewCaldavToken(u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &caldavTokenBody{Body: token}, nil
+}
+
+func caldavTokensList(ctx context.Context, in *ListParams) (*caldavTokenListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ tokens, err := user.GetCaldavTokens(u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &caldavTokenListBody{Body: NewPaginated(tokens, int64(len(tokens)), in.Page, in.PerPage)}, nil
+}
+
+func caldavTokensDelete(ctx context.Context, in *struct {
+ ID int64 `path:"id" doc:"The numeric id of the CalDAV token to delete."`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ if err := user.DeleteCaldavTokenByID(u, in.ID); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/routes/api/v2/errors.go b/pkg/routes/api/v2/errors.go
index 3292b2e2b..24b73a19d 100644
--- a/pkg/routes/api/v2/errors.go
+++ b/pkg/routes/api/v2/errors.go
@@ -28,6 +28,7 @@ import (
"code.vikunja.io/api/pkg/web"
"github.com/danielgtaylor/huma/v2"
+ "github.com/labstack/echo/v5"
)
// authFromCtx retrieves the authed user from a Huma handler context,
@@ -80,6 +81,17 @@ func translateDomainError(err error) error {
}
return se
}
+ // Shared transport-agnostic cores (e.g. auth.RefreshSession) signal HTTP
+ // semantics with *echo.HTTPError. v1 lets echo's error handler render it;
+ // without this it would fall through as a 500 on v2.
+ var he *echo.HTTPError
+ if errors.As(err, &he) {
+ msg := he.Message
+ if msg == "" {
+ msg = http.StatusText(he.Code)
+ }
+ return huma.NewError(he.Code, msg)
+ }
return err
}
diff --git a/pkg/routes/api/v2/health.go b/pkg/routes/api/v2/health.go
new file mode 100644
index 000000000..674dc7b85
--- /dev/null
+++ b/pkg/routes/api/v2/health.go
@@ -0,0 +1,61 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/health"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+type healthBody struct {
+ Body struct {
+ Status string `json:"status" doc:"\"OK\" when the service and its dependencies are reachable." example:"OK"`
+ }
+}
+
+// RegisterHealthRoutes wires the public healthcheck endpoint onto the Huma API.
+func RegisterHealthRoutes(api huma.API) {
+ Register(api, huma.Operation{
+ OperationID: "health",
+ Summary: "Healthcheck",
+ Description: "Reports whether the service and its dependencies (database) are reachable. Returns 200 with status \"OK\" when healthy, 500 otherwise. Public — no authentication required.",
+ Method: http.MethodGet,
+ Path: "/health",
+ Tags: []string{"service"},
+ // Public: opt out of the globally-applied auth. The path is also listed
+ // in unauthenticatedAPIPaths so the token middleware lets it through.
+ Security: []map[string][]string{},
+ }, healthcheck)
+}
+
+func init() { AddRouteRegistrar(RegisterHealthRoutes) }
+
+func healthcheck(_ context.Context, _ *struct{}) (*healthBody, error) {
+ //nolint:contextcheck // health.Check is the shared v1/v2 probe; it takes no context and uses background contexts for its own pings.
+ if err := health.Check(); err != nil {
+ // Mirror v1: a failed check is an internal error; the cause is logged,
+ // not leaked to the client.
+ return nil, huma.Error500InternalServerError("Internal server error", err)
+ }
+ out := &healthBody{}
+ out.Body.Status = "OK"
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go
index 7ad6f18f6..9c674c1a7 100644
--- a/pkg/routes/api/v2/huma.go
+++ b/pkg/routes/api/v2/huma.go
@@ -19,7 +19,10 @@ package apiv2
import (
"context"
+ "encoding/json"
+ "io"
"net/http"
+ "net/url"
"strings"
"code.vikunja.io/api/pkg/config"
@@ -31,6 +34,36 @@ import (
"github.com/labstack/echo/v5"
)
+// formURLEncodedContentType is the content type the OAuth token endpoint accepts
+// in addition to JSON, per RFC 6749.
+const formURLEncodedContentType = "application/x-www-form-urlencoded"
+
+// formURLEncodedFormat lets Huma bind application/x-www-form-urlencoded request
+// bodies into the same json-tagged structs it uses for JSON: the form values are
+// re-marshaled to JSON and decoded via the standard path. Only string scalars
+// are produced, which is all the form-encoded endpoints (OAuth token) need.
+var formURLEncodedFormat = huma.Format{
+ Marshal: func(io.Writer, any) error {
+ // Responses are always JSON; this format is request-body only.
+ return huma.ErrUnknownContentType
+ },
+ Unmarshal: func(data []byte, v any) error {
+ values, err := url.ParseQuery(string(data))
+ if err != nil {
+ return err
+ }
+ flat := make(map[string]string, len(values))
+ for key := range values {
+ flat[key] = values.Get(key)
+ }
+ raw, err := json.Marshal(flat)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(raw, v)
+ },
+}
+
// GroupPrefix is the URL prefix the Echo group for /api/v2 is mounted at.
const GroupPrefix = "/api/v2"
@@ -44,6 +77,14 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API {
// Real presence/format rules live in `valid:` tags, enforced by govalidator in
// the Register wrapper; leave the schema permissive so partial updates match v1.
cfg.FieldsOptionalByDefault = true
+ // Accept application/x-www-form-urlencoded bodies (the OAuth token endpoint)
+ // alongside JSON. Copy the default map so we don't mutate the package global.
+ formats := make(map[string]huma.Format, len(cfg.Formats)+1)
+ for ct, f := range cfg.Formats {
+ formats[ct] = f
+ }
+ formats[formURLEncodedContentType] = formURLEncodedFormat
+ cfg.Formats = formats
api := humaecho5.NewWithGroup(e, g, GroupPrefix, cfg)
oapi := api.OpenAPI()
@@ -63,6 +104,14 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API {
Scheme: "bearer",
Description: "Vikunja API token (tk_ prefix) with scoped permissions. Created via /api/v1/tokens.",
}
+ // HTTP Basic, used only by the notifications Atom feed: feed readers can't
+ // carry a bearer header, so the feed accepts the API token as the Basic
+ // password (username = token owner). See notifications_feed.go.
+ oapi.Components.SecuritySchemes["BasicAuth"] = &huma.SecurityScheme{
+ Type: "http",
+ Scheme: "basic",
+ Description: "HTTP Basic auth used by the notifications Atom feed: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix).",
+ }
// Applied globally; public endpoints (spec, docs) opt out with an empty Security list.
oapi.Security = []map[string][]string{
{"JWTKeyAuth": {}},
diff --git a/pkg/routes/api/v2/info.go b/pkg/routes/api/v2/info.go
new file mode 100644
index 000000000..3b4256363
--- /dev/null
+++ b/pkg/routes/api/v2/info.go
@@ -0,0 +1,51 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/routes/api/shared"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+type infoBody struct {
+ Body shared.VikunjaInfos
+}
+
+// RegisterInfoRoutes wires the public instance-info endpoint onto the Huma API.
+func RegisterInfoRoutes(api huma.API) {
+ Register(api, huma.Operation{
+ OperationID: "info",
+ Summary: "Instance info",
+ Description: "Returns version, frontend URL, motd and the enabled features of this Vikunja instance. Public — no authentication required.",
+ Method: http.MethodGet,
+ Path: "/info",
+ Tags: []string{"service"},
+ // Public: opt out of the globally-applied auth. The path is also listed
+ // in unauthenticatedAPIPaths so the token middleware lets it through.
+ Security: []map[string][]string{},
+ }, info)
+}
+
+func init() { AddRouteRegistrar(RegisterInfoRoutes) }
+
+func info(_ context.Context, _ *struct{}) (*infoBody, error) {
+ return &infoBody{Body: shared.BuildInfo()}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request
+}
diff --git a/pkg/routes/api/v2/label_task_bulk.go b/pkg/routes/api/v2/label_task_bulk.go
new file mode 100644
index 000000000..82f837540
--- /dev/null
+++ b/pkg/routes/api/v2/label_task_bulk.go
@@ -0,0 +1,62 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterLabelTaskBulkRoutes wires the bulk label-replacement action onto the
+// Huma API. The model op is a CRUDable Create (handler.DoCreate, whose
+// CanCreate enforces write access to the task), but the verb is PUT because the
+// operation replaces the task's whole label set — the idempotent PUT semantics
+// describe it more honestly than POST.
+func RegisterLabelTaskBulkRoutes(api huma.API) {
+ tags := []string{"labels"}
+
+ Register(api, huma.Operation{
+ OperationID: "task-labels-bulk-replace",
+ Summary: "Replace all labels on a task",
+ Description: "Sets the task's labels to exactly the provided list: labels not in the list are removed, missing ones are added, unchanged ones are left alone. Requires write access to the task, and you must be able to see every label you attach. Returns the resulting label set.",
+ Method: http.MethodPut,
+ Path: "/tasks/{projecttask}/labels/bulk",
+ Tags: tags,
+ }, labelTasksBulkReplace)
+}
+
+func init() { AddRouteRegistrar(RegisterLabelTaskBulkRoutes) }
+
+func labelTasksBulkReplace(ctx context.Context, in *struct {
+ TaskID int64 `path:"projecttask" doc:"The numeric id of the task whose labels to replace."`
+ Body models.LabelTaskBulk
+}) (*singleBody[models.LabelTaskBulk], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ in.Body.TaskID = in.TaskID // parent from the path, not the body
+ if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.LabelTaskBulk]{Body: &in.Body}, nil
+}
diff --git a/pkg/routes/api/v2/migration_csv.go b/pkg/routes/api/v2/migration_csv.go
new file mode 100644
index 000000000..9f1922671
--- /dev/null
+++ b/pkg/routes/api/v2/migration_csv.go
@@ -0,0 +1,200 @@
+// 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 apiv2
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/modules/migration"
+ "code.vikunja.io/api/pkg/modules/migration/csv"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// csvDetectInput is the detect upload: just the file.
+type csvDetectInput struct {
+ RawBody huma.MultipartFormFiles[struct {
+ Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to analyze."`
+ }]
+}
+
+// csvImportInput is the preview/migrate upload: the file plus a JSON config
+// blob carried as a multipart form value (mirrors v1's FormValue(\"config\")).
+type csvImportInput struct {
+ RawBody huma.MultipartFormFiles[struct {
+ Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to import."`
+ Config string `form:"config" required:"true" doc:"The import configuration as a JSON object (see the ImportConfig schema), passed as a multipart form value. Obtain a starting config from the detect endpoint."`
+ }]
+}
+
+type csvDetectBody struct {
+ Body *csv.DetectionResult
+}
+
+type csvPreviewBody struct {
+ Body *csv.PreviewResult
+}
+
+// RegisterMigrationCSVRoutes wires the generic CSV importer onto the Huma API.
+// Like the other file migrators it has no config flag in v1, so it is always
+// registered.
+func RegisterMigrationCSVRoutes(api huma.API) {
+ tags := []string{"migration"}
+ // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes.
+ // #nosec G115 - configured value won't exceed int64 max in practice.
+ maxBody := (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024
+
+ Register(api, huma.Operation{
+ OperationID: "migration-csv-status",
+ Summary: "Get the CSV migration status",
+ Description: "Returns the migration status of the authenticated user for the CSV importer, i.e. whether and when they last imported a CSV.",
+ Method: http.MethodGet,
+ Path: "/migration/csv/status",
+ Tags: tags,
+ }, csvStatus)
+
+ Register(api, huma.Operation{
+ OperationID: "migration-csv-detect",
+ Summary: "Detect a CSV file's structure",
+ Description: "Analyzes an uploaded CSV file and returns its detected columns, delimiter, quote character and date format, plus a suggested column-to-attribute mapping the client can edit before previewing or migrating. Read-only: nothing is imported.",
+ Method: http.MethodPost,
+ Path: "/migration/csv/detect",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ MaxBodyBytes: maxBody,
+ }, csvDetect)
+
+ Register(api, huma.Operation{
+ OperationID: "migration-csv-preview",
+ Summary: "Preview a CSV import",
+ Description: "Returns the first few tasks that would be imported from the uploaded CSV file with the given config, without importing anything. Read-only.",
+ Method: http.MethodPost,
+ Path: "/migration/csv/preview",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ MaxBodyBytes: maxBody,
+ }, csvPreview)
+
+ Register(api, huma.Operation{
+ OperationID: "migration-csv-migrate",
+ Summary: "Import a CSV file",
+ Description: "Imports the tasks from the uploaded CSV file into Vikunja using the given config. The import runs synchronously and returns once it has finished.",
+ Method: http.MethodPost,
+ Path: "/migration/csv/migrate",
+ // POST runs an import rather than creating a REST resource, so it
+ // returns 200 with a confirmation, not the wrapper's 201.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ MaxBodyBytes: maxBody,
+ }, csvMigrate)
+}
+
+func init() { AddRouteRegistrar(RegisterMigrationCSVRoutes) }
+
+func csvStatus(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ status, err := migration.GetMigrationStatus(&csv.Migrator{}, u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &migrationStatusBody{Body: status}, nil
+}
+
+func csvDetect(ctx context.Context, in *csvDetectInput) (*csvDetectBody, error) {
+ if _, err := authFromCtx(ctx); err != nil {
+ return nil, err
+ }
+
+ src := in.RawBody.Data().Import
+ defer func() { _ = src.Close() }()
+
+ result, err := csv.DetectCSVStructure(src, src.Size)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &csvDetectBody{Body: result}, nil
+}
+
+func csvPreview(ctx context.Context, in *csvImportInput) (*csvPreviewBody, error) {
+ if _, err := authFromCtx(ctx); err != nil {
+ return nil, err
+ }
+
+ cfg, err := parseCSVImportConfig(in.RawBody.Data().Config)
+ if err != nil {
+ return nil, err
+ }
+
+ src := in.RawBody.Data().Import
+ defer func() { _ = src.Close() }()
+
+ result, err := csv.PreviewImport(src, src.Size, cfg)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &csvPreviewBody{Body: result}, nil
+}
+
+func csvMigrate(ctx context.Context, in *csvImportInput) (*migrationStartedBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ cfg, err := parseCSVImportConfig(in.RawBody.Data().Config)
+ if err != nil {
+ return nil, err
+ }
+
+ src := in.RawBody.Data().Import
+ defer func() { _ = src.Close() }()
+
+ if err := csv.RunMigration(u, src, src.Size, cfg); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ out := &migrationStartedBody{}
+ out.Body.Message = "Everything was migrated successfully."
+ return out, nil
+}
+
+// parseCSVImportConfig unmarshals the JSON config form value, mirroring v1's
+// json.Unmarshal of FormValue("config"). required:"true" guarantees presence,
+// so only a malformed body needs guarding here.
+func parseCSVImportConfig(raw string) (*csv.ImportConfig, error) {
+ var cfg csv.ImportConfig
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+ return nil, huma.Error400BadRequest("Invalid configuration: " + err.Error())
+ }
+ return &cfg, nil
+}
diff --git a/pkg/routes/api/v2/migration_file.go b/pkg/routes/api/v2/migration_file.go
new file mode 100644
index 000000000..d02db596e
--- /dev/null
+++ b/pkg/routes/api/v2/migration_file.go
@@ -0,0 +1,126 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/modules/migration"
+ migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
+ "code.vikunja.io/api/pkg/modules/migration/ticktick"
+ vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file"
+ "code.vikunja.io/api/pkg/modules/migration/wekan"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// fileMigrateInput is the multipart upload body shared by every file migrator's
+// migrate endpoint.
+type fileMigrateInput struct {
+ RawBody huma.MultipartFormFiles[struct {
+ Import huma.FormFile `form:"import" required:"true" doc:"The export file to import. Its expected format depends on the migrator (e.g. a Vikunja export zip, a TickTick CSV, a WeKan JSON export)."`
+ }]
+}
+
+// RegisterMigrationFileRoutes wires the file-based migrators (Vikunja export,
+// TickTick, WeKan) onto the Huma API. Unlike the OAuth migrators these have no
+// config flag in v1, so they are always registered.
+func RegisterMigrationFileRoutes(api huma.API) {
+ registerFileMigrator(api, func() migration.FileMigrator { return &vikunja_file.FileMigrator{} })
+ registerFileMigrator(api, func() migration.FileMigrator { return &ticktick.Migrator{} })
+ registerFileMigrator(api, func() migration.FileMigrator { return &wekan.Migrator{} })
+}
+
+func init() { AddRouteRegistrar(RegisterMigrationFileRoutes) }
+
+// registerFileMigrator registers status + migrate for a single file migrator.
+// factory produces a fresh migrator instance per request, matching v1's
+// MigrationStruct func so concurrent requests never share mutable state.
+func registerFileMigrator(api huma.API, factory func() migration.FileMigrator) {
+ name := factory().Name()
+ tags := []string{"migration"}
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-status",
+ Summary: "Get the migration status for " + name,
+ Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated.",
+ Method: http.MethodGet,
+ Path: "/migration/" + name + "/status",
+ Tags: tags,
+ }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) {
+ return migrationFileStatus(ctx, factory)
+ })
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-migrate",
+ Summary: "Migrate from " + name,
+ Description: "Imports the authenticated user's data from an uploaded export file into Vikunja. Send the file under the multipart \"import\" field. The import runs synchronously and returns once it has finished.",
+ Method: http.MethodPost,
+ Path: "/migration/" + name + "/migrate",
+ // POST runs an import rather than creating a REST resource, so it
+ // returns 200 with a confirmation, not the wrapper's 201.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes.
+ // #nosec G115 - configured value won't exceed int64 max in practice.
+ MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024,
+ }, func(ctx context.Context, in *fileMigrateInput) (*migrationStartedBody, error) {
+ return migrationFileMigrate(ctx, factory, in)
+ })
+}
+
+func migrationFileStatus(ctx context.Context, factory func() migration.FileMigrator) (*migrationStatusBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ status, err := migration.GetMigrationStatus(factory(), u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &migrationStatusBody{Body: status}, nil
+}
+
+func migrationFileMigrate(ctx context.Context, factory func() migration.FileMigrator, in *fileMigrateInput) (*migrationStartedBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ src := in.RawBody.Data().Import
+ defer func() { _ = src.Close() }()
+
+ if err := migrationHandler.RunFileMigration(factory(), u, src, src.Size); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ out := &migrationStartedBody{}
+ out.Body.Message = "Everything was migrated successfully."
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/migration_oauth.go b/pkg/routes/api/v2/migration_oauth.go
new file mode 100644
index 000000000..4d254632c
--- /dev/null
+++ b/pkg/routes/api/v2/migration_oauth.go
@@ -0,0 +1,167 @@
+// 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 apiv2
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/modules/migration"
+ migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
+ microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo"
+ "code.vikunja.io/api/pkg/modules/migration/todoist"
+ "code.vikunja.io/api/pkg/modules/migration/trello"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// migrationAuthURLBody is the response for the OAuth auth-url endpoint.
+type migrationAuthURLBody struct {
+ Body migrationHandler.AuthURL
+}
+
+// migrationStatusBody is the response for the migration status endpoint.
+type migrationStatusBody struct {
+ Body *migration.Status
+}
+
+// migrationMigrateBody carries the OAuth code obtained from the auth url back
+// to the server. It is applied onto the concrete migrator (whose field carries
+// json:"code") so it works across migrators regardless of their field name.
+type migrationMigrateBody struct {
+ Code string `json:"code" doc:"The OAuth code obtained after authorizing against the auth url."`
+}
+
+// migrationStartedBody confirms the migration was kicked off; the actual work
+// runs asynchronously.
+type migrationStartedBody struct {
+ Body struct {
+ Message string `json:"message" readOnly:"true" doc:"A confirmation message."`
+ }
+}
+
+// RegisterMigrationOAuthRoutes wires the OAuth-based migrators (Todoist, Trello,
+// Microsoft To-Do) onto the Huma API. Each migrator is gated behind its static
+// config flag and exposes the same three operations, so registration is driven
+// by one generic helper instead of three copy-pasted blocks.
+func RegisterMigrationOAuthRoutes(api huma.API) {
+ registerOAuthMigrator(api, config.MigrationTodoistEnable.GetBool(), func() migration.Migrator { return &todoist.Migration{} })
+ registerOAuthMigrator(api, config.MigrationTrelloEnable.GetBool(), func() migration.Migrator { return &trello.Migration{} })
+ registerOAuthMigrator(api, config.MigrationMicrosoftTodoEnable.GetBool(), func() migration.Migrator { return µsofttodo.Migration{} })
+}
+
+func init() { AddRouteRegistrar(RegisterMigrationOAuthRoutes) }
+
+// registerOAuthMigrator registers auth/status/migrate for a single OAuth
+// migrator. enabled gates the whole migrator (config early-return, no
+// middleware); factory produces a fresh migrator instance per request, matching
+// v1's MigrationStruct func so concurrent requests never share mutable state.
+func registerOAuthMigrator(api huma.API, enabled bool, factory func() migration.Migrator) {
+ if !enabled {
+ return
+ }
+
+ name := factory().Name()
+ tags := []string{"migration"}
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-auth",
+ Summary: "Get the auth url for " + name,
+ Description: "Returns the OAuth url the user needs to authenticate against. The code obtained there is passed back to the migrate endpoint.",
+ Method: http.MethodGet,
+ Path: "/migration/" + name + "/auth",
+ Tags: tags,
+ }, func(_ context.Context, _ *struct{}) (*migrationAuthURLBody, error) {
+ return &migrationAuthURLBody{Body: migrationHandler.AuthURL{URL: factory().AuthURL()}}, nil
+ })
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-status",
+ Summary: "Get the migration status for " + name,
+ Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated. Used to prevent starting a second migration while one is running.",
+ Method: http.MethodGet,
+ Path: "/migration/" + name + "/status",
+ Tags: tags,
+ }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) {
+ return migrationOAuthStatus(ctx, factory)
+ })
+
+ Register(api, huma.Operation{
+ OperationID: "migration-" + name + "-migrate",
+ Summary: "Migrate from " + name,
+ Description: "Starts a migration of the authenticated user's data from this service into Vikunja. The migration runs asynchronously; this returns once it has been queued. Refuses with 412 if a migration for this service is already running.",
+ Method: http.MethodPost,
+ Path: "/migration/" + name + "/migrate",
+ // POST kicks off a job rather than creating a REST resource, so it
+ // returns 200 with a confirmation, not the wrapper's 201.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, func(ctx context.Context, in *struct{ Body migrationMigrateBody }) (*migrationStartedBody, error) {
+ return migrationOAuthMigrate(ctx, factory, in.Body)
+ })
+}
+
+func migrationOAuthStatus(ctx context.Context, factory func() migration.Migrator) (*migrationStatusBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ status, err := migration.GetMigrationStatus(factory(), u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &migrationStatusBody{Body: status}, nil
+}
+
+func migrationOAuthMigrate(ctx context.Context, factory func() migration.Migrator, body migrationMigrateBody) (*migrationStartedBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ ms := factory()
+ // Apply the request payload onto the concrete migrator the same way v1's
+ // c.Bind does, so migrator-specific field names (e.g. Trello's Token,
+ // json:"code") bind transparently.
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(raw, ms); err != nil {
+ return nil, huma.Error400BadRequest("invalid migration payload", err)
+ }
+
+ if err := migrationHandler.StartMigration(ms, u); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ out := &migrationStartedBody{}
+ out.Body.Message = "Migration was started successfully."
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/notifications_feed.go b/pkg/routes/api/v2/notifications_feed.go
new file mode 100644
index 000000000..d6195def2
--- /dev/null
+++ b/pkg/routes/api/v2/notifications_feed.go
@@ -0,0 +1,103 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/modules/humaecho5"
+ "code.vikunja.io/api/pkg/routes/feeds"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/labstack/echo/v5"
+)
+
+// RegisterNotificationsFeedRoutes wires the Atom notifications feed onto the
+// Huma API. It documents HTTP Basic auth (a feeds-scoped API token) because
+// feed readers can't carry a bearer header.
+func RegisterNotificationsFeedRoutes(api huma.API) {
+ Register(api, huma.Operation{
+ OperationID: "notifications-atom-feed",
+ Summary: "Notifications Atom feed",
+ Description: "Returns the authenticated user's latest notifications as an Atom feed. Authenticated with HTTP Basic auth: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix) — password and LDAP credentials are rejected because feed URLs are commonly shared or cached. Fetching the feed does not mark notifications as read.",
+ Method: http.MethodGet,
+ Path: "/notifications.atom",
+ Tags: []string{"service"},
+ // This op carries its own HTTP Basic auth instead of the global bearer
+ // schemes; the path is in unauthenticatedAPIPaths so the JWT middleware
+ // lets it through and the handler authenticates itself.
+ Security: []map[string][]string{{"BasicAuth": {}}},
+ Responses: map[string]*huma.Response{
+ "200": {
+ Description: "The notifications Atom feed.",
+ Content: map[string]*huma.MediaType{
+ "application/atom+xml": {
+ Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
+ },
+ },
+ },
+ },
+ }, notificationsAtomFeed)
+}
+
+func init() { AddRouteRegistrar(RegisterNotificationsFeedRoutes) }
+
+// notificationsAtomFeed authenticates with HTTP Basic (sharing the feeds
+// validator) and streams the Atom feed; there is no handler.Do* for a non-JSON
+// body and the auth can't ride the group's JWT middleware.
+func notificationsAtomFeed(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
+ c, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
+ if !ok {
+ return nil, huma.Error500InternalServerError("could not resolve request context")
+ }
+
+ username, password, ok := (*c).Request().BasicAuth()
+ if !ok {
+ return nil, basicAuthChallenge(c)
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := feeds.AuthenticateFeedToken(s, username, password)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ if u == nil {
+ return nil, basicAuthChallenge(c)
+ }
+
+ atom, err := feeds.BuildNotificationsAtomFeed(s, u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &huma.StreamResponse{Body: func(hctx huma.Context) {
+ ec := humaecho5.Unwrap(hctx)
+ (*ec).Response().Header().Set(echo.HeaderContentType, feeds.AtomContentType)
+ _, _ = (*ec).Response().Write([]byte(atom))
+ }}, nil
+}
+
+// basicAuthChallenge returns a 401 carrying a WWW-Authenticate Basic challenge,
+// mirroring v1's BasicAuth middleware so feed readers prompt for credentials.
+func basicAuthChallenge(c *echo.Context) error {
+ (*c).Response().Header().Set(echo.HeaderWWWAuthenticate, `Basic realm="Restricted"`)
+ return huma.Error401Unauthorized(http.StatusText(http.StatusUnauthorized))
+}
diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go
new file mode 100644
index 000000000..a67441ad3
--- /dev/null
+++ b/pkg/routes/api/v2/oauth.go
@@ -0,0 +1,111 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/modules/auth/oauth2server"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// oauthTokenBody wraps the OAuth 2.0 token response.
+type oauthTokenBody struct {
+ // Cache-Control: no-store is required by RFC 6749 §5.1 so tokens are not
+ // cached. v2 already sets it globally, but declaring it keeps the contract
+ // explicit in the spec.
+ CacheControl string `header:"Cache-Control"`
+ Body *oauth2server.TokenResponse
+}
+
+// oauthAuthorizeBody wraps the OAuth 2.0 authorization response.
+type oauthAuthorizeBody struct {
+ Body *oauth2server.AuthorizeResponse
+}
+
+func init() { AddRouteRegistrar(RegisterOAuthRoutes) }
+
+// RegisterOAuthRoutes wires the OAuth 2.0 token and authorize endpoints. The
+// token endpoint is public (it authenticates the request itself); authorize
+// inherits the global JWT auth.
+func RegisterOAuthRoutes(api huma.API) {
+ tags := []string{"auth"}
+
+ Register(api, huma.Operation{
+ OperationID: "oauth-token",
+ Summary: "OAuth 2.0 token endpoint",
+ Description: "Exchanges an authorization code (grant_type=authorization_code) or a refresh token (grant_type=refresh_token) for an access token. Accepts application/x-www-form-urlencoded per RFC 6749 as well as JSON.",
+ Method: http.MethodPost,
+ Path: "/oauth/token",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ Security: publicSecurity,
+ }, oauthToken)
+
+ Register(api, huma.Operation{
+ OperationID: "oauth-authorize",
+ Summary: "OAuth 2.0 authorize endpoint",
+ Description: "Creates a single-use authorization code for the authenticated user. PKCE (code_challenge with method S256) and a loopback or vikunja- scheme redirect_uri are required.",
+ Method: http.MethodPost,
+ Path: "/oauth/authorize",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, oauthAuthorize)
+}
+
+func oauthToken(ctx context.Context, in *struct {
+ Body oauth2server.TokenRequest `contentType:"application/x-www-form-urlencoded"`
+}) (*oauthTokenBody, error) {
+ deviceInfo, ipAddress := requestClientInfo(ctx)
+ resp, err := oauth2server.ExchangeToken(ctx, &in.Body, deviceInfo, ipAddress)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &oauthTokenBody{CacheControl: "no-store", Body: resp}, nil
+}
+
+func oauthAuthorize(ctx context.Context, in *struct{ Body oauth2server.AuthorizeRequest }) (*oauthAuthorizeBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ u, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ resp, err := oauth2server.Authorize(&in.Body, u.ID)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &oauthAuthorizeBody{Body: resp}, nil
+}
+
+// requestClientInfo pulls the user agent and client IP off the underlying Echo
+// request so the authorization_code grant (and login) can record them on the
+// session they create, mirroring v1. Both fall back to "" when the context is
+// unavailable.
+func requestClientInfo(ctx context.Context) (deviceInfo, ipAddress string) {
+ ec := echoContextFromCtx(ctx)
+ if ec == nil {
+ return "", ""
+ }
+ return (*ec).Request().UserAgent(), (*ec).RealIP()
+}
diff --git a/pkg/routes/api/v2/project_duplicate.go b/pkg/routes/api/v2/project_duplicate.go
new file mode 100644
index 000000000..6a050b2c2
--- /dev/null
+++ b/pkg/routes/api/v2/project_duplicate.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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterProjectDuplicateRoutes wires the project-duplicate action onto the Huma API.
+//
+// ProjectDuplicate is a CRUDable Create, so the handler reuses handler.DoCreate
+// (its CanCreate enforces access); the only custom part is taking ProjectID from
+// the path rather than the request body.
+func RegisterProjectDuplicateRoutes(api huma.API) {
+ tags := []string{"projects"}
+
+ Register(api, huma.Operation{
+ OperationID: "projects-duplicate",
+ Summary: "Duplicate a project",
+ Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds — into a new project owned by the authenticated user. User/team/link shares are only copied when duplicate_shares is set to true. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.",
+ Method: http.MethodPost,
+ Path: "/projects/{projectid}/duplicate",
+ Tags: tags,
+ }, projectsDuplicate)
+}
+
+func init() { AddRouteRegistrar(RegisterProjectDuplicateRoutes) }
+
+func projectsDuplicate(ctx context.Context, in *struct {
+ ProjectID int64 `path:"projectid" doc:"The numeric id of the project to duplicate."`
+ Body models.ProjectDuplicate
+}) (*singleBody[models.ProjectDuplicate], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ pd := &in.Body
+ pd.ProjectID = in.ProjectID
+ if err := handler.DoCreate(ctx, pd, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.ProjectDuplicate]{Body: pd}, nil
+}
diff --git a/pkg/routes/api/v2/reactions.go b/pkg/routes/api/v2/reactions.go
new file mode 100644
index 000000000..722b2615a
--- /dev/null
+++ b/pkg/routes/api/v2/reactions.go
@@ -0,0 +1,135 @@
+// 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 apiv2
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// {entitykind} stays a string: the model derives the numeric EntityKind from
+// it and rejects unknown kinds. The enum tag (repeated on the create/delete
+// inputs) makes Huma reject anything else with a 422 before the handler runs;
+// keep the values in sync with models.Reaction.setEntityKindFromString.
+type reactionPathParams struct {
+ EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."`
+ EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."`
+}
+
+// Reactions list as a map keyed by reaction value, not a slice, so it does not
+// fit the Paginated envelope.
+type reactionListBody struct {
+ Body models.ReactionMap
+}
+
+func RegisterReactionRoutes(api huma.API) {
+ tags := []string{"reactions"}
+
+ Register(api, huma.Operation{
+ OperationID: "reactions-list",
+ Summary: "List reactions for an entity",
+ Description: "Returns every reaction on the entity, grouped as a map keyed by reaction value; each value maps to the users who reacted with it. Requires read access to the entity. Not paginated.",
+ Method: http.MethodGet,
+ Path: "/{entitykind}/{entityid}/reactions",
+ Tags: tags,
+ }, reactionsList)
+
+ Register(api, huma.Operation{
+ OperationID: "reactions-create",
+ Summary: "React to an entity",
+ Description: "Adds the authenticated user's reaction to the entity. Requires write access. No-op if the same reaction already exists.",
+ Method: http.MethodPost,
+ Path: "/{entitykind}/{entityid}/reactions",
+ Tags: tags,
+ }, reactionsCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "reactions-delete",
+ Summary: "Remove a reaction from an entity",
+ Description: "Removes the authenticated user's own reaction from the entity. The reaction to remove is named in the body (there is no per-reaction id), so this is a POST with a body rather than a DELETE. Requires write access.",
+ Method: http.MethodPost,
+ Path: "/{entitykind}/{entityid}/reactions/delete",
+ Tags: tags,
+ DefaultStatus: http.StatusOK,
+ }, reactionsDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterReactionRoutes) }
+
+func reactionsList(ctx context.Context, in *reactionPathParams) (*reactionListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ r := &models.Reaction{EntityID: in.EntityID, EntityKindString: in.EntityKind}
+ result, _, _, err := handler.DoReadAll(ctx, r, a, "", 1, -1)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ reactions, ok := result.(models.ReactionMap)
+ if !ok {
+ return nil, fmt.Errorf("reactions.ReadAll returned unexpected type %T (expected models.ReactionMap)", result)
+ }
+ if reactions == nil {
+ reactions = models.ReactionMap{}
+ }
+ return &reactionListBody{Body: reactions}, nil
+}
+
+// Path params are flattened (not via the embedded reactionPathParams) because
+// Huma fails to bind an embedded path-param struct when the input also has a Body.
+func reactionsCreate(ctx context.Context, in *struct {
+ EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."`
+ EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."`
+ Body models.Reaction
+}) (*singleBody[models.Reaction], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ r := &in.Body
+ r.EntityID = in.EntityID
+ r.EntityKindString = in.EntityKind
+ if err := handler.DoCreate(ctx, r, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.Reaction]{Body: r}, nil
+}
+
+func reactionsDelete(ctx context.Context, in *struct {
+ EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."`
+ EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."`
+ Body models.Reaction
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ r := &in.Body
+ r.EntityID = in.EntityID
+ r.EntityKindString = in.EntityKind
+ if err := handler.DoDelete(ctx, r, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/routes/api/v2/task_assignees_bulk.go b/pkg/routes/api/v2/task_assignees_bulk.go
new file mode 100644
index 000000000..a60d84248
--- /dev/null
+++ b/pkg/routes/api/v2/task_assignees_bulk.go
@@ -0,0 +1,60 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterTaskAssigneeBulkRoutes wires the bulk assignee replacement onto the
+// Huma API. PUT is the honest verb — the operation replaces the task's whole
+// assignee set idempotently — even though the model implements it as a Create.
+func RegisterTaskAssigneeBulkRoutes(api huma.API) {
+ tags := []string{"assignees"}
+
+ Register(api, huma.Operation{
+ OperationID: "task-assignees-bulk",
+ Summary: "Replace all assignees of a task",
+ Description: "Replaces the task's full assignee set with the users in the body: users not in the list are unassigned, new ones are added. Pass an empty array to unassign everyone. Each assignee must have access to the task's project, and the caller needs write access to the task.",
+ Method: http.MethodPut,
+ Path: "/tasks/{projecttask}/assignees/bulk",
+ Tags: tags,
+ }, taskAssigneesBulk)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskAssigneeBulkRoutes) }
+
+func taskAssigneesBulk(ctx context.Context, in *struct {
+ TaskID int64 `path:"projecttask"`
+ Body models.BulkAssignees
+}) (*singleBody[models.BulkAssignees], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ in.Body.TaskID = in.TaskID // URL wins over body
+ if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.BulkAssignees]{Body: &in.Body}, nil
+}
diff --git a/pkg/routes/api/v2/task_attachments.go b/pkg/routes/api/v2/task_attachments.go
new file mode 100644
index 000000000..9faee2c0d
--- /dev/null
+++ b/pkg/routes/api/v2/task_attachments.go
@@ -0,0 +1,215 @@
+// 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 apiv2
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/humaecho5"
+ webfiles "code.vikunja.io/api/pkg/web/files"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// models.TaskAttachment.ReadAll returns []*models.TaskAttachment.
+type taskAttachmentListBody struct {
+ Body Paginated[*models.TaskAttachment]
+}
+
+type taskAttachmentUploadInput struct {
+ TaskID int64 `path:"task" doc:"The id of the task to attach the files to."`
+ // Accept any upload; the byte-level mime detection happens in files.CreateWithSession,
+ // so there is no part content-type allow-list to enforce here (unlike the avatar endpoint).
+ RawBody huma.MultipartFormFiles[struct {
+ Files []huma.FormFile `form:"files" required:"true" doc:"One or more files to upload as task attachments. Send multiple parts under the same \"files\" field to upload several at once."`
+ }]
+}
+
+type taskAttachmentUploadBody struct {
+ Body *webfiles.AttachmentUploadResult
+}
+
+// RegisterTaskAttachmentRoutes wires task-attachment list/upload/download/delete onto
+// the Huma API. The whole resource is gated by the service.enabletaskattachments config
+// flag; the check runs here (not at init()) because RegisterAll fires after config loads.
+func RegisterTaskAttachmentRoutes(api huma.API) {
+ if !config.ServiceEnableTaskAttachments.GetBool() {
+ return
+ }
+
+ tags := []string{"task"}
+
+ Register(api, huma.Operation{
+ OperationID: "task-attachments-list",
+ Summary: "List a task's attachments",
+ Description: "Returns the attachment metadata for one task, paginated. Requires read access to the task. The file bytes are not included; fetch them from the download endpoint.",
+ Method: http.MethodGet,
+ Path: "/tasks/{task}/attachments",
+ Tags: tags,
+ }, taskAttachmentsList)
+
+ Register(api, huma.Operation{
+ OperationID: "task-attachments-upload",
+ Summary: "Upload task attachments",
+ Description: "Uploads one or more files as attachments to a task via multipart/form-data under the \"files\" field. Requires write access to the task. Each file is processed independently: a file that fails (for example, exceeding the configured size limit) is reported in the errors list while the others still succeed, so the request returns 201 even on a partial upload. The max size per file is the server's configured file size limit.",
+ Method: http.MethodPost,
+ Path: "/tasks/{task}/attachments",
+ Tags: tags,
+ // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes.
+ // #nosec G115 - configured value won't exceed int64 max in practice.
+ MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024,
+ }, taskAttachmentsUpload)
+
+ Register(api, huma.Operation{
+ OperationID: "task-attachments-download",
+ Summary: "Download a task attachment",
+ Description: "Returns the raw bytes of one attachment. Requires read access to the task. Pass preview_size to get a downscaled PNG preview instead — only for image attachments; for non-images or an unknown size the original file is returned. The Content-Type header carries the file's real mime type.",
+ Method: http.MethodGet,
+ Path: "/tasks/{task}/attachments/{attachment}",
+ Tags: tags,
+ // Spell out the binary response; a bare []byte Body would otherwise be
+ // modeled as a base64 JSON string instead of binary file data.
+ Responses: map[string]*huma.Response{
+ "200": {
+ Description: "The attachment file bytes. The Content-Type header carries the file's mime type.",
+ Content: map[string]*huma.MediaType{
+ "application/octet-stream": {
+ Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
+ },
+ },
+ },
+ },
+ }, taskAttachmentsDownload)
+
+ Register(api, huma.Operation{
+ OperationID: "task-attachments-delete",
+ Summary: "Delete a task attachment",
+ Description: "Deletes one attachment and its underlying file. Requires write access to the task. The attachment must belong to the task in the path.",
+ Method: http.MethodDelete,
+ Path: "/tasks/{task}/attachments/{attachment}",
+ Tags: tags,
+ }, taskAttachmentsDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskAttachmentRoutes) }
+
+func taskAttachmentsList(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The id of the task whose attachments to list."`
+ ListParams
+}) (*taskAttachmentListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, &models.TaskAttachment{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ items, ok := result.([]*models.TaskAttachment)
+ if !ok {
+ return nil, fmt.Errorf("taskAttachments.ReadAll returned unexpected type %T (expected []*models.TaskAttachment)", result)
+ }
+ return &taskAttachmentListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
+}
+
+// taskAttachmentsUpload owns auth, the session and the permission check because
+// there is no handler.Do* for multipart uploads (see the api-v2-routes skill's
+// "Non-CRUDable / custom routes" section).
+func taskAttachmentsUpload(ctx context.Context, in *taskAttachmentUploadInput) (*taskAttachmentUploadBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ formFiles := in.RawBody.Data().Files
+ uploads := make([]*models.AttachmentToUpload, 0, len(formFiles))
+ for _, file := range formFiles {
+ uploads = append(uploads, &models.AttachmentToUpload{Reader: file, Filename: file.Filename, Size: uint64(file.Size)})
+ }
+
+ success, failures, err := models.UploadTaskAttachments(s, a, in.TaskID, uploads)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ return &taskAttachmentUploadBody{Body: webfiles.BuildUploadResult(success, failures)}, nil
+}
+
+// taskAttachmentsDownload owns auth, the session and the permission check; there is
+// no handler.Do* for a file body. It loads the attachment, then streams the bytes
+// from the StreamResponse callback (no buffering — attachments can be large).
+func taskAttachmentsDownload(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."`
+ AttachmentID int64 `path:"attachment" doc:"The id of the attachment to download."`
+ PreviewSize string `query:"preview_size" enum:"sm,md,lg,xl" doc:"If set and the attachment is an image, return a downscaled PNG preview instead of the original: sm=100px, md=200px, lg=400px, xl=800px. Ignored for non-image attachments."`
+}) (*huma.StreamResponse, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ previewSize := models.GetPreviewSizeFromString(in.PreviewSize)
+ ta, preview, err := models.LoadTaskAttachmentForDownload(s, a, in.TaskID, in.AttachmentID, previewSize)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ // The file reader comes from object storage, not the DB session, so it stays
+ // valid after the commit; the StreamResponse callback runs after this returns.
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ return &huma.StreamResponse{Body: func(hctx huma.Context) {
+ c := humaecho5.Unwrap(hctx)
+ webfiles.WriteAttachmentDownload((*c).Response(), (*c).Request(), ta, preview)
+ }}, nil
+}
+
+func taskAttachmentsDelete(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."`
+ AttachmentID int64 `path:"attachment" doc:"The id of the attachment to delete."`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoDelete(ctx, &models.TaskAttachment{ID: in.AttachmentID, TaskID: in.TaskID}, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/routes/api/v2/task_bucket.go b/pkg/routes/api/v2/task_bucket.go
new file mode 100644
index 000000000..b07774b2f
--- /dev/null
+++ b/pkg/routes/api/v2/task_bucket.go
@@ -0,0 +1,69 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterTaskBucketRoutes wires the kanban task-bucket move onto the Huma API.
+//
+// TaskBucket exposes only Update, so the handler reuses handler.DoUpdate (its
+// CanUpdate enforces write access on the bucket's project). The bucket and view
+// come from the path; only the task id is read from the body.
+func RegisterTaskBucketRoutes(api huma.API) {
+ tags := []string{"projects"}
+
+ Register(api, huma.Operation{
+ OperationID: "task-bucket-update",
+ Summary: "Place a task in a kanban bucket",
+ Description: "Moves a task into the given bucket of a project's kanban view. Requires write access to the project. " +
+ "Idempotent: re-sending the same bucket is a no-op. Side effects: moving a task into the view's done bucket marks it done (and out of it un-marks it); a repeating task moved into the done bucket is reopened and routed back to the default bucket instead. " +
+ "Moving a task into a bucket that is already at its task limit is rejected with 412. A bucket that does not resolve under the project and view in the path is rejected with 404.",
+ Method: http.MethodPut,
+ Path: "/projects/{project}/views/{view}/buckets/{bucket}/tasks",
+ Tags: tags,
+ }, taskBucketUpdate)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskBucketRoutes) }
+
+func taskBucketUpdate(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ ViewID int64 `path:"view"`
+ BucketID int64 `path:"bucket"`
+ Body models.TaskBucket
+}) (*singleBody[models.TaskBucket], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ tb := &in.Body
+ tb.ProjectID = in.ProjectID // URL wins over body
+ tb.ProjectViewID = in.ViewID // URL wins over body
+ tb.BucketID = in.BucketID // URL wins over body
+ if err := handler.DoUpdate(ctx, tb, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.TaskBucket]{Body: tb}, nil
+}
diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go
new file mode 100644
index 000000000..1a379dbe6
--- /dev/null
+++ b/pkg/routes/api/v2/task_collection.go
@@ -0,0 +1,235 @@
+// 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 apiv2
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+const taskListFilterDoc = "Filtering, sorting and search apply to every variant. See https://vikunja.io/docs/filters for the filter language."
+
+type taskListBody struct {
+ Body Paginated[*models.Task]
+}
+
+// bucketsWithTasksBody is the buckets-with-tasks response. It is not paginated:
+// the view's bucket configuration bounds how many tasks each bucket carries, so
+// page/per_page don't apply and total is simply the number of buckets.
+type bucketsWithTasksBody struct {
+ Body struct {
+ Items []*models.Bucket `json:"items"`
+ Total int64 `json:"total" doc:"The number of buckets returned."`
+ }
+}
+
+// TaskListQueryParams is the shared filter/sort/search/expand query block for
+// every task-list variant. It must stay EXPORTED: Huma promotes an anonymous
+// embed's params only when the embed field is itself exported, and an embed
+// field is exported iff its type name is (a lowercase type name silently drops
+// all of its params from binding and the spec).
+//
+// The three input structs below embed it but keep their path params inline:
+// Huma lists every path:"" field regardless of the route template, so a shared
+// project/view field would leak onto a narrower route as a phantom path param.
+// taskListViewInput is shared by both view-scoped endpoints.
+type TaskListQueryParams struct {
+ ListParams
+ Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."`
+ FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."`
+ FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."`
+ SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."`
+ OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."`
+ Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."`
+}
+
+type taskListAllInput struct {
+ TaskListQueryParams
+}
+
+type taskListProjectInput struct {
+ ProjectID int64 `path:"project" doc:"The numeric id of the project."`
+ TaskListQueryParams
+}
+
+type taskListViewInput struct {
+ ProjectID int64 `path:"project" doc:"The numeric id of the project."`
+ ViewID int64 `path:"view" doc:"The numeric id of the project view."`
+ TaskListQueryParams
+}
+
+// taskListFilters is the bound query carried into the shared collection builder.
+// The three input structs convert into it so the collection logic lives once.
+type taskListFilters struct {
+ Q string
+ Filter string
+ FilterTimezone string
+ FilterIncludeNulls bool
+ SortBy []string
+ OrderBy []string
+ Expand []string
+}
+
+func (in taskListAllInput) filters() taskListFilters {
+ return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand}
+}
+
+func (in taskListProjectInput) filters() taskListFilters {
+ return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand}
+}
+
+func (in taskListViewInput) filters() taskListFilters {
+ return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand}
+}
+
+// collection turns the bound query into a TaskCollection. The search term
+// arrives as `q` but reaches the model through DoReadAll's search argument, not
+// the collection's Search field. forceFlat keeps a kanban view path returning
+// flat tasks; the buckets endpoint leaves it false for the polymorphic shape.
+func (f taskListFilters) collection(projectID, viewID int64, forceFlat bool) (*models.TaskCollection, error) {
+ expand, err := parseTaskExpand(f.Expand)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ tc := &models.TaskCollection{
+ ProjectID: projectID,
+ ProjectViewID: viewID,
+ Filter: f.Filter,
+ FilterTimezone: f.FilterTimezone,
+ FilterIncludeNulls: f.FilterIncludeNulls,
+ SortBy: f.SortBy,
+ OrderBy: f.OrderBy,
+ Expand: expand,
+ }
+ if forceFlat {
+ tc.SetForceFlatTasks()
+ }
+ return tc, nil
+}
+
+func RegisterTaskCollectionRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-list",
+ Summary: "List tasks across all projects",
+ Description: "Returns the tasks the authenticated user can see across every project they have access to, paginated and flat. " + taskListFilterDoc,
+ Method: http.MethodGet,
+ Path: "/tasks",
+ Tags: tags,
+ }, tasksListAll)
+
+ Register(api, huma.Operation{
+ OperationID: "project-tasks-list",
+ Summary: "List tasks in a project",
+ Description: "Returns the tasks in a project, paginated and flat. Requires read access to the project. " + taskListFilterDoc,
+ Method: http.MethodGet,
+ Path: "/projects/{project}/tasks",
+ Tags: tags,
+ }, projectTasksList)
+
+ Register(api, huma.Operation{
+ OperationID: "project-view-tasks-list",
+ Summary: "List tasks in a project view",
+ Description: "Returns the tasks in a project view, paginated and flat. The view's own filter, sort and search are applied on top of the query. Always returns flat tasks, even for a kanban view — use the buckets endpoint to get tasks grouped by bucket. " + taskListFilterDoc,
+ Method: http.MethodGet,
+ Path: "/projects/{project}/views/{view}/tasks",
+ Tags: tags,
+ }, projectViewTasksList)
+
+ Register(api, huma.Operation{
+ OperationID: "project-view-buckets-tasks-list",
+ Summary: "List a kanban view's buckets with their tasks",
+ Description: "Returns the buckets of a project's kanban view, each populated with the tasks in it. Requires read access to the project. Not paginated: the number and size of buckets follow the view's bucket configuration, so page/per_page do not apply. " + taskListFilterDoc,
+ Method: http.MethodGet,
+ Path: "/projects/{project}/views/{view}/buckets/tasks",
+ Tags: tags,
+ }, projectViewBucketsTasksList)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskCollectionRoutes) }
+
+func tasksListAll(ctx context.Context, in *taskListAllInput) (*taskListBody, error) {
+ return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, 0, 0)
+}
+
+func projectTasksList(ctx context.Context, in *taskListProjectInput) (*taskListBody, error) {
+ return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, 0)
+}
+
+func projectViewTasksList(ctx context.Context, in *taskListViewInput) (*taskListBody, error) {
+ return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, in.ViewID)
+}
+
+// readFlatTasks runs DoReadAll for a flat-task endpoint and unwraps the result.
+// The model authorizes (project/view CanRead) inside ReadAll, so there's no
+// Can* call here.
+func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, projectID, viewID int64) (*taskListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ tc, err := f.collection(projectID, viewID, true)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, page, perPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ tasks, ok := result.([]*models.Task)
+ if !ok {
+ return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result)
+ }
+ return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil
+}
+
+func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*bucketsWithTasksBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ f := in.filters()
+ tc, err := f.collection(in.ProjectID, in.ViewID, false)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ buckets, ok := result.([]*models.Bucket)
+ if !ok {
+ // ReadAll only yields []*Bucket from the kanban branch; a flat []*Task
+ // here means the view has no bucket configuration, so there are no
+ // buckets to return. That's a client error, not a 500.
+ if _, isTasks := result.([]*models.Task); isTasks {
+ return nil, huma.Error400BadRequest("this view has no buckets; use the tasks endpoint for non-kanban views")
+ }
+ return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result)
+ }
+ out := &bucketsWithTasksBody{}
+ out.Body.Items = buckets
+ out.Body.Total = total
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/task_position.go b/pkg/routes/api/v2/task_position.go
new file mode 100644
index 000000000..13a7e3af8
--- /dev/null
+++ b/pkg/routes/api/v2/task_position.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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterTaskPositionRoutes wires the task-position update onto the Huma API.
+//
+// Setting a position is a plain CRUDable Update, so the handler reuses
+// handler.DoUpdate (its CanUpdate delegates to the task's CanUpdate); the only
+// custom part is taking TaskID from the path rather than the request body.
+func RegisterTaskPositionRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-position-update",
+ Summary: "Set a task's position in a view",
+ Description: "Sets where a task sorts within one of its project's views. The position is per view, so this only affects the view named by project_view_id. Requires write access to the task. Positions below the minimum spacing make the server recalculate every position in the view, so the returned value may differ from the one sent.",
+ Method: http.MethodPut,
+ Path: "/tasks/{task}/position",
+ Tags: tags,
+ }, tasksPositionUpdate)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskPositionRoutes) }
+
+func tasksPositionUpdate(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The numeric id of the task whose position to set."`
+ Body models.TaskPosition
+}) (*singleBody[models.TaskPosition], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ tp := &in.Body
+ tp.TaskID = in.TaskID // URL wins over body
+ if err := handler.DoUpdate(ctx, tp, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.TaskPosition]{Body: tp}, nil
+}
diff --git a/pkg/routes/api/v2/task_relations.go b/pkg/routes/api/v2/task_relations.go
new file mode 100644
index 000000000..eb9fa305f
--- /dev/null
+++ b/pkg/routes/api/v2/task_relations.go
@@ -0,0 +1,94 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterTaskRelationRoutes wires task-relation create/delete onto the Huma API.
+//
+// Both operations reuse handler.DoCreate/DoDelete; CanCreate enforces write on
+// the base task + read on the other task and rejects invalid kinds, CanDelete
+// enforces write on the base task. The only custom part is mapping the path
+// segments onto the model.
+func RegisterTaskRelationRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-relations-create",
+ Summary: "Create a task relation",
+ Description: "Relates two tasks. The authenticated user needs write access to the base task (in the path) and at least read access to the other task; the two tasks need not share a project. The inverse relation is created automatically (e.g. a subtask relation also stores the parenttask relation on the other task). Subtask/parenttask chains that would form a cycle are rejected.",
+ Method: http.MethodPost,
+ Path: "/tasks/{task}/relations",
+ Tags: tags,
+ }, tasksRelationsCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-relations-delete",
+ Summary: "Delete a task relation",
+ Description: "Removes the relation identified by the base task, relation kind and other task. The automatically created inverse relation is removed as well. The authenticated user needs write access to the base task.",
+ Method: http.MethodDelete,
+ Path: "/tasks/{task}/relations/{relationKind}/{otherTask}",
+ Tags: tags,
+ }, tasksRelationsDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskRelationRoutes) }
+
+func tasksRelationsCreate(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The numeric id of the base task to relate from."`
+ Body models.TaskRelation
+}) (*singleBody[models.TaskRelation], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rel := &in.Body
+ rel.TaskID = in.TaskID // URL wins over body
+ if err := handler.DoCreate(ctx, rel, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.TaskRelation]{Body: rel}, nil
+}
+
+// The relationKind enum mirrors models.TaskRelation.RelationKind's tag (see the sync note there).
+func tasksRelationsDelete(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The numeric id of the base task."`
+ RelationKind models.RelationKind `path:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of the relation to remove."`
+ OtherTaskID int64 `path:"otherTask" doc:"The numeric id of the other task in the relation."`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rel := &models.TaskRelation{
+ TaskID: in.TaskID,
+ RelationKind: in.RelationKind,
+ OtherTaskID: in.OtherTaskID,
+ }
+ if err := handler.DoDelete(ctx, rel, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/routes/api/v2/task_unread_status.go b/pkg/routes/api/v2/task_unread_status.go
new file mode 100644
index 000000000..f7a944092
--- /dev/null
+++ b/pkg/routes/api/v2/task_unread_status.go
@@ -0,0 +1,73 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// taskReadBody confirms the mark-read action: the underlying model carries no
+// JSON-exposed fields, so it returns a status message rather than a resource.
+type taskReadBody struct {
+ Body struct {
+ Message string `json:"message" readOnly:"true" doc:"A confirmation message."`
+ }
+}
+
+// RegisterTaskUnreadStatusRoutes wires the mark-task-as-read action onto the Huma API.
+//
+// Marking a task read clears the caller's unread entry for it, which is what
+// drives the per-task "unread" dot shown for mentions and other notifications.
+// The model's Update deletes that entry, so the action is idempotent — PUT, not
+// POST. It is also unconditional: there is no read entry to clear for a task the
+// caller cannot see, so it succeeds as a no-op rather than refusing.
+func RegisterTaskUnreadStatusRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-mark-read",
+ Summary: "Mark a task as read",
+ Description: "Clears the authenticated user's unread status for a task, dismissing the unread indicator raised by mentions and other task notifications. Idempotent: marking an already-read or inaccessible task succeeds as a no-op.",
+ Method: http.MethodPut,
+ Path: "/tasks/{projecttask}/read",
+ Tags: tags,
+ }, tasksMarkRead)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskUnreadStatusRoutes) }
+
+func tasksMarkRead(ctx context.Context, in *struct {
+ TaskID int64 `path:"projecttask" doc:"The numeric id of the task to mark as read."`
+}) (*taskReadBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ t := &models.TaskUnreadStatus{TaskID: in.TaskID}
+ if err := handler.DoUpdate(ctx, t, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ out := &taskReadBody{}
+ out.Body.Message = "success"
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/tasks.go b/pkg/routes/api/v2/tasks.go
new file mode 100644
index 000000000..49fa21910
--- /dev/null
+++ b/pkg/routes/api/v2/tasks.go
@@ -0,0 +1,225 @@
+// 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 apiv2
+
+import (
+ "context"
+ "strconv"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/danielgtaylor/huma/v2/conditional"
+)
+
+// expandDoc lists the accepted expand values; shared between the by-id and
+// by-index operations so the docs stay in sync.
+const expandDoc = "Embed extra, more expensive data in each task. Repeatable. One of: subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread. Expanding can return more tasks than the page limit (subtasks) and inflate the response."
+
+// parseTaskExpand turns the raw `expand` query values into validated
+// TaskCollectionExpandable entries. Kept package-level for the TaskCollection
+// list endpoint, which accepts the same option. An invalid value returns the
+// model's own validation error, which translateDomainError maps to 422.
+func parseTaskExpand(raw []string) ([]models.TaskCollectionExpandable, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ expand := make([]models.TaskCollectionExpandable, 0, len(raw))
+ for _, e := range raw {
+ v := models.TaskCollectionExpandable(e)
+ if err := v.Validate(); err != nil {
+ return nil, err
+ }
+ expand = append(expand, v)
+ }
+ return expand, nil
+}
+
+// RegisterTaskRoutes wires Task CRUD onto the Huma API. The list lives on
+// TaskCollection, not here.
+func RegisterTaskRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-read",
+ Summary: "Get a task",
+ Description: "Returns a single task by its numeric id. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified. " + expandDoc,
+ Method: "GET",
+ Path: "/tasks/{projecttask}",
+ Tags: tags,
+ }, tasksRead)
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-read-by-index",
+ Summary: "Get a task by its project index",
+ Description: "Returns a single task addressed by its per-project index. The {project} segment accepts either a numeric project id or a textual project identifier (e.g. \"PROJ\"); a value made solely of digits is always treated as an id. " + expandDoc,
+ Method: "GET",
+ Path: "/projects/{project}/tasks/by-index/{index}",
+ Tags: tags,
+ }, tasksReadByIndex)
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-create",
+ Summary: "Create a task",
+ Description: "Creates a task in the project from the URL. The authenticated user needs write access to that project and becomes the task's creator.",
+ Method: "POST",
+ Path: "/projects/{project}/tasks",
+ Tags: tags,
+ }, tasksCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-update",
+ Summary: "Update a task",
+ Description: "Replaces all of a task's fields; requires write access. Setting project_id to a different project moves the task and also requires write access to the target project. Use PATCH for a partial update.",
+ Method: "PUT",
+ Path: "/tasks/{projecttask}",
+ Tags: tags,
+ }, tasksUpdate)
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-delete",
+ Summary: "Delete a task",
+ Description: "Deletes a task. Requires write access to its project.",
+ Method: "DELETE",
+ Path: "/tasks/{projecttask}",
+ Tags: tags,
+ }, tasksDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskRoutes) }
+
+type taskReadOneBody struct {
+ models.Task
+ MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this task (0=read, 1=read/write, 2=admin)."`
+}
+
+func tasksRead(ctx context.Context, in *struct {
+ ID int64 `path:"projecttask" doc:"The numeric id of the task."`
+ Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."`
+ conditional.Params
+}) (*singleReadBody[taskReadOneBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ expand, err := parseTaskExpand(in.Expand)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ task := &models.Task{ID: in.ID, Expand: expand}
+ maxPermission, err := handler.DoReadOne(ctx, task, a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
+ return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
+}
+
+func tasksReadByIndex(ctx context.Context, in *struct {
+ Project string `path:"project" doc:"A numeric project id or a textual project identifier (e.g. \"PROJ\")."`
+ Index int64 `path:"index" doc:"The per-project task index."`
+ Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."`
+ conditional.Params
+}) (*singleReadBody[taskReadOneBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ expand, err := parseTaskExpand(in.Expand)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ projectID, err := resolveProjectIdentifier(in.Project)
+ if err != nil {
+ return nil, err
+ }
+
+ // ID 0 + ProjectID + Index makes the model resolve the id from the
+ // (project, index) pair in both CanRead and ReadOne.
+ task := &models.Task{ProjectID: projectID, Index: in.Index, Expand: expand}
+ maxPermission, err := handler.DoReadOne(ctx, task, a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
+ return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
+}
+
+func tasksCreate(ctx context.Context, in *struct {
+ Project int64 `path:"project" doc:"The numeric id of the project to create the task in."`
+ Body models.Task
+}) (*singleBody[models.Task], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ task := &in.Body
+ task.ProjectID = in.Project // URL wins over body
+ if err := handler.DoCreate(ctx, task, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.Task]{Body: task}, nil
+}
+
+// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
+func tasksUpdate(ctx context.Context, in *struct {
+ ID int64 `path:"projecttask"`
+ Body taskReadOneBody
+}) (*singleBody[models.Task], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ task := &in.Body.Task
+ task.ID = in.ID // URL wins over body
+ if err := handler.DoUpdate(ctx, task, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.Task]{Body: task}, nil
+}
+
+func tasksDelete(ctx context.Context, in *struct {
+ ID int64 `path:"projecttask"`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoDelete(ctx, &models.Task{ID: in.ID}, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
+
+// resolveProjectIdentifier turns the {project} path segment into a numeric
+// project id. A pure-digit value is always an id (mirroring v1's
+// ResolveProjectIdentifier middleware); anything else is looked up as a
+// case-insensitive identifier and 404s if unknown.
+func resolveProjectIdentifier(raw string) (int64, error) {
+ if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
+ return id, nil
+ }
+ s := db.NewSession()
+ defer s.Close()
+ project, err := models.GetProjectSimpleByIdentifier(s, raw)
+ if err != nil {
+ return 0, translateDomainError(err)
+ }
+ return project.ID, nil
+}
diff --git a/pkg/routes/api/v2/testing.go b/pkg/routes/api/v2/testing.go
new file mode 100644
index 000000000..2f753f3fe
--- /dev/null
+++ b/pkg/routes/api/v2/testing.go
@@ -0,0 +1,129 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/routes/api/shared"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// testingReplaceInput is the request for resetting a single table. The
+// Authorization header carries the configured testing token (not a JWT or API
+// token); the endpoint is public and checks it in-handler like v1.
+type testingReplaceInput struct {
+ Table string `path:"table" doc:"The table to reset."`
+ // String (not bool) so absent is distinguishable from an explicit "false":
+ // like v1, an absent truncate parameter means truncate. Huma does not
+ // support *bool params, and a bool with default:"true" silently ignores an
+ // explicit ?truncate=false, so the parameter is read as a raw string and
+ // interpreted in the handler exactly like v1 does.
+ Truncate string `query:"truncate" enum:"true,false" doc:"Empty the table (and its dependents) before inserting the rows. Defaults to true; pass false to restore on top of existing data."`
+ Authorization string `header:"Authorization" doc:"The configured testing token."`
+ Body []map[string]any `doc:"The rows to write into the table. Free-form objects matching the table's columns."`
+}
+
+type testingReplaceBody struct {
+ Body []map[string]any `doc:"The table's contents after the reset."`
+}
+
+type testingTruncateAllInput struct {
+ Authorization string `header:"Authorization" doc:"The configured testing token."`
+}
+
+type testingTruncateAllBody struct {
+ Body struct {
+ Message string `json:"message" doc:"Always \"ok\" on success."`
+ }
+}
+
+// RegisterTestingRoutes wires the e2e testing-support endpoints onto the Huma
+// API. They are only mounted when the testing token is configured, matching v1.
+func RegisterTestingRoutes(api huma.API) {
+ if config.ServiceTestingtoken.GetString() == "" {
+ return
+ }
+
+ tags := []string{"testing"}
+ // Public: opt out of the globally-applied JWT/API-token auth — these
+ // authenticate with the testing token via the Authorization header
+ // instead. Their paths are also listed in unauthenticatedAPIPaths so the
+ // token middleware lets them through.
+ noAuth := []map[string][]string{}
+
+ Register(api, huma.Operation{
+ OperationID: "testing-truncate-all",
+ Summary: "Truncate all tables",
+ Description: "Removes all data from every Vikunja table. Used by e2e tests to ensure a clean state before each test. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.",
+ Method: http.MethodDelete,
+ Path: "/test/all",
+ Tags: tags,
+ Security: noAuth,
+ // v1 returns 200 with a body rather than the 204 a DELETE would default to.
+ DefaultStatus: http.StatusOK,
+ }, testingTruncateAll)
+
+ Register(api, huma.Operation{
+ OperationID: "testing-replace-table",
+ Summary: "Reset a table to a defined state",
+ Description: "Replaces the contents of the named table with the rows in the payload and returns the resulting contents. Used by e2e tests to seed fixtures. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.",
+ Method: http.MethodPut,
+ Path: "/test/{table}",
+ Tags: tags,
+ Security: noAuth,
+ // Mirror v1's 201 for a successful reset.
+ DefaultStatus: http.StatusCreated,
+ }, testingReplaceTable)
+}
+
+func init() { AddRouteRegistrar(RegisterTestingRoutes) }
+
+func testingReplaceTable(_ context.Context, in *testingReplaceInput) (*testingReplaceBody, error) {
+ if in.Authorization != config.ServiceTestingtoken.GetString() {
+ return nil, huma.Error403Forbidden("forbidden")
+ }
+
+ // Mirror v1: absent or "true" truncates; only an explicit "false" appends.
+ truncate := in.Truncate == "true" || in.Truncate == ""
+ data, err := shared.ReplaceTableContents(in.Table, in.Body, truncate)
+ if err != nil {
+ log.Errorf("Error replacing table data: %v", err)
+ return nil, huma.Error500InternalServerError("could not replace table data")
+ }
+
+ return &testingReplaceBody{Body: data}, nil
+}
+
+func testingTruncateAll(_ context.Context, in *testingTruncateAllInput) (*testingTruncateAllBody, error) {
+ if in.Authorization != config.ServiceTestingtoken.GetString() {
+ return nil, huma.Error403Forbidden("forbidden")
+ }
+
+ if err := shared.TruncateAllTestingTables(); err != nil {
+ log.Errorf("Error truncating all tables: %v", err)
+ return nil, huma.Error500InternalServerError("could not truncate tables")
+ }
+
+ out := &testingTruncateAllBody{}
+ out.Body.Message = "ok"
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/time_entries.go b/pkg/routes/api/v2/time_entries.go
new file mode 100644
index 000000000..a58ee8b92
--- /dev/null
+++ b/pkg/routes/api/v2/time_entries.go
@@ -0,0 +1,285 @@
+// 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 apiv2
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/danielgtaylor/huma/v2/conditional"
+)
+
+// timeTrackingGate is Huma operation middleware that 404s a time-tracking op when the license
+// feature is off. It's a middleware because license state can change while the instance is running.
+func timeTrackingGate(api huma.API) func(huma.Context, func(huma.Context)) {
+ return func(ctx huma.Context, next func(huma.Context)) {
+ if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
+ _ = huma.WriteErr(api, ctx, http.StatusNotFound, "Not Found")
+ return
+ }
+ next(ctx)
+ }
+}
+
+func registerGated[I, O any](api huma.API, op huma.Operation, handler func(context.Context, *I) (*O, error)) {
+ op.Middlewares = append(op.Middlewares, timeTrackingGate(api))
+ Register(api, op, handler)
+}
+
+type timeEntryListBody struct {
+ Body Paginated[*models.TimeEntry]
+}
+
+// RegisterTimeEntryRoutes wires the time-entry CRUD surface onto the Huma API.
+func RegisterTimeEntryRoutes(api huma.API) {
+ tags := []string{"time-entries"}
+
+ registerGated(api, huma.Operation{
+ OperationID: "time-entries-list",
+ Summary: "List time entries",
+ Description: "Returns the time entries the authenticated user can see, paginated. Filterable by date range, project, task and user.",
+ Method: http.MethodGet,
+ Path: "/time-entries",
+ Tags: tags,
+ }, timeEntriesList)
+
+ registerGated(api, huma.Operation{
+ OperationID: "time-entries-read",
+ Summary: "Get a time entry",
+ Description: "Returns a single time entry. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.",
+ Method: http.MethodGet,
+ Path: "/time-entries/{id}",
+ Tags: tags,
+ }, timeEntriesRead)
+
+ registerGated(api, huma.Operation{
+ OperationID: "time-entries-create",
+ Summary: "Create a time entry",
+ Description: "Logs a manual time entry for the authenticated user. Exactly one of task_id / project_id must be set.",
+ Method: http.MethodPost,
+ Path: "/time-entries",
+ Tags: tags,
+ }, timeEntriesCreate)
+
+ registerGated(api, huma.Operation{
+ OperationID: "time-entries-update",
+ Summary: "Update a time entry",
+ Description: "Updates a time entry. Only the author may update it. The entry can be moved between a task and a project — exactly one of task_id / project_id must be set, and you need read access to the new one. PUT replaces all editable fields; use PATCH for a partial update.",
+ Method: http.MethodPut,
+ Path: "/time-entries/{id}",
+ Tags: tags,
+ }, timeEntriesUpdate)
+
+ registerGated(api, huma.Operation{
+ OperationID: "time-entries-delete",
+ Summary: "Delete a time entry",
+ Description: "Deletes a time entry. Only the author may delete it. If it is the running timer, deleting it removes that timer.",
+ Method: http.MethodDelete,
+ Path: "/time-entries/{id}",
+ Tags: tags,
+ }, timeEntriesDelete)
+
+ registerGated(api, huma.Operation{
+ OperationID: "task-time-entries-list",
+ Summary: "List a task's time entries",
+ Description: "Returns the time entries logged against the given task, across all users, paginated. Scoped to what you can read: an inaccessible or unknown task yields an empty list, not an error.",
+ Method: http.MethodGet,
+ Path: "/tasks/{task_id}/time-entries",
+ Tags: tags,
+ }, taskTimeEntriesList)
+
+ registerGated(api, huma.Operation{
+ OperationID: "project-time-entries-list",
+ Summary: "List a project's time entries",
+ Description: "Returns the time entries for the given project — both standalone project entries and entries on tasks currently in the project — paginated. Scoped to what you can read: an inaccessible or unknown project yields an empty list, not an error.",
+ Method: http.MethodGet,
+ Path: "/projects/{project_id}/time-entries",
+ Tags: tags,
+ }, projectTimeEntriesList)
+
+ registerGated(api, huma.Operation{
+ OperationID: "time-entries-timer-stop",
+ Summary: "Stop the running timer",
+ Description: "Stops the authenticated user's running timer, setting its end time to the server's current time, and returns the stopped entry. Returns 404 when no timer is running. Starting a timer and editing entries go through the regular create/update endpoints.",
+ Method: http.MethodPost,
+ Path: "/time-entries/timer/stop",
+ // Override the wrapper's POST→201: this stops an existing entry, it creates nothing.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, timeEntriesTimerStop)
+}
+
+func init() { AddRouteRegistrar(RegisterTimeEntryRoutes) }
+
+// timeEntriesTimerStop is a custom action scoped to the caller: it stops their
+// own running timer, so it owns its session and needs no resource permission
+// beyond authentication.
+func timeEntriesTimerStop(ctx context.Context, _ *struct{}) (*singleBody[models.TimeEntry], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ entry, err := models.StopRunningTimer(s, a)
+ if err != nil {
+ _ = s.Rollback()
+ events.CleanupPending(s)
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ events.CleanupPending(s)
+ return nil, translateDomainError(err)
+ }
+ events.DispatchPending(ctx, s)
+ return &singleBody[models.TimeEntry]{Body: entry}, nil
+}
+
+func timeEntriesList(ctx context.Context, in *struct {
+ ListParams
+ Filter string `query:"filter" doc:"Filter entries with the task filter syntax over user_id, task_id, project_id, start_time and end_time — e.g. \"project_id = 5 && start_time > now-7d\". Use end_time = null to match running timers."`
+ FilterTimezone string `query:"filter_timezone" doc:"IANA timezone name used to resolve relative dates (now, now-7d) in the filter, e.g. Europe/Berlin."`
+}) (*timeEntryListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ m := &models.TimeEntry{
+ Filter: in.Filter,
+ FilterTimezone: in.FilterTimezone,
+ }
+ result, _, total, err := handler.DoReadAll(ctx, m, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return timeEntriesListResponse(result, total, in.Page, in.PerPage)
+}
+
+type timeEntryReadBody struct {
+ models.TimeEntry
+ MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this time entry (0=read, 1=read/write, 2=admin)."`
+}
+
+func timeEntriesRead(ctx context.Context, in *struct {
+ ID int64 `path:"id"`
+ conditional.Params
+}) (*singleReadBody[timeEntryReadBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ entry := &models.TimeEntry{ID: in.ID}
+ maxPermission, err := handler.DoReadOne(ctx, entry, a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ body := &timeEntryReadBody{TimeEntry: *entry, MaxPermission: models.Permission(maxPermission)}
+ return conditionalReadResponse(&in.Params, body, entry.Updated, maxPermission)
+}
+
+func timeEntriesCreate(ctx context.Context, in *struct {
+ Body models.TimeEntry
+}) (*singleBody[models.TimeEntry], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.TimeEntry]{Body: &in.Body}, nil
+}
+
+// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
+func timeEntriesUpdate(ctx context.Context, in *struct {
+ ID int64 `path:"id"`
+ Body timeEntryReadBody
+}) (*singleBody[models.TimeEntry], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ entry := &in.Body.TimeEntry
+ entry.ID = in.ID // URL wins over body
+ if err := handler.DoUpdate(ctx, entry, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.TimeEntry]{Body: entry}, nil
+}
+
+func timeEntriesDelete(ctx context.Context, in *struct {
+ ID int64 `path:"id"`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoDelete(ctx, &models.TimeEntry{ID: in.ID}, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
+
+func taskTimeEntriesList(ctx context.Context, in *struct {
+ TaskID int64 `path:"task_id"`
+ ListParams
+}) (*timeEntryListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return timeEntriesListResponse(result, total, in.Page, in.PerPage)
+}
+
+func projectTimeEntriesList(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project_id"`
+ ListParams
+}) (*timeEntryListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, &models.TimeEntry{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return timeEntriesListResponse(result, total, in.Page, in.PerPage)
+}
+
+// timeEntriesListResponse turns the any-typed DoReadAll result into the list
+// envelope, hard-failing on a type mismatch (the generic-any silent-empty trap).
+func timeEntriesListResponse(result any, total int64, page, perPage int) (*timeEntryListBody, error) {
+ items, ok := result.([]*models.TimeEntry)
+ if !ok {
+ return nil, fmt.Errorf("timeEntries.ReadAll returned unexpected type %T (expected []*models.TimeEntry)", result)
+ }
+ return &timeEntryListBody{Body: NewPaginated(items, total, page, perPage)}, nil
+}
diff --git a/pkg/routes/api/v2/token_meta.go b/pkg/routes/api/v2/token_meta.go
new file mode 100644
index 000000000..120c3c81e
--- /dev/null
+++ b/pkg/routes/api/v2/token_meta.go
@@ -0,0 +1,138 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// tokenTestBody is the response for the token-check endpoints.
+type tokenTestBody struct {
+ Body struct {
+ Message string `json:"message" readOnly:"true" doc:"A static confirmation message."`
+ }
+}
+
+// apiRoutesBody is the response for the token-routes endpoint: the available
+// API routes grouped by permission, for building API-token scopes.
+type apiRoutesBody struct {
+ Body map[string]models.APITokenRoute
+}
+
+// renewTokenBody wraps a freshly issued link-share JWT. The token field is
+// inlined rather than embedding auth.Token because Huma derives schema names
+// from the bare Go type name, and a top-level auth.Token body would collide with
+// user.Token (the caldav-token schema, also named "Token").
+type renewTokenBody struct {
+ Body struct {
+ Token string `json:"token" readOnly:"true" doc:"The renewed JWT auth token."`
+ }
+}
+
+func init() { AddRouteRegistrar(RegisterTokenMetaRoutes) }
+
+// RegisterTokenMetaRoutes wires the token introspection helpers and the
+// link-share token renewal endpoint.
+func RegisterTokenMetaRoutes(api huma.API) {
+ tags := []string{"auth"}
+
+ // v1 served GET as a 200 "ok" and POST as a 418 teapot easter egg; v2 makes
+ // both a plain 200 so a token check is an ordinary success.
+ Register(api, huma.Operation{
+ OperationID: "token-test",
+ Summary: "Test a token",
+ Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.",
+ Method: http.MethodGet,
+ Path: "/token/test",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, tokenTest)
+
+ Register(api, huma.Operation{
+ OperationID: "token-check",
+ Summary: "Check a token",
+ Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.",
+ Method: http.MethodPost,
+ Path: "/token/test",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, tokenCheck)
+
+ Register(api, huma.Operation{
+ OperationID: "token-routes",
+ Summary: "List API token routes",
+ Description: "Returns every API route available to scope an API token against, grouped by resource and permission. Covers both /api/v1 and /api/v2 routes.",
+ Method: http.MethodGet,
+ Path: "/routes",
+ Tags: []string{"api"},
+ }, tokenRoutes)
+
+ Register(api, huma.Operation{
+ OperationID: "token-renew",
+ Summary: "Renew a link-share token",
+ Description: "Issues a fresh JWT for the current link share. Only link-share tokens can be renewed here; user sessions must use the refresh-token flow.",
+ Method: http.MethodPost,
+ Path: "/user/token",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, tokenRenew)
+}
+
+func tokenTest(_ context.Context, _ *struct{}) (*tokenTestBody, error) {
+ out := &tokenTestBody{}
+ out.Body.Message = "ok"
+ return out, nil
+}
+
+func tokenCheck(_ context.Context, _ *struct{}) (*tokenTestBody, error) {
+ out := &tokenTestBody{}
+ out.Body.Message = "ok"
+ return out, nil
+}
+
+func tokenRoutes(_ context.Context, _ *struct{}) (*apiRoutesBody, error) {
+ return &apiRoutesBody{Body: models.GetAPITokenRoutes()}, nil
+}
+
+func tokenRenew(ctx context.Context, _ *struct{}) (*renewTokenBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Only link-share tokens are renewable here; a user JWT lands as *user.User
+ // and must use the refresh-token flow instead.
+ share, ok := a.(*models.LinkSharing)
+ if !ok {
+ return nil, huma.Error400BadRequest("User tokens cannot be renewed via this endpoint. Use the refresh-token flow instead.")
+ }
+
+ t, err := auth.NewLinkShareJWTAuthtoken(share)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ out := &renewTokenBody{}
+ out.Body.Token = t
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/user_deletion.go b/pkg/routes/api/v2/user_deletion.go
new file mode 100644
index 000000000..1ecc5e009
--- /dev/null
+++ b/pkg/routes/api/v2/user_deletion.go
@@ -0,0 +1,172 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+ "xorm.io/xorm"
+)
+
+type userDeletionPasswordBody struct {
+ Body struct {
+ Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."`
+ }
+}
+
+type userDeletionConfirmBody struct {
+ Body struct {
+ Token string `json:"token" required:"true" minLength:"1" doc:"The deletion confirmation token from the email sent by the request-deletion endpoint."`
+ }
+}
+
+func RegisterUserDeletionRoutes(api huma.API) {
+ tags := []string{"user"}
+
+ Register(api, huma.Operation{
+ OperationID: "user-deletion-request",
+ Summary: "Request account deletion",
+ Description: "Starts deletion of the authenticated user's account. Local users must provide their password; a confirmation email is then sent and deletion only proceeds once confirmed.",
+ Method: http.MethodPost,
+ Path: "/user/deletion/request",
+ Tags: tags,
+ DefaultStatus: http.StatusNoContent,
+ }, userDeletionRequest)
+
+ Register(api, huma.Operation{
+ OperationID: "user-deletion-confirm",
+ Summary: "Confirm account deletion",
+ Description: "Confirms a requested account deletion using the token from the confirmation email and schedules the account for deletion.",
+ Method: http.MethodPost,
+ Path: "/user/deletion/confirm",
+ Tags: tags,
+ DefaultStatus: http.StatusNoContent,
+ }, userDeletionConfirm)
+
+ Register(api, huma.Operation{
+ OperationID: "user-deletion-cancel",
+ Summary: "Cancel account deletion",
+ Description: "Cancels a scheduled account deletion. Local users must provide their password.",
+ Method: http.MethodPost,
+ Path: "/user/deletion/cancel",
+ Tags: tags,
+ DefaultStatus: http.StatusNoContent,
+ }, userDeletionCancel)
+}
+
+func init() { AddRouteRegistrar(RegisterUserDeletionRoutes) }
+
+// authUserFromCtx resolves the full DB user for the authenticated caller, refusing
+// link shares (which have no account to delete) with a 403.
+func authUserFromCtx(ctx context.Context, s *xorm.Session) (*user.User, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ authUser, is := a.(*user.User)
+ if !is {
+ return nil, huma.Error403Forbidden("only users can manage account deletion")
+ }
+ // The auth user from the JWT claims is partial; re-fetch for the password hash.
+ u, err := user.GetUserByID(s, authUser.ID)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ return u, nil
+}
+
+func userDeletionRequest(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if u.IsLocalUser() {
+ if err := user.CheckUserPassword(u, in.Body.Password); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ }
+
+ if err := user.RequestDeletion(s, u); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
+
+func userDeletionConfirm(ctx context.Context, in *userDeletionConfirmBody) (*emptyBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if err := user.ConfirmDeletion(s, u, in.Body.Token); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
+
+func userDeletionCancel(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ if u.IsLocalUser() {
+ if err := user.CheckUserPassword(u, in.Body.Password); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ }
+
+ if err := user.CancelDeletion(s, u); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/routes/api/v2/user_export.go b/pkg/routes/api/v2/user_export.go
new file mode 100644
index 000000000..952f8127b
--- /dev/null
+++ b/pkg/routes/api/v2/user_export.go
@@ -0,0 +1,181 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/humaecho5"
+ "code.vikunja.io/api/pkg/user"
+ webfiles "code.vikunja.io/api/pkg/web/files"
+
+ "github.com/danielgtaylor/huma/v2"
+ "xorm.io/xorm"
+)
+
+type userExportPasswordBody struct {
+ Body struct {
+ Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."`
+ }
+}
+
+type userExportStatusBody struct {
+ Body *models.UserExportStatus
+}
+
+func RegisterUserExportRoutes(api huma.API) {
+ tags := []string{"user"}
+
+ Register(api, huma.Operation{
+ OperationID: "user-export-request",
+ Summary: "Request a data export",
+ Description: "Starts building a full export of the authenticated user's data. Local users must confirm with their password. The export runs in the background; an email is sent when it is ready to download.",
+ Method: http.MethodPost,
+ Path: "/user/export/request",
+ Tags: tags,
+ DefaultStatus: http.StatusOK,
+ }, userExportRequest)
+
+ Register(api, huma.Operation{
+ OperationID: "user-export-download",
+ Summary: "Download the data export",
+ Description: "Streams the authenticated user's prepared data export as a zip file. Local users must confirm with their password. Fails with 404 if no export has been prepared. A POST (not GET) because the password is sent in the body.",
+ Method: http.MethodPost,
+ Path: "/user/export/download",
+ Tags: tags,
+ // Spell out the binary response; the default would be modeled as JSON.
+ Responses: map[string]*huma.Response{
+ "200": {
+ Description: "The data export as a zip file.",
+ Content: map[string]*huma.MediaType{
+ "application/zip": {
+ Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
+ },
+ },
+ },
+ },
+ }, userExportDownload)
+
+ Register(api, huma.Operation{
+ OperationID: "user-export-status",
+ Summary: "Get the current data export",
+ Description: "Returns metadata about the authenticated user's current data export (id, size, creation and expiry time), or null if none has been prepared.",
+ Method: http.MethodGet,
+ Path: "/user/export",
+ Tags: tags,
+ }, userExportStatus)
+}
+
+func init() { AddRouteRegistrar(RegisterUserExportRoutes) }
+
+// confirmExportPassword resolves the full DB user and, for local accounts, verifies
+// the supplied password — mirroring v1's checkExportRequest. External-provider users
+// cannot supply a password and are passed through, as in v1.
+func confirmExportPassword(ctx context.Context, s *xorm.Session, password string) (*user.User, error) {
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ return nil, err
+ }
+ if u.IsLocalUser() {
+ if err := user.CheckUserPassword(u, password); err != nil {
+ return nil, translateDomainError(err)
+ }
+ }
+ return u, nil
+}
+
+func userExportRequest(ctx context.Context, in *userExportPasswordBody) (*messageBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := confirmExportPassword(ctx, s, in.Body.Password)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ events.DispatchOnCommit(s, &models.UserDataExportRequestedEvent{User: u})
+
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ events.CleanupPending(s)
+ return nil, translateDomainError(err)
+ }
+ events.DispatchPending(ctx, s)
+
+ out := &messageBody{}
+ out.Body.Message = "Successfully requested data export. We will send you an email when it's ready."
+ return out, nil
+}
+
+func userExportDownload(ctx context.Context, in *userExportPasswordBody) (*huma.StreamResponse, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := confirmExportPassword(ctx, s, in.Body.Password)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ exportFile, err := models.GetUserDataExportFile(u)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ // The file reader comes from object storage, not the DB session, so it stays
+ // valid after the commit; the StreamResponse callback runs after this returns.
+ if err := s.Commit(); err != nil {
+ _ = s.Rollback()
+ // The stream callback (which closes the reader) won't run on this error path.
+ _ = exportFile.File.Close()
+ return nil, translateDomainError(err)
+ }
+
+ return &huma.StreamResponse{Body: func(hctx huma.Context) {
+ defer func() { _ = exportFile.File.Close() }()
+ c := humaecho5.Unwrap(hctx)
+ webfiles.WriteFileDownload((*c).Response(), (*c).Request(), exportFile)
+ }}, nil
+}
+
+func userExportStatus(ctx context.Context, _ *struct{}) (*userExportStatusBody, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := authUserFromCtx(ctx, s)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, err
+ }
+
+ status, err := models.GetUserDataExportStatus(u)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &userExportStatusBody{Body: status}, nil
+}
diff --git a/pkg/routes/api/v2/user_search.go b/pkg/routes/api/v2/user_search.go
new file mode 100644
index 000000000..5848ba5c0
--- /dev/null
+++ b/pkg/routes/api/v2/user_search.go
@@ -0,0 +1,120 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+type userListBody struct {
+ Body Paginated[*user.User]
+}
+
+// RegisterUserSearchRoutes wires the two user-search endpoints onto the Huma API:
+// a global search and a per-project search used for share autocomplete.
+func RegisterUserSearchRoutes(api huma.API) {
+ Register(api, huma.Operation{
+ OperationID: "users-search",
+ Summary: "Search users",
+ Description: "Searches users by username, name or full email. Matching by name or email requires the target user to have made themselves discoverable, unless both users share an external (OIDC/LDAP) team. Email addresses are never returned.",
+ Method: http.MethodGet,
+ Path: "/users",
+ Tags: []string{"user"},
+ }, usersSearch)
+
+ Register(api, huma.Operation{
+ OperationID: "projects-users-search",
+ Summary: "Search users with access to a project",
+ Description: "Returns the users who can access the project — through ownership, a direct share or a team — optionally filtered by a search string. Intended for share autocomplete. Requires read access to the project.",
+ Method: http.MethodGet,
+ Path: "/projects/{project}/users/search",
+ Tags: []string{"sharing"},
+ }, projectUsersSearch)
+}
+
+func init() { AddRouteRegistrar(RegisterUserSearchRoutes) }
+
+func usersSearch(ctx context.Context, in *struct {
+ Q string `query:"q" doc:"Search query matched against username, name or full email."`
+}) (*userListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ currentUser, err := models.GetUserOrLinkShareUser(s, a)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ users, err := user.SearchUsers(s, in.Q, currentUser)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &userListBody{Body: NewPaginated(users, int64(len(users)), 1, len(users))}, nil
+}
+
+func projectUsersSearch(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ Q string `query:"q" doc:"Search query matched against username and name."`
+}) (*userListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ currentUser, err := models.GetUserOrLinkShareUser(s, a)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ project := &models.Project{ID: in.ProjectID}
+ users, canRead, err := models.SearchUsersForProject(s, project, a, currentUser, in.Q)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if !canRead {
+ _ = s.Rollback()
+ return nil, huma.Error403Forbidden("forbidden")
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &userListBody{Body: NewPaginated(users, int64(len(users)), 1, len(users))}, nil
+}
diff --git a/pkg/routes/api/v2/user_settings.go b/pkg/routes/api/v2/user_settings.go
new file mode 100644
index 000000000..35a366644
--- /dev/null
+++ b/pkg/routes/api/v2/user_settings.go
@@ -0,0 +1,334 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/routes/api/shared"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/tkuchiki/go-timezone"
+)
+
+// userInfoBody is the GET /user response: the public user fields plus the
+// computed account facts v1 returned alongside the user object.
+type userInfoBody struct {
+ user.User
+ Settings *models.UserGeneralSettings `json:"settings" readOnly:"true" doc:"The current user's settings."`
+ DeletionScheduledAt time.Time `json:"deletion_scheduled_at" readOnly:"true" doc:"When the account is scheduled for deletion, if a deletion was requested."`
+ IsLocalUser bool `json:"is_local_user" readOnly:"true" doc:"True if the user authenticates locally (not via LDAP or OpenID)."`
+ AuthProvider string `json:"auth_provider" readOnly:"true" doc:"The name of the source the user authenticated with: 'local', 'ldap', or the configured OpenID provider name."`
+ IsAdmin bool `json:"is_admin" readOnly:"true" doc:"True if the user is an instance administrator."`
+}
+
+// userAvatarProviderBody is the get/set body for the user's avatar provider.
+type userAvatarProviderBody struct {
+ AvatarProvider string `json:"avatar_provider" doc:"The avatar provider. One of: gravatar (uses the user email), upload, initials, marble (random per user), ldap (synced from LDAP), openid (synced from OpenID), default."`
+}
+
+type userActionMessageBody struct {
+ Message string `json:"message" readOnly:"true" doc:"A confirmation message."`
+}
+
+// RegisterUserSettingsRoutes wires the current-user account & settings
+// endpoints onto the Huma API. These are not CRUDable resources: each operates
+// on the authenticated user pulled from the request context.
+func RegisterUserSettingsRoutes(api huma.API) {
+ tags := []string{"user"}
+
+ Register(api, huma.Operation{
+ OperationID: "user-show",
+ Summary: "Get the current user",
+ Description: "Returns the authenticated user together with their settings and computed account facts (auth_provider, is_local_user, is_admin, deletion_scheduled_at).",
+ Method: http.MethodGet,
+ Path: "/user",
+ Tags: tags,
+ }, userShow)
+
+ Register(api, huma.Operation{
+ OperationID: "user-change-password",
+ Summary: "Change the current user's password",
+ Description: "Changes the authenticated user's password after verifying the old one. All of the user's existing sessions are invalidated.",
+ Method: http.MethodPost,
+ Path: "/user/password",
+ // Changes a password, it creates nothing — keep 200 over the wrapper's POST→201.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, userChangePassword)
+
+ Register(api, huma.Operation{
+ OperationID: "user-update-email",
+ Summary: "Update the current user's email address",
+ Description: "Sets a new email address for the authenticated user after verifying their password. If the mailer is enabled the change is pending until the user confirms it via a link sent to the new address; otherwise it takes effect immediately.",
+ Method: http.MethodPut,
+ Path: "/user/settings/email",
+ Tags: tags,
+ }, userUpdateEmail)
+
+ Register(api, huma.Operation{
+ OperationID: "user-update-settings",
+ Summary: "Update the current user's general settings",
+ Description: "Replaces the authenticated user's general settings (name, reminders, discoverability, default project, week start, language, timezone, frontend settings).",
+ Method: http.MethodPut,
+ Path: "/user/settings/general",
+ Tags: tags,
+ }, userUpdateSettings)
+
+ // Path differs from v1's /user/settings/avatar: on v2 that path is the
+ // binary avatar upload (PUT), so the provider get/set live on a sub-path.
+ Register(api, huma.Operation{
+ OperationID: "user-get-avatar-provider",
+ Summary: "Get the current user's avatar provider",
+ Description: "Returns the avatar provider configured for the authenticated user.",
+ Method: http.MethodGet,
+ Path: "/user/settings/avatar/provider",
+ Tags: tags,
+ }, userGetAvatarProvider)
+
+ Register(api, huma.Operation{
+ OperationID: "user-set-avatar-provider",
+ Summary: "Set the current user's avatar provider",
+ Description: "Changes the avatar provider for the authenticated user. Valid values: gravatar, upload, initials, marble, ldap, openid, default.",
+ Method: http.MethodPut,
+ Path: "/user/settings/avatar/provider",
+ Tags: tags,
+ }, userSetAvatarProvider)
+
+ Register(api, huma.Operation{
+ OperationID: "user-timezones",
+ Summary: "List available time zones",
+ Description: "Returns every time zone this Vikunja instance can handle. The list depends on the host system and is unsorted; sort it client-side.",
+ Method: http.MethodGet,
+ Path: "/user/timezones",
+ Tags: tags,
+ }, userTimezones)
+}
+
+func init() { AddRouteRegistrar(RegisterUserSettingsRoutes) }
+
+func userShow(ctx context.Context, _ *struct{}) (*singleBody[userInfoBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := models.GetUserOrLinkShareUser(s, a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ info := &userInfoBody{
+ User: *u,
+ Settings: models.NewUserGeneralSettings(u),
+ DeletionScheduledAt: u.DeletionScheduledAt,
+ IsLocalUser: u.Issuer == user.IssuerLocal,
+ IsAdmin: u.IsAdmin,
+ }
+
+ // nolint:contextcheck // openid.GetAllProviders/Issuer (called via shared) take
+ // no context; threading one would change those signatures across both APIs.
+ info.AuthProvider, err = shared.GetAuthProviderName(u)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[userInfoBody]{Body: info}, nil
+}
+
+func userChangePassword(ctx context.Context, in *struct {
+ Body struct {
+ OldPassword string `json:"old_password" doc:"The current password, for confirmation."`
+ NewPassword string `json:"new_password" valid:"bcrypt_password" minLength:"8" maxLength:"72" doc:"The new password. Max 72 bytes (a bcrypt limit), which may be fewer than 72 characters."`
+ }
+}) (*singleBody[userActionMessageBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ if err := models.ChangeUserPassword(ctx, s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The password was updated successfully."}}, nil
+}
+
+func userUpdateEmail(ctx context.Context, in *struct {
+ Body struct {
+ NewEmail string `json:"new_email" valid:"email,length(0|250),required" maxLength:"250" doc:"The new email address."`
+ Password string `json:"password" doc:"The current password, for confirmation."`
+ }
+}) (*singleBody[userActionMessageBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ if err := user.ChangeUserEmail(ctx, s, doer, in.Body.Password, in.Body.NewEmail); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "We sent you email with a link to confirm your email address."}}, nil
+}
+
+func userUpdateSettings(ctx context.Context, in *struct {
+ Body models.UserGeneralSettings
+}) (*singleBody[userActionMessageBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID})
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := models.UpdateUserGeneralSettings(s, u, &in.Body); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The settings were updated successfully."}}, nil
+}
+
+func userGetAvatarProvider(ctx context.Context, _ *struct{}) (*singleBody[userAvatarProviderBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID})
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil
+}
+
+func userSetAvatarProvider(ctx context.Context, in *struct {
+ Body userAvatarProviderBody
+}) (*singleBody[userAvatarProviderBody], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ doer, err := user.GetFromAuth(a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID})
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := models.UpdateUserAvatarProvider(s, u, in.Body.AvatarProvider); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+
+ return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil
+}
+
+type timezonesBody struct {
+ Body []string
+}
+
+func userTimezones(ctx context.Context, _ *struct{}) (*timezonesBody, error) {
+ if _, err := authFromCtx(ctx); err != nil {
+ return nil, err
+ }
+
+ timezoneMap := make(map[string]bool) // de-dupe across the per-abbreviation groups
+ for _, group := range timezone.New().Timezones() {
+ for _, t := range group {
+ timezoneMap[t] = true
+ }
+ }
+
+ ts := make([]string, 0, len(timezoneMap))
+ for t := range timezoneMap {
+ ts = append(ts, t)
+ }
+
+ return &timezonesBody{Body: ts}, nil
+}
diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go
new file mode 100644
index 000000000..dd3b0c575
--- /dev/null
+++ b/pkg/routes/api/v2/user_totp.go
@@ -0,0 +1,255 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+ "xorm.io/xorm"
+)
+
+type totpStatusBody struct {
+ Body *user.TOTP
+}
+
+type totpEnableBody struct {
+ Body struct {
+ Passcode string `json:"passcode" doc:"The current totp passcode, used to confirm the authenticator is set up correctly."`
+ }
+}
+
+type totpDisableBody struct {
+ Body struct {
+ Password string `json:"password" doc:"The current user's password, required to disable totp."`
+ }
+}
+
+type totpMessageBody struct {
+ Body models.Message
+}
+
+// totpQrCodeResponse carries the qr code jpeg bytes plus a fixed Content-Type.
+// Huma writes the []byte Body straight to the wire; the header field overrides
+// content negotiation so image/jpeg reaches the client (matching v1).
+type totpQrCodeResponse struct {
+ ContentType string `header:"Content-Type"`
+ Body []byte
+}
+
+// RegisterTOTPRoutes wires the current-user totp (2FA) operations onto the Huma
+// API. Totp is a local-account feature; every handler rejects OIDC/LDAP users.
+func RegisterTOTPRoutes(api huma.API) {
+ if !config.ServiceEnableTotp.GetBool() {
+ return
+ }
+
+ tags := []string{"user"}
+
+ Register(api, huma.Operation{
+ OperationID: "totp-get",
+ Summary: "Get totp status",
+ Description: "Returns the authenticated user's current totp setting. Fails with 412 if totp was never enrolled. Local accounts only.",
+ Method: http.MethodGet,
+ Path: "/user/settings/totp",
+ Tags: tags,
+ }, totpGet)
+
+ Register(api, huma.Operation{
+ OperationID: "totp-enroll",
+ Summary: "Enroll into totp",
+ Description: "Creates the totp secret for the authenticated user. The setup must still be confirmed via the enable endpoint before it takes effect. Local accounts only.",
+ Method: http.MethodPost,
+ Path: "/user/settings/totp/enroll",
+ // v1 returns 200 here, not 201: enrollment is an inactive draft, not a usable resource yet.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, totpEnroll)
+
+ Register(api, huma.Operation{
+ OperationID: "totp-enable",
+ Summary: "Enable totp",
+ Description: "Activates a previously enrolled totp setting by confirming a passcode. All existing sessions are invalidated. Local accounts only.",
+ Method: http.MethodPost,
+ Path: "/user/settings/totp/enable",
+ // Confirms an existing enrollment; creates no new resource.
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, totpEnable)
+
+ Register(api, huma.Operation{
+ OperationID: "totp-disable",
+ Summary: "Disable totp",
+ Description: "Removes all totp settings for the authenticated user. Requires the current password for confirmation. Local accounts only.",
+ Method: http.MethodPost,
+ Path: "/user/settings/totp/disable",
+ DefaultStatus: http.StatusOK,
+ Tags: tags,
+ }, totpDisable)
+
+ Register(api, huma.Operation{
+ OperationID: "totp-qrcode",
+ Summary: "Get the totp enrollment qr code",
+ Description: "Returns the qr code for the authenticated user's enrolled totp setting as a jpeg image, for scanning into an authenticator app. Requires a prior enrollment. Local accounts only.",
+ Method: http.MethodGet,
+ Path: "/user/settings/totp/qrcode",
+ Tags: tags,
+ // Spell out the binary response; a bare []byte Body would otherwise be
+ // modeled as a base64 JSON string instead of binary image data.
+ Responses: map[string]*huma.Response{
+ "200": {
+ Description: "The qr code as a jpeg image.",
+ Content: map[string]*huma.MediaType{
+ "image/jpeg": {
+ Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
+ },
+ },
+ },
+ },
+ }, totpQrCode)
+}
+
+func init() { AddRouteRegistrar(RegisterTOTPRoutes) }
+
+// localUserFromCtx resolves the authenticated user and refuses anything that is
+// not a local account, mirroring v1's getLocalUserFromContext. The caller owns
+// the returned session. CheckUserPassword and IsLocalUser need the full DB
+// record (password hash, issuer), so this loads it rather than trusting the
+// token claims.
+func localUserFromCtx(ctx context.Context) (*user.User, *xorm.Session, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ s := db.NewSession()
+ u, err := models.GetUserOrLinkShareUser(s, a)
+ if err != nil {
+ s.Close()
+ return nil, nil, translateDomainError(err)
+ }
+ // A link share resolves to a synthetic, non-local user; any other auth type
+ // yields nil. Both must be refused — totp is a real-account-only feature.
+ if u == nil || !u.IsLocalUser() {
+ s.Close()
+ return nil, nil, translateDomainError(&user.ErrAccountIsNotLocal{})
+ }
+
+ return u, s, nil
+}
+
+func totpGet(ctx context.Context, _ *struct{}) (*totpStatusBody, error) {
+ u, s, err := localUserFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer s.Close()
+
+ t, err := user.GetTOTPForUser(s, u)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &totpStatusBody{Body: t}, nil
+}
+
+func totpEnroll(ctx context.Context, _ *struct{}) (*totpStatusBody, error) {
+ u, s, err := localUserFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer s.Close()
+
+ t, err := user.EnrollTOTP(s, u)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &totpStatusBody{Body: t}, nil
+}
+
+func totpEnable(ctx context.Context, in *totpEnableBody) (*totpMessageBody, error) {
+ u, s, err := localUserFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer s.Close()
+
+ if err := user.EnableTOTP(s, &user.TOTPPasscode{User: u, Passcode: in.Body.Passcode}); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := models.DeleteAllUserSessions(s, u.ID); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &totpMessageBody{Body: models.Message{Message: "TOTP was enabled successfully."}}, nil
+}
+
+func totpDisable(ctx context.Context, in *totpDisableBody) (*totpMessageBody, error) {
+ u, s, err := localUserFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer s.Close()
+
+ if err := user.CheckUserPassword(u, in.Body.Password); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := user.DisableTOTP(s, u); err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &totpMessageBody{Body: models.Message{Message: "TOTP was disabled successfully."}}, nil
+}
+
+func totpQrCode(ctx context.Context, _ *struct{}) (*totpQrCodeResponse, error) {
+ u, s, err := localUserFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer s.Close()
+
+ qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u)
+ if err != nil {
+ _ = s.Rollback()
+ return nil, translateDomainError(err)
+ }
+ if err := s.Commit(); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &totpQrCodeResponse{ContentType: "image/jpeg", Body: qrcode}, nil
+}
diff --git a/pkg/routes/api/v2/user_webhooks.go b/pkg/routes/api/v2/user_webhooks.go
new file mode 100644
index 000000000..b35407c79
--- /dev/null
+++ b/pkg/routes/api/v2/user_webhooks.go
@@ -0,0 +1,167 @@
+// 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 apiv2
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// models.Webhook.ReadAll returns []*models.Webhook, so that's the element type.
+type userWebhookListBody struct {
+ Body Paginated[*models.Webhook]
+}
+
+type userWebhookEventsBody struct {
+ Body []string
+}
+
+// RegisterUserWebhookRoutes wires the per-user webhook CRUD onto the Huma API.
+// User webhooks are the project-less sibling of the project webhooks (see
+// webhooks.go): they fire across all of a user's projects and are owned by the
+// user, not a project. Both resources share the webhooks.enabled gate; the check
+// runs here (not at init()) because RegisterAll fires after config is loaded.
+// Like project webhooks there is deliberately no ReadOne — webhooks carry
+// credentials — so AutoPatch synthesises no PATCH and update is PUT only.
+func RegisterUserWebhookRoutes(api huma.API) {
+ if !config.WebhooksEnabled.GetBool() {
+ return
+ }
+
+ tags := []string{"webhooks"}
+
+ Register(api, huma.Operation{
+ OperationID: "user-webhooks-list",
+ Summary: "List the current user's webhooks",
+ Description: "Returns the webhook targets the authenticated user has configured for themselves (not project webhooks), paginated. Secret and basic-auth credentials are never included.",
+ Method: http.MethodGet,
+ Path: "/user/settings/webhooks",
+ Tags: tags,
+ }, userWebhooksList)
+
+ Register(api, huma.Operation{
+ OperationID: "user-webhooks-events",
+ Summary: "List available user-directed webhook events",
+ Description: "Returns the webhook event names a user-level webhook may subscribe to. This is a subset of the project webhook events — only events that target a single user.",
+ Method: http.MethodGet,
+ Path: "/user/settings/webhooks/events",
+ Tags: tags,
+ }, userWebhooksEvents)
+
+ Register(api, huma.Operation{
+ OperationID: "user-webhooks-create",
+ Summary: "Create a webhook for the current user",
+ Description: "Creates a webhook target owned by the authenticated user that receives POST requests across all of their projects. The owning user is taken from the token, not the body. May only subscribe to user-directed events (see the events route). The secret and basic-auth credentials are write-only and not returned in the response.",
+ Method: http.MethodPost,
+ Path: "/user/settings/webhooks",
+ Tags: tags,
+ }, userWebhooksCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "user-webhooks-update",
+ Summary: "Update a user webhook's events",
+ Description: "Changes the events a user webhook subscribes to. Only the events list can be changed; target_url, secret and auth are immutable after creation. Only the owning user may update it.",
+ Method: http.MethodPut,
+ Path: "/user/settings/webhooks/{webhook}",
+ Tags: tags,
+ }, userWebhooksUpdate)
+
+ Register(api, huma.Operation{
+ OperationID: "user-webhooks-delete",
+ Summary: "Delete a user webhook",
+ Description: "Deletes a webhook owned by the authenticated user. Only the owning user may delete it.",
+ Method: http.MethodDelete,
+ Path: "/user/settings/webhooks/{webhook}",
+ Tags: tags,
+ }, userWebhooksDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterUserWebhookRoutes) }
+
+func userWebhooksList(ctx context.Context, in *ListParams) (*userWebhookListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, &models.Webhook{UserID: a.GetID()}, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ items, ok := result.([]*models.Webhook)
+ if !ok {
+ return nil, fmt.Errorf("webhooks.ReadAll returned unexpected type %T (expected []*models.Webhook)", result)
+ }
+ return &userWebhookListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
+}
+
+func userWebhooksEvents(_ context.Context, _ *struct{}) (*userWebhookEventsBody, error) {
+ return &userWebhookEventsBody{Body: models.GetUserDirectedWebhookEvents()}, nil
+}
+
+func userWebhooksCreate(ctx context.Context, in *struct {
+ Body models.Webhook
+}) (*singleBody[models.Webhook], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // Force user ownership: a user webhook is keyed on the user, never a project.
+ in.Body.UserID = a.GetID()
+ in.Body.ProjectID = 0
+ if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.Webhook]{Body: &in.Body}, nil
+}
+
+func userWebhooksUpdate(ctx context.Context, in *struct {
+ ID int64 `path:"webhook"`
+ Body models.Webhook
+}) (*singleBody[models.Webhook], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // canDoWebhook resolves the owner from the stored row, so only the id is
+ // needed to gate the update; the rest of the body's ownership fields are
+ // ignored. Update persists only the events list.
+ in.Body.ID = in.ID
+ if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.Webhook]{Body: &in.Body}, nil
+}
+
+func userWebhooksDelete(ctx context.Context, in *struct {
+ ID int64 `path:"webhook"`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoDelete(ctx, &models.Webhook{ID: in.ID}, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/routes/api/v2/webhook_events.go b/pkg/routes/api/v2/webhook_events.go
new file mode 100644
index 000000000..56ad57873
--- /dev/null
+++ b/pkg/routes/api/v2/webhook_events.go
@@ -0,0 +1,54 @@
+// 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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+type webhookEventsBody struct {
+ Body []string `json:"events" doc:"The events a webhook target can subscribe to."`
+}
+
+// RegisterWebhookEventRoutes wires the available-webhook-events listing onto the
+// Huma API. Like v1, the whole endpoint only exists when webhooks are enabled.
+func RegisterWebhookEventRoutes(api huma.API) {
+ if !config.WebhooksEnabled.GetBool() {
+ return
+ }
+
+ Register(api, huma.Operation{
+ OperationID: "webhooks-events-list",
+ Summary: "List available webhook events",
+ Description: "Returns every event a webhook target can subscribe to. Use these values when creating or updating a webhook.",
+ Method: http.MethodGet,
+ Path: "/webhooks/events",
+ Tags: []string{"webhooks"},
+ }, webhookEventsList)
+}
+
+func init() { AddRouteRegistrar(RegisterWebhookEventRoutes) }
+
+func webhookEventsList(_ context.Context, _ *struct{}) (*webhookEventsBody, error) {
+ return &webhookEventsBody{Body: models.GetAvailableWebhookEvents()}, nil
+}
diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go
index 0c9708849..4f146e5a7 100644
--- a/pkg/routes/api_tokens.go
+++ b/pkg/routes/api_tokens.go
@@ -21,6 +21,7 @@ import (
"strings"
"code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@@ -89,5 +90,17 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context, sk
c.Set("api_token", token)
c.Set("api_user", u)
+ // Guarded by config: this fires on every token-authenticated request and
+ // only the audit listener consumes it.
+ if config.AuditEnabled.GetBool() {
+ err = events.DispatchWithContext(c.Request().Context(), &models.APITokenUsedEvent{
+ TokenID: token.ID,
+ OwnerID: token.OwnerID,
+ })
+ if err != nil {
+ log.Errorf("Could not dispatch api token used event: %s", err)
+ }
+ }
+
return nil
}
diff --git a/pkg/routes/caldav/auth.go b/pkg/routes/caldav/auth.go
index 930b8f013..fc89d7555 100644
--- a/pkg/routes/caldav/auth.go
+++ b/pkg/routes/caldav/auth.go
@@ -88,7 +88,7 @@ func BasicAuth(c *echo.Context, username, password string) (bool, error) {
return false, nil
}
if u == nil {
- u, err = user.CheckUserCredentials(s, credentials)
+ u, err = user.CheckUserCredentials(c.Request().Context(), s, credentials)
if err != nil {
log.Errorf("Error during basic auth for caldav: %v", err)
return false, nil
diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go
index 5544d3ec7..60a151e2d 100644
--- a/pkg/routes/caldav/listStorageProvider.go
+++ b/pkg/routes/caldav/listStorageProvider.go
@@ -17,6 +17,7 @@
package caldav
import (
+ "context"
"slices"
"strconv"
"strings"
@@ -396,7 +397,7 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (
return nil, err
}
- events.DispatchPending(s)
+ events.DispatchPending(context.Background(), s)
// Build up the proper response
rr := VikunjaProjectResourceAdapter{
@@ -473,7 +474,7 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (
return nil, err
}
- events.DispatchPending(s)
+ events.DispatchPending(context.Background(), s)
// Build up the proper response
rr := VikunjaProjectResourceAdapter{
@@ -516,7 +517,7 @@ func (vcls *VikunjaCaldavProjectStorage) DeleteResource(_ string) error {
return err
}
- events.DispatchPending(s)
+ events.DispatchPending(context.Background(), s)
}
return nil
diff --git a/pkg/routes/feeds/auth.go b/pkg/routes/feeds/auth.go
index 419fd2ccd..a0142317b 100644
--- a/pkg/routes/feeds/auth.go
+++ b/pkg/routes/feeds/auth.go
@@ -23,9 +23,9 @@ import (
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
- "xorm.io/xorm"
"github.com/labstack/echo/v5"
+ "xorm.io/xorm"
)
func checkAPIToken(s *xorm.Session, username, token string) (*user.User, error) {
@@ -50,35 +50,48 @@ func checkAPIToken(s *xorm.Session, username, token string) (*user.User, error)
return u, nil
}
-// BasicAuth authenticates feed requests. Only API tokens are accepted —
-// password and LDAP credentials are rejected outright because feed URLs are
-// commonly exported, shared, or cached by feed readers.
-func BasicAuth(c *echo.Context, username, password string) (bool, error) {
+// AuthenticateFeedToken validates feed credentials against an existing session.
+// Only API tokens are accepted — password and LDAP credentials are rejected
+// outright because feed URLs are commonly exported, shared, or cached by feed
+// readers. It returns the authenticated user, or nil for any rejection so
+// callers can treat "invalid" and "unknown" identically.
+func AuthenticateFeedToken(s *xorm.Session, username, password string) (*user.User, error) {
if !strings.HasPrefix(password, models.APITokenPrefix) {
- return false, nil
+ return nil, nil
}
// GetTokenFromTokenString slices password[len-8:] without a length check,
// so a stray "tk_" or other short prefix-only string would panic before
// the credentials could be rejected. Real tokens are far longer than
// prefix+8, so anything shorter is invalid by construction.
if len(password) < len(models.APITokenPrefix)+8 {
- return false, nil
+ return nil, nil
}
- s := db.NewSession()
- defer s.Close()
-
u, err := checkAPIToken(s, username, password)
if err != nil {
log.Errorf("Error during API token auth for feeds: %v", err)
- return false, nil
+ return nil, nil
}
if u == nil {
- return false, nil
+ return nil, nil
}
if u.IsBot() {
log.Warningf("Feed auth rejected for bot user %d", u.ID)
- return false, nil
+ return nil, nil
+ }
+
+ return u, nil
+}
+
+// BasicAuth authenticates feed requests for echo's BasicAuth middleware. The
+// validation logic is shared with the v2 handler via AuthenticateFeedToken.
+func BasicAuth(c *echo.Context, username, password string) (bool, error) {
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := AuthenticateFeedToken(s, username, password)
+ if err != nil || u == nil {
+ return false, err
}
c.Set("userBasicAuth", u)
diff --git a/pkg/routes/feeds/handler.go b/pkg/routes/feeds/handler.go
index 9d0794c76..2a5ce289d 100644
--- a/pkg/routes/feeds/handler.go
+++ b/pkg/routes/feeds/handler.go
@@ -30,24 +30,22 @@ import (
"github.com/gorilla/feeds"
"github.com/labstack/echo/v5"
+ "xorm.io/xorm"
)
const feedItemLimit = 50
-// NotificationsAtomFeed serves the authenticated user's notifications as an
-// Atom feed. Notifications are not marked as read by being fetched here.
-func NotificationsAtomFeed(c *echo.Context) error {
- u, ok := c.Get("userBasicAuth").(*user.User)
- if !ok {
- return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
- }
-
- s := db.NewSession()
- defer s.Close()
+// AtomContentType is the content type of the notifications Atom feed. Shared so
+// the v1 echo handler and the v2 Huma op set the same header.
+const AtomContentType = "application/atom+xml; charset=utf-8"
+// BuildNotificationsAtomFeed renders the user's latest notifications as Atom XML
+// against an existing session. Notifications are not marked as read by being
+// fetched here. Shared by the v1 echo handler and the v2 Huma op.
+func BuildNotificationsAtomFeed(s *xorm.Session, u *user.User) (string, error) {
rows, _, _, err := notifications.GetNotificationsForUser(s, u.ID, feedItemLimit, 0)
if err != nil {
- return err
+ return "", err
}
publicURL := config.ServicePublicURL.GetString()
@@ -85,11 +83,25 @@ func NotificationsAtomFeed(c *echo.Context) error {
})
}
- atom, err := feed.ToAtom()
+ return feed.ToAtom()
+}
+
+// NotificationsAtomFeed serves the authenticated user's notifications as an
+// Atom feed. Notifications are not marked as read by being fetched here.
+func NotificationsAtomFeed(c *echo.Context) error {
+ u, ok := c.Get("userBasicAuth").(*user.User)
+ if !ok {
+ return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ atom, err := BuildNotificationsAtomFeed(s, u)
if err != nil {
return err
}
- c.Response().Header().Set(echo.HeaderContentType, "application/atom+xml; charset=utf-8")
+ c.Response().Header().Set(echo.HeaderContentType, AtomContentType)
return c.String(http.StatusOK, atom)
}
diff --git a/pkg/routes/middleware/request_meta.go b/pkg/routes/middleware/request_meta.go
new file mode 100644
index 000000000..865cd5cde
--- /dev/null
+++ b/pkg/routes/middleware/request_meta.go
@@ -0,0 +1,42 @@
+// 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 middleware
+
+import (
+ "code.vikunja.io/api/pkg/events"
+
+ "github.com/labstack/echo/v5"
+)
+
+// RequestMeta stashes IP, User-Agent and the request ID on the request
+// context so events dispatched while handling the request carry them as
+// message metadata (consumed by the audit listeners). Must run after the
+// RequestID middleware, which guarantees the response header is populated.
+func RequestMeta() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c *echo.Context) error {
+ req := c.Request()
+ ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{
+ IP: c.RealIP(),
+ UserAgent: req.UserAgent(),
+ RequestID: c.Response().Header().Get(echo.HeaderXRequestID),
+ })
+ c.SetRequest(req.WithContext(ctx))
+ return next(c)
+ }
+ }
+}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 159994724..bcdea1bdb 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -155,6 +155,11 @@ func NewEcho() *echo.Echo {
e.Logger = log.NewEchoLogger(config.LogEnabled.GetBool(), config.LogHTTP.GetString(), config.LogFormat.GetString())
+ // First middleware in the chain so every request has an ID — reuses the
+ // X-Request-Id header from a proxy or generates one — and everything
+ // downstream (logging, audit) sees the same value.
+ e.Use(middleware.RequestID())
+
// Logger
if config.LogEnabled.GetBool() && config.LogHTTP.GetString() != "off" {
httpLogger := log.NewHTTPLogger(config.LogEnabled.GetBool(), config.LogHTTP.GetString(), config.LogFormat.GetString())
@@ -199,6 +204,10 @@ func NewEcho() *echo.Echo {
// handler binds them. Runs globally so both /api/v1 and /api/v2 benefit.
e.Use(vmiddleware.NormalizeArrayParams())
+ if config.AuditEnabled.GetBool() {
+ e.Use(vmiddleware.RequestMeta())
+ }
+
setupSentry(e)
// Validation
@@ -343,6 +352,33 @@ var unauthenticatedAPIPaths = map[string]bool{
"/api/v2/docs": true,
"/api/v2/docs/scalar.standalone.js": true,
"/api/v2/schemas/:schema": true,
+ "/api/v2/info": true,
+
+ "/api/v2/register": true,
+ "/api/v2/user/password/token": true,
+ "/api/v2/user/password/reset": true,
+ "/api/v2/user/confirm": true,
+ "/api/v2/shares/:share/auth": true,
+ "/api/v2/oauth/token": true,
+ "/api/v2/login": true,
+ "/api/v2/user/token/refresh": true,
+ "/api/v2/auth/openid/:provider/callback": true,
+
+ // Testing endpoints authenticate with the testing token via a custom
+ // Authorization header, not a JWT; mounted only when that token is set.
+ "/api/v2/test/all": true,
+ "/api/v2/test/:table": true,
+
+ // Public infra healthcheck (a Huma op that opts out of the global auth).
+ "/api/v2/health": true,
+
+ // Atom feed (a Huma op) authenticates itself with HTTP Basic auth (a
+ // feeds-scoped API token), like its /feeds counterpart, not a JWT.
+ "/api/v2/notifications.atom": true,
+
+ // WebSocket upgrade (a raw echo route — OpenAPI can't model WebSockets);
+ // it authenticates via its first message, so the upgrade needs no JWT.
+ "/api/v2/ws": true,
}
// collectRoutesForAPITokens collects all routes for API token permission checking.
@@ -415,6 +451,13 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
a.GET("/docs", apiv2.ScalarUI)
a.GET("/docs/scalar.standalone.js", apiv2.ScalarJS)
+ // WebSockets can't be modeled in OpenAPI and Huma has no WS support, so the
+ // upgrade endpoint stays a raw echo route (outside the Huma spec). It
+ // authenticates via its first message, so unauthenticatedAPIPaths exempts it
+ // from the group's JWT middleware. Health and the Atom feed are Huma ops and
+ // self-register via init()/RegisterAll.
+ a.GET("/ws", ws.UpgradeHandler)
+
// Resources self-register via init(); RegisterAll runs them all + AutoPatch.
apiv2.RegisterAll(api)
}
diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go
index eac2622f5..93de88c59 100644
--- a/pkg/swagger/docs.go
+++ b/pkg/swagger/docs.go
@@ -42,7 +42,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.Overview"
+ "$ref": "#/definitions/models.Overview"
}
},
"404": {
@@ -207,7 +207,7 @@ const docTemplate = `{
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
}
},
@@ -243,7 +243,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/admin.CreateUserBody"
+ "$ref": "#/definitions/models.CreateUserBody"
}
}
],
@@ -251,7 +251,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
},
"400": {
@@ -352,7 +352,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
},
"400": {
@@ -410,7 +410,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
},
"400": {
@@ -836,7 +836,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/v1.vikunjaInfos"
+ "$ref": "#/definitions/shared.VikunjaInfos"
}
}
}
@@ -3438,7 +3438,7 @@ const docTemplate = `{
"JWTKeyAuth": []
}
],
- "description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project.",
+ "description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project.",
"consumes": [
"application/json"
],
@@ -7342,7 +7342,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/v1.UserExportStatus"
+ "$ref": "#/definitions/models.UserExportStatus"
}
}
}
@@ -7456,7 +7456,7 @@ const docTemplate = `{
},
"/user/logout": {
"post": {
- "description": "Destroys the current session and clears the refresh token cookie.",
+ "description": "Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an ` + "`" + `oidc_logout_url` + "`" + ` the client should redirect to so the provider session is ended too.",
"produces": [
"application/json"
],
@@ -7468,7 +7468,7 @@ const docTemplate = `{
"200": {
"description": "Successfully logged out.",
"schema": {
- "$ref": "#/definitions/models.Message"
+ "$ref": "#/definitions/v1.LogoutResponse"
}
}
}
@@ -7848,7 +7848,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/v1.UserSettings"
+ "$ref": "#/definitions/models.UserGeneralSettings"
}
}
],
@@ -8884,44 +8884,6 @@ const docTemplate = `{
}
},
"definitions": {
- "admin.CreateUserBody": {
- "type": "object",
- "properties": {
- "email": {
- "description": "The user's email address",
- "type": "string",
- "maxLength": 250
- },
- "is_admin": {
- "description": "Mark the new user as an instance admin.",
- "type": "boolean"
- },
- "language": {
- "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.",
- "type": "string"
- },
- "name": {
- "description": "The full name of the new user. Optional.",
- "type": "string"
- },
- "password": {
- "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.",
- "type": "string",
- "maxLength": 72,
- "minLength": 8
- },
- "skip_email_confirm": {
- "description": "Activate the new user immediately without email confirmation.",
- "type": "boolean"
- },
- "username": {
- "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.",
- "type": "string",
- "maxLength": 250,
- "minLength": 3
- }
- }
- },
"admin.IsAdminPatch": {
"type": "object",
"properties": {
@@ -8931,29 +8893,6 @@ const docTemplate = `{
}
}
},
- "admin.Overview": {
- "type": "object",
- "properties": {
- "license": {
- "$ref": "#/definitions/license.Info"
- },
- "projects": {
- "type": "integer"
- },
- "shares": {
- "$ref": "#/definitions/admin.ShareCounts"
- },
- "tasks": {
- "type": "integer"
- },
- "teams": {
- "type": "integer"
- },
- "users": {
- "type": "integer"
- }
- }
- },
"admin.OwnerPatch": {
"type": "object",
"properties": {
@@ -8962,20 +8901,6 @@ const docTemplate = `{
}
}
},
- "admin.ShareCounts": {
- "type": "object",
- "properties": {
- "link_shares": {
- "type": "integer"
- },
- "team_shares": {
- "type": "integer"
- },
- "user_shares": {
- "type": "integer"
- }
- }
- },
"admin.StatusPatch": {
"type": "object",
"properties": {
@@ -8989,57 +8914,6 @@ const docTemplate = `{
}
}
},
- "admin.User": {
- "type": "object",
- "properties": {
- "auth_provider": {
- "type": "string"
- },
- "bot_owner_id": {
- "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.",
- "type": "integer"
- },
- "created": {
- "description": "A timestamp when this task was created. You cannot change this value.",
- "type": "string"
- },
- "email": {
- "description": "The user's email address.",
- "type": "string",
- "maxLength": 250
- },
- "id": {
- "description": "The unique, numeric id of this user.",
- "type": "integer"
- },
- "is_admin": {
- "type": "boolean"
- },
- "issuer": {
- "type": "string"
- },
- "name": {
- "description": "The full name of the user.",
- "type": "string"
- },
- "status": {
- "$ref": "#/definitions/user.Status"
- },
- "subject": {
- "type": "string"
- },
- "updated": {
- "description": "A timestamp when this task was last updated. You cannot change this value.",
- "type": "string"
- },
- "username": {
- "description": "The username of the user. Is always unique.",
- "type": "string",
- "maxLength": 250,
- "minLength": 1
- }
- }
- },
"auth.Token": {
"type": "object",
"properties": {
@@ -9470,6 +9344,44 @@ const docTemplate = `{
}
}
},
+ "models.CreateUserBody": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "description": "The user's email address",
+ "type": "string",
+ "maxLength": 250
+ },
+ "is_admin": {
+ "description": "Mark the new user as an instance admin.",
+ "type": "boolean"
+ },
+ "language": {
+ "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.",
+ "type": "string"
+ },
+ "name": {
+ "description": "The full name of the new user. Optional.",
+ "type": "string"
+ },
+ "password": {
+ "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.",
+ "type": "string",
+ "maxLength": 72,
+ "minLength": 8
+ },
+ "skip_email_confirm": {
+ "description": "Activate the new user immediately without email confirmation.",
+ "type": "boolean"
+ },
+ "username": {
+ "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.",
+ "type": "string",
+ "maxLength": 250,
+ "minLength": 3
+ }
+ }
+ },
"models.DatabaseNotifications": {
"type": "object",
"properties": {
@@ -9629,6 +9541,29 @@ const docTemplate = `{
}
}
},
+ "models.Overview": {
+ "type": "object",
+ "properties": {
+ "license": {
+ "$ref": "#/definitions/license.Info"
+ },
+ "projects": {
+ "type": "integer"
+ },
+ "shares": {
+ "$ref": "#/definitions/models.ShareCounts"
+ },
+ "tasks": {
+ "type": "integer"
+ },
+ "teams": {
+ "type": "integer"
+ },
+ "users": {
+ "type": "integer"
+ }
+ }
+ },
"models.Permission": {
"type": "integer",
"enum": [
@@ -9730,6 +9665,10 @@ const docTemplate = `{
"models.ProjectDuplicate": {
"type": "object",
"properties": {
+ "duplicate_shares": {
+ "description": "Whether to copy the project's shares to the duplicate",
+ "type": "boolean"
+ },
"duplicated_project": {
"description": "The copied project",
"allOf": [
@@ -9876,7 +9815,8 @@ const docTemplate = `{
},
"value": {
"description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.",
- "type": "string"
+ "type": "string",
+ "maxLength": 20
}
}
},
@@ -10000,6 +9940,20 @@ const docTemplate = `{
}
}
},
+ "models.ShareCounts": {
+ "type": "object",
+ "properties": {
+ "link_shares": {
+ "type": "integer"
+ },
+ "team_shares": {
+ "type": "integer"
+ },
+ "user_shares": {
+ "type": "integer"
+ }
+ }
+ },
"models.SharingType": {
"type": "integer",
"enum": [
@@ -10202,6 +10156,10 @@ const docTemplate = `{
}
]
},
+ "time_entries_count": {
+ "description": "Time entry count of this task. Only present when fetching tasks with the ` + "`" + `expand` + "`" + ` parameter set to ` + "`" + `time_entries_count` + "`" + `.",
+ "type": "integer"
+ },
"title": {
"description": "The task text. This is what you'll see in the project.",
"type": "string",
@@ -10369,7 +10327,7 @@ const docTemplate = `{
"type": "integer"
},
"relation_kind": {
- "description": "The kind of the relation.",
+ "description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.",
"allOf": [
{
"$ref": "#/definitions/models.RelationKind"
@@ -10625,6 +10583,66 @@ const docTemplate = `{
}
}
},
+ "models.UserExportStatus": {
+ "type": "object",
+ "properties": {
+ "created": {
+ "type": "string"
+ },
+ "expires": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "size": {
+ "type": "integer"
+ }
+ }
+ },
+ "models.UserGeneralSettings": {
+ "type": "object",
+ "properties": {
+ "default_project_id": {
+ "type": "integer"
+ },
+ "discoverable_by_email": {
+ "type": "boolean"
+ },
+ "discoverable_by_name": {
+ "type": "boolean"
+ },
+ "email_reminders_enabled": {
+ "type": "boolean"
+ },
+ "extra_settings_links": {
+ "description": "Server/OpenID-provided; populated on read, ignored on write.",
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "frontend_settings": {},
+ "language": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "overdue_tasks_reminders_enabled": {
+ "type": "boolean"
+ },
+ "overdue_tasks_reminders_time": {
+ "type": "string"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "week_start": {
+ "type": "integer",
+ "maximum": 6,
+ "minimum": 0
+ }
+ }
+ },
"models.UserWithPermission": {
"type": "object",
"properties": {
@@ -10761,6 +10779,196 @@ const docTemplate = `{
}
}
},
+ "shared.AdminUser": {
+ "type": "object",
+ "properties": {
+ "auth_provider": {
+ "type": "string"
+ },
+ "bot_owner_id": {
+ "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.",
+ "type": "integer"
+ },
+ "created": {
+ "description": "A timestamp when this task was created. You cannot change this value.",
+ "type": "string"
+ },
+ "email": {
+ "description": "The user's email address.",
+ "type": "string",
+ "maxLength": 250
+ },
+ "id": {
+ "description": "The unique, numeric id of this user.",
+ "type": "integer"
+ },
+ "is_admin": {
+ "type": "boolean"
+ },
+ "issuer": {
+ "type": "string"
+ },
+ "name": {
+ "description": "The full name of the user.",
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/definitions/user.Status"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "updated": {
+ "description": "A timestamp when this task was last updated. You cannot change this value.",
+ "type": "string"
+ },
+ "username": {
+ "description": "The username of the user. Is always unique.",
+ "type": "string",
+ "maxLength": 250,
+ "minLength": 1
+ }
+ }
+ },
+ "shared.AuthInfo": {
+ "type": "object",
+ "properties": {
+ "ldap": {
+ "$ref": "#/definitions/shared.LdapAuthInfo"
+ },
+ "local": {
+ "$ref": "#/definitions/shared.LocalAuthInfo"
+ },
+ "openid_connect": {
+ "$ref": "#/definitions/shared.OpenIDAuthInfo"
+ }
+ }
+ },
+ "shared.LdapAuthInfo": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "shared.LegalInfo": {
+ "type": "object",
+ "properties": {
+ "imprint_url": {
+ "type": "string"
+ },
+ "privacy_policy_url": {
+ "type": "string"
+ }
+ }
+ },
+ "shared.LocalAuthInfo": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "registration_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "shared.OpenIDAuthInfo": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider"
+ }
+ }
+ }
+ },
+ "shared.VikunjaInfos": {
+ "type": "object",
+ "properties": {
+ "allow_icon_changes": {
+ "type": "boolean"
+ },
+ "auth": {
+ "$ref": "#/definitions/shared.AuthInfo"
+ },
+ "available_migrators": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "caldav_enabled": {
+ "type": "boolean"
+ },
+ "concurrent_writes": {
+ "description": "ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.",
+ "type": "boolean"
+ },
+ "demo_mode_enabled": {
+ "type": "boolean"
+ },
+ "email_reminders_enabled": {
+ "type": "boolean"
+ },
+ "enabled_background_providers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "enabled_pro_features": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/license.Feature"
+ }
+ },
+ "frontend_url": {
+ "type": "string"
+ },
+ "legal": {
+ "$ref": "#/definitions/shared.LegalInfo"
+ },
+ "link_sharing_enabled": {
+ "type": "boolean"
+ },
+ "max_file_size": {
+ "type": "string"
+ },
+ "max_items_per_page": {
+ "type": "integer"
+ },
+ "motd": {
+ "type": "string"
+ },
+ "public_teams_enabled": {
+ "type": "boolean"
+ },
+ "task_attachments_enabled": {
+ "type": "boolean"
+ },
+ "task_comments_enabled": {
+ "type": "boolean"
+ },
+ "totp_enabled": {
+ "type": "boolean"
+ },
+ "user_deletion_enabled": {
+ "type": "boolean"
+ },
+ "version": {
+ "type": "string"
+ },
+ "webhooks_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
"todoist.Migration": {
"type": "object",
"properties": {
@@ -10941,6 +11149,18 @@ const docTemplate = `{
}
}
},
+ "v1.LogoutResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "oidc_logout_url": {
+ "description": "RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.",
+ "type": "string"
+ }
+ }
+ },
"v1.UserAvatarProvider": {
"type": "object",
"properties": {
@@ -10958,23 +11178,6 @@ const docTemplate = `{
}
}
},
- "v1.UserExportStatus": {
- "type": "object",
- "properties": {
- "created": {
- "type": "string"
- },
- "expires": {
- "type": "string"
- },
- "id": {
- "type": "integer"
- },
- "size": {
- "type": "integer"
- }
- }
- },
"v1.UserPassword": {
"type": "object",
"properties": {
@@ -11022,59 +11225,6 @@ const docTemplate = `{
}
}
},
- "v1.UserSettings": {
- "type": "object",
- "properties": {
- "default_project_id": {
- "description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.",
- "type": "integer"
- },
- "discoverable_by_email": {
- "description": "If true, the user can be found when searching for their exact email.",
- "type": "boolean"
- },
- "discoverable_by_name": {
- "description": "If true, this user can be found by their name or parts of it when searching for it.",
- "type": "boolean"
- },
- "email_reminders_enabled": {
- "description": "If enabled, sends email reminders of tasks to the user.",
- "type": "boolean"
- },
- "extra_settings_links": {
- "description": "Additional settings links as provided by openid",
- "type": "object",
- "additionalProperties": {}
- },
- "frontend_settings": {
- "description": "Additional settings only used by the frontend"
- },
- "language": {
- "description": "The user's language",
- "type": "string"
- },
- "name": {
- "description": "The new name of the current user.",
- "type": "string"
- },
- "overdue_tasks_reminders_enabled": {
- "description": "If enabled, the user will get an email for their overdue tasks each morning.",
- "type": "boolean"
- },
- "overdue_tasks_reminders_time": {
- "description": "The time when the daily summary of overdue tasks will be sent via email.",
- "type": "string"
- },
- "timezone": {
- "description": "The user's time zone. Used to send task reminders in the time zone of the user.",
- "type": "string"
- },
- "week_start": {
- "description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.",
- "type": "integer"
- }
- }
- },
"v1.UserWithSettings": {
"type": "object",
"properties": {
@@ -11112,7 +11262,7 @@ const docTemplate = `{
"type": "string"
},
"settings": {
- "$ref": "#/definitions/v1.UserSettings"
+ "$ref": "#/definitions/models.UserGeneralSettings"
},
"updated": {
"description": "A timestamp when this task was last updated. You cannot change this value.",
@@ -11126,141 +11276,6 @@ const docTemplate = `{
}
}
},
- "v1.authInfo": {
- "type": "object",
- "properties": {
- "ldap": {
- "$ref": "#/definitions/v1.ldapAuthInfo"
- },
- "local": {
- "$ref": "#/definitions/v1.localAuthInfo"
- },
- "openid_connect": {
- "$ref": "#/definitions/v1.openIDAuthInfo"
- }
- }
- },
- "v1.ldapAuthInfo": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- }
- }
- },
- "v1.legalInfo": {
- "type": "object",
- "properties": {
- "imprint_url": {
- "type": "string"
- },
- "privacy_policy_url": {
- "type": "string"
- }
- }
- },
- "v1.localAuthInfo": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- },
- "registration_enabled": {
- "type": "boolean"
- }
- }
- },
- "v1.openIDAuthInfo": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- },
- "providers": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider"
- }
- }
- }
- },
- "v1.vikunjaInfos": {
- "type": "object",
- "properties": {
- "allow_icon_changes": {
- "type": "boolean"
- },
- "auth": {
- "$ref": "#/definitions/v1.authInfo"
- },
- "available_migrators": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "caldav_enabled": {
- "type": "boolean"
- },
- "demo_mode_enabled": {
- "type": "boolean"
- },
- "email_reminders_enabled": {
- "type": "boolean"
- },
- "enabled_background_providers": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "enabled_pro_features": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/license.Feature"
- }
- },
- "frontend_url": {
- "type": "string"
- },
- "legal": {
- "$ref": "#/definitions/v1.legalInfo"
- },
- "link_sharing_enabled": {
- "type": "boolean"
- },
- "max_file_size": {
- "type": "string"
- },
- "max_items_per_page": {
- "type": "integer"
- },
- "motd": {
- "type": "string"
- },
- "public_teams_enabled": {
- "type": "boolean"
- },
- "task_attachments_enabled": {
- "type": "boolean"
- },
- "task_comments_enabled": {
- "type": "boolean"
- },
- "totp_enabled": {
- "type": "boolean"
- },
- "user_deletion_enabled": {
- "type": "boolean"
- },
- "version": {
- "type": "string"
- },
- "webhooks_enabled": {
- "type": "boolean"
- }
- }
- },
"web.HTTPError": {
"type": "object",
"properties": {
diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json
index 645ebb2cb..f02bf9bde 100644
--- a/pkg/swagger/swagger.json
+++ b/pkg/swagger/swagger.json
@@ -34,7 +34,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.Overview"
+ "$ref": "#/definitions/models.Overview"
}
},
"404": {
@@ -199,7 +199,7 @@
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
}
},
@@ -235,7 +235,7 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/admin.CreateUserBody"
+ "$ref": "#/definitions/models.CreateUserBody"
}
}
],
@@ -243,7 +243,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
},
"400": {
@@ -344,7 +344,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
},
"400": {
@@ -402,7 +402,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/admin.User"
+ "$ref": "#/definitions/shared.AdminUser"
}
},
"400": {
@@ -828,7 +828,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/v1.vikunjaInfos"
+ "$ref": "#/definitions/shared.VikunjaInfos"
}
}
}
@@ -3430,7 +3430,7 @@
"JWTKeyAuth": []
}
],
- "description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project.",
+ "description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project.",
"consumes": [
"application/json"
],
@@ -7334,7 +7334,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/v1.UserExportStatus"
+ "$ref": "#/definitions/models.UserExportStatus"
}
}
}
@@ -7448,7 +7448,7 @@
},
"/user/logout": {
"post": {
- "description": "Destroys the current session and clears the refresh token cookie.",
+ "description": "Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too.",
"produces": [
"application/json"
],
@@ -7460,7 +7460,7 @@
"200": {
"description": "Successfully logged out.",
"schema": {
- "$ref": "#/definitions/models.Message"
+ "$ref": "#/definitions/v1.LogoutResponse"
}
}
}
@@ -7840,7 +7840,7 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/v1.UserSettings"
+ "$ref": "#/definitions/models.UserGeneralSettings"
}
}
],
@@ -8876,44 +8876,6 @@
}
},
"definitions": {
- "admin.CreateUserBody": {
- "type": "object",
- "properties": {
- "email": {
- "description": "The user's email address",
- "type": "string",
- "maxLength": 250
- },
- "is_admin": {
- "description": "Mark the new user as an instance admin.",
- "type": "boolean"
- },
- "language": {
- "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.",
- "type": "string"
- },
- "name": {
- "description": "The full name of the new user. Optional.",
- "type": "string"
- },
- "password": {
- "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.",
- "type": "string",
- "maxLength": 72,
- "minLength": 8
- },
- "skip_email_confirm": {
- "description": "Activate the new user immediately without email confirmation.",
- "type": "boolean"
- },
- "username": {
- "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.",
- "type": "string",
- "maxLength": 250,
- "minLength": 3
- }
- }
- },
"admin.IsAdminPatch": {
"type": "object",
"properties": {
@@ -8923,29 +8885,6 @@
}
}
},
- "admin.Overview": {
- "type": "object",
- "properties": {
- "license": {
- "$ref": "#/definitions/license.Info"
- },
- "projects": {
- "type": "integer"
- },
- "shares": {
- "$ref": "#/definitions/admin.ShareCounts"
- },
- "tasks": {
- "type": "integer"
- },
- "teams": {
- "type": "integer"
- },
- "users": {
- "type": "integer"
- }
- }
- },
"admin.OwnerPatch": {
"type": "object",
"properties": {
@@ -8954,20 +8893,6 @@
}
}
},
- "admin.ShareCounts": {
- "type": "object",
- "properties": {
- "link_shares": {
- "type": "integer"
- },
- "team_shares": {
- "type": "integer"
- },
- "user_shares": {
- "type": "integer"
- }
- }
- },
"admin.StatusPatch": {
"type": "object",
"properties": {
@@ -8981,57 +8906,6 @@
}
}
},
- "admin.User": {
- "type": "object",
- "properties": {
- "auth_provider": {
- "type": "string"
- },
- "bot_owner_id": {
- "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.",
- "type": "integer"
- },
- "created": {
- "description": "A timestamp when this task was created. You cannot change this value.",
- "type": "string"
- },
- "email": {
- "description": "The user's email address.",
- "type": "string",
- "maxLength": 250
- },
- "id": {
- "description": "The unique, numeric id of this user.",
- "type": "integer"
- },
- "is_admin": {
- "type": "boolean"
- },
- "issuer": {
- "type": "string"
- },
- "name": {
- "description": "The full name of the user.",
- "type": "string"
- },
- "status": {
- "$ref": "#/definitions/user.Status"
- },
- "subject": {
- "type": "string"
- },
- "updated": {
- "description": "A timestamp when this task was last updated. You cannot change this value.",
- "type": "string"
- },
- "username": {
- "description": "The username of the user. Is always unique.",
- "type": "string",
- "maxLength": 250,
- "minLength": 1
- }
- }
- },
"auth.Token": {
"type": "object",
"properties": {
@@ -9462,6 +9336,44 @@
}
}
},
+ "models.CreateUserBody": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "description": "The user's email address",
+ "type": "string",
+ "maxLength": 250
+ },
+ "is_admin": {
+ "description": "Mark the new user as an instance admin.",
+ "type": "boolean"
+ },
+ "language": {
+ "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.",
+ "type": "string"
+ },
+ "name": {
+ "description": "The full name of the new user. Optional.",
+ "type": "string"
+ },
+ "password": {
+ "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.",
+ "type": "string",
+ "maxLength": 72,
+ "minLength": 8
+ },
+ "skip_email_confirm": {
+ "description": "Activate the new user immediately without email confirmation.",
+ "type": "boolean"
+ },
+ "username": {
+ "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.",
+ "type": "string",
+ "maxLength": 250,
+ "minLength": 3
+ }
+ }
+ },
"models.DatabaseNotifications": {
"type": "object",
"properties": {
@@ -9621,6 +9533,29 @@
}
}
},
+ "models.Overview": {
+ "type": "object",
+ "properties": {
+ "license": {
+ "$ref": "#/definitions/license.Info"
+ },
+ "projects": {
+ "type": "integer"
+ },
+ "shares": {
+ "$ref": "#/definitions/models.ShareCounts"
+ },
+ "tasks": {
+ "type": "integer"
+ },
+ "teams": {
+ "type": "integer"
+ },
+ "users": {
+ "type": "integer"
+ }
+ }
+ },
"models.Permission": {
"type": "integer",
"enum": [
@@ -9722,6 +9657,10 @@
"models.ProjectDuplicate": {
"type": "object",
"properties": {
+ "duplicate_shares": {
+ "description": "Whether to copy the project's shares to the duplicate",
+ "type": "boolean"
+ },
"duplicated_project": {
"description": "The copied project",
"allOf": [
@@ -9868,7 +9807,8 @@
},
"value": {
"description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.",
- "type": "string"
+ "type": "string",
+ "maxLength": 20
}
}
},
@@ -9992,6 +9932,20 @@
}
}
},
+ "models.ShareCounts": {
+ "type": "object",
+ "properties": {
+ "link_shares": {
+ "type": "integer"
+ },
+ "team_shares": {
+ "type": "integer"
+ },
+ "user_shares": {
+ "type": "integer"
+ }
+ }
+ },
"models.SharingType": {
"type": "integer",
"enum": [
@@ -10194,6 +10148,10 @@
}
]
},
+ "time_entries_count": {
+ "description": "Time entry count of this task. Only present when fetching tasks with the `expand` parameter set to `time_entries_count`.",
+ "type": "integer"
+ },
"title": {
"description": "The task text. This is what you'll see in the project.",
"type": "string",
@@ -10361,7 +10319,7 @@
"type": "integer"
},
"relation_kind": {
- "description": "The kind of the relation.",
+ "description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.",
"allOf": [
{
"$ref": "#/definitions/models.RelationKind"
@@ -10617,6 +10575,66 @@
}
}
},
+ "models.UserExportStatus": {
+ "type": "object",
+ "properties": {
+ "created": {
+ "type": "string"
+ },
+ "expires": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "size": {
+ "type": "integer"
+ }
+ }
+ },
+ "models.UserGeneralSettings": {
+ "type": "object",
+ "properties": {
+ "default_project_id": {
+ "type": "integer"
+ },
+ "discoverable_by_email": {
+ "type": "boolean"
+ },
+ "discoverable_by_name": {
+ "type": "boolean"
+ },
+ "email_reminders_enabled": {
+ "type": "boolean"
+ },
+ "extra_settings_links": {
+ "description": "Server/OpenID-provided; populated on read, ignored on write.",
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "frontend_settings": {},
+ "language": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "overdue_tasks_reminders_enabled": {
+ "type": "boolean"
+ },
+ "overdue_tasks_reminders_time": {
+ "type": "string"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "week_start": {
+ "type": "integer",
+ "maximum": 6,
+ "minimum": 0
+ }
+ }
+ },
"models.UserWithPermission": {
"type": "object",
"properties": {
@@ -10753,6 +10771,196 @@
}
}
},
+ "shared.AdminUser": {
+ "type": "object",
+ "properties": {
+ "auth_provider": {
+ "type": "string"
+ },
+ "bot_owner_id": {
+ "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.",
+ "type": "integer"
+ },
+ "created": {
+ "description": "A timestamp when this task was created. You cannot change this value.",
+ "type": "string"
+ },
+ "email": {
+ "description": "The user's email address.",
+ "type": "string",
+ "maxLength": 250
+ },
+ "id": {
+ "description": "The unique, numeric id of this user.",
+ "type": "integer"
+ },
+ "is_admin": {
+ "type": "boolean"
+ },
+ "issuer": {
+ "type": "string"
+ },
+ "name": {
+ "description": "The full name of the user.",
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/definitions/user.Status"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "updated": {
+ "description": "A timestamp when this task was last updated. You cannot change this value.",
+ "type": "string"
+ },
+ "username": {
+ "description": "The username of the user. Is always unique.",
+ "type": "string",
+ "maxLength": 250,
+ "minLength": 1
+ }
+ }
+ },
+ "shared.AuthInfo": {
+ "type": "object",
+ "properties": {
+ "ldap": {
+ "$ref": "#/definitions/shared.LdapAuthInfo"
+ },
+ "local": {
+ "$ref": "#/definitions/shared.LocalAuthInfo"
+ },
+ "openid_connect": {
+ "$ref": "#/definitions/shared.OpenIDAuthInfo"
+ }
+ }
+ },
+ "shared.LdapAuthInfo": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "shared.LegalInfo": {
+ "type": "object",
+ "properties": {
+ "imprint_url": {
+ "type": "string"
+ },
+ "privacy_policy_url": {
+ "type": "string"
+ }
+ }
+ },
+ "shared.LocalAuthInfo": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "registration_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
+ "shared.OpenIDAuthInfo": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider"
+ }
+ }
+ }
+ },
+ "shared.VikunjaInfos": {
+ "type": "object",
+ "properties": {
+ "allow_icon_changes": {
+ "type": "boolean"
+ },
+ "auth": {
+ "$ref": "#/definitions/shared.AuthInfo"
+ },
+ "available_migrators": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "caldav_enabled": {
+ "type": "boolean"
+ },
+ "concurrent_writes": {
+ "description": "ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.",
+ "type": "boolean"
+ },
+ "demo_mode_enabled": {
+ "type": "boolean"
+ },
+ "email_reminders_enabled": {
+ "type": "boolean"
+ },
+ "enabled_background_providers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "enabled_pro_features": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/license.Feature"
+ }
+ },
+ "frontend_url": {
+ "type": "string"
+ },
+ "legal": {
+ "$ref": "#/definitions/shared.LegalInfo"
+ },
+ "link_sharing_enabled": {
+ "type": "boolean"
+ },
+ "max_file_size": {
+ "type": "string"
+ },
+ "max_items_per_page": {
+ "type": "integer"
+ },
+ "motd": {
+ "type": "string"
+ },
+ "public_teams_enabled": {
+ "type": "boolean"
+ },
+ "task_attachments_enabled": {
+ "type": "boolean"
+ },
+ "task_comments_enabled": {
+ "type": "boolean"
+ },
+ "totp_enabled": {
+ "type": "boolean"
+ },
+ "user_deletion_enabled": {
+ "type": "boolean"
+ },
+ "version": {
+ "type": "string"
+ },
+ "webhooks_enabled": {
+ "type": "boolean"
+ }
+ }
+ },
"todoist.Migration": {
"type": "object",
"properties": {
@@ -10933,6 +11141,18 @@
}
}
},
+ "v1.LogoutResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "oidc_logout_url": {
+ "description": "RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.",
+ "type": "string"
+ }
+ }
+ },
"v1.UserAvatarProvider": {
"type": "object",
"properties": {
@@ -10950,23 +11170,6 @@
}
}
},
- "v1.UserExportStatus": {
- "type": "object",
- "properties": {
- "created": {
- "type": "string"
- },
- "expires": {
- "type": "string"
- },
- "id": {
- "type": "integer"
- },
- "size": {
- "type": "integer"
- }
- }
- },
"v1.UserPassword": {
"type": "object",
"properties": {
@@ -11014,59 +11217,6 @@
}
}
},
- "v1.UserSettings": {
- "type": "object",
- "properties": {
- "default_project_id": {
- "description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.",
- "type": "integer"
- },
- "discoverable_by_email": {
- "description": "If true, the user can be found when searching for their exact email.",
- "type": "boolean"
- },
- "discoverable_by_name": {
- "description": "If true, this user can be found by their name or parts of it when searching for it.",
- "type": "boolean"
- },
- "email_reminders_enabled": {
- "description": "If enabled, sends email reminders of tasks to the user.",
- "type": "boolean"
- },
- "extra_settings_links": {
- "description": "Additional settings links as provided by openid",
- "type": "object",
- "additionalProperties": {}
- },
- "frontend_settings": {
- "description": "Additional settings only used by the frontend"
- },
- "language": {
- "description": "The user's language",
- "type": "string"
- },
- "name": {
- "description": "The new name of the current user.",
- "type": "string"
- },
- "overdue_tasks_reminders_enabled": {
- "description": "If enabled, the user will get an email for their overdue tasks each morning.",
- "type": "boolean"
- },
- "overdue_tasks_reminders_time": {
- "description": "The time when the daily summary of overdue tasks will be sent via email.",
- "type": "string"
- },
- "timezone": {
- "description": "The user's time zone. Used to send task reminders in the time zone of the user.",
- "type": "string"
- },
- "week_start": {
- "description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.",
- "type": "integer"
- }
- }
- },
"v1.UserWithSettings": {
"type": "object",
"properties": {
@@ -11104,7 +11254,7 @@
"type": "string"
},
"settings": {
- "$ref": "#/definitions/v1.UserSettings"
+ "$ref": "#/definitions/models.UserGeneralSettings"
},
"updated": {
"description": "A timestamp when this task was last updated. You cannot change this value.",
@@ -11118,141 +11268,6 @@
}
}
},
- "v1.authInfo": {
- "type": "object",
- "properties": {
- "ldap": {
- "$ref": "#/definitions/v1.ldapAuthInfo"
- },
- "local": {
- "$ref": "#/definitions/v1.localAuthInfo"
- },
- "openid_connect": {
- "$ref": "#/definitions/v1.openIDAuthInfo"
- }
- }
- },
- "v1.ldapAuthInfo": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- }
- }
- },
- "v1.legalInfo": {
- "type": "object",
- "properties": {
- "imprint_url": {
- "type": "string"
- },
- "privacy_policy_url": {
- "type": "string"
- }
- }
- },
- "v1.localAuthInfo": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- },
- "registration_enabled": {
- "type": "boolean"
- }
- }
- },
- "v1.openIDAuthInfo": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- },
- "providers": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider"
- }
- }
- }
- },
- "v1.vikunjaInfos": {
- "type": "object",
- "properties": {
- "allow_icon_changes": {
- "type": "boolean"
- },
- "auth": {
- "$ref": "#/definitions/v1.authInfo"
- },
- "available_migrators": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "caldav_enabled": {
- "type": "boolean"
- },
- "demo_mode_enabled": {
- "type": "boolean"
- },
- "email_reminders_enabled": {
- "type": "boolean"
- },
- "enabled_background_providers": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "enabled_pro_features": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/license.Feature"
- }
- },
- "frontend_url": {
- "type": "string"
- },
- "legal": {
- "$ref": "#/definitions/v1.legalInfo"
- },
- "link_sharing_enabled": {
- "type": "boolean"
- },
- "max_file_size": {
- "type": "string"
- },
- "max_items_per_page": {
- "type": "integer"
- },
- "motd": {
- "type": "string"
- },
- "public_teams_enabled": {
- "type": "boolean"
- },
- "task_attachments_enabled": {
- "type": "boolean"
- },
- "task_comments_enabled": {
- "type": "boolean"
- },
- "totp_enabled": {
- "type": "boolean"
- },
- "user_deletion_enabled": {
- "type": "boolean"
- },
- "version": {
- "type": "string"
- },
- "webhooks_enabled": {
- "type": "boolean"
- }
- }
- },
"web.HTTPError": {
"type": "object",
"properties": {
diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml
index 1e1bbc818..775b2a024 100644
--- a/pkg/swagger/swagger.yaml
+++ b/pkg/swagger/swagger.yaml
@@ -1,39 +1,5 @@
basePath: /api/v1
definitions:
- admin.CreateUserBody:
- properties:
- email:
- description: The user's email address
- maxLength: 250
- type: string
- is_admin:
- description: Mark the new user as an instance admin.
- type: boolean
- language:
- description: The language of the new user. Must be a valid IETF BCP 47 language
- code and exist in Vikunja.
- type: string
- name:
- description: The full name of the new user. Optional.
- type: string
- password:
- description: The user's password in clear text. Only used when registering
- the user. The maximum limi is 72 bytes, which may be less than 72 characters.
- This is due to the limit in the bcrypt hashing algorithm used to store passwords
- in Vikunja.
- maxLength: 72
- minLength: 8
- type: string
- skip_email_confirm:
- description: Activate the new user immediately without email confirmation.
- type: boolean
- username:
- description: The user's username. Cannot contain anything that looks like
- an url or whitespaces.
- maxLength: 250
- minLength: 3
- type: string
- type: object
admin.IsAdminPatch:
properties:
is_admin:
@@ -41,35 +7,11 @@ definitions:
silently demote otherwise.
type: boolean
type: object
- admin.Overview:
- properties:
- license:
- $ref: '#/definitions/license.Info'
- projects:
- type: integer
- shares:
- $ref: '#/definitions/admin.ShareCounts'
- tasks:
- type: integer
- teams:
- type: integer
- users:
- type: integer
- type: object
admin.OwnerPatch:
properties:
owner_id:
type: integer
type: object
- admin.ShareCounts:
- properties:
- link_shares:
- type: integer
- team_shares:
- type: integer
- user_shares:
- type: integer
- type: object
admin.StatusPatch:
properties:
status:
@@ -78,47 +20,6 @@ definitions:
description: Pointer to distinguish "omitted" from StatusActive; an empty
body would silently re-enable otherwise.
type: object
- admin.User:
- properties:
- auth_provider:
- type: string
- bot_owner_id:
- description: |-
- BotOwnerID is the ID of the owning (human) user if this user is a bot.
- A non-zero value means this user is a bot and cannot authenticate via password.
- type: integer
- created:
- description: A timestamp when this task was created. You cannot change this
- value.
- type: string
- email:
- description: The user's email address.
- maxLength: 250
- type: string
- id:
- description: The unique, numeric id of this user.
- type: integer
- is_admin:
- type: boolean
- issuer:
- type: string
- name:
- description: The full name of the user.
- type: string
- status:
- $ref: '#/definitions/user.Status'
- subject:
- type: string
- updated:
- description: A timestamp when this task was last updated. You cannot change
- this value.
- type: string
- username:
- description: The username of the user. Is always unique.
- maxLength: 250
- minLength: 1
- type: string
- type: object
auth.Token:
properties:
token:
@@ -423,6 +324,40 @@ definitions:
values:
$ref: '#/definitions/models.Task'
type: object
+ models.CreateUserBody:
+ properties:
+ email:
+ description: The user's email address
+ maxLength: 250
+ type: string
+ is_admin:
+ description: Mark the new user as an instance admin.
+ type: boolean
+ language:
+ description: The language of the new user. Must be a valid IETF BCP 47 language
+ code and exist in Vikunja.
+ type: string
+ name:
+ description: The full name of the new user. Optional.
+ type: string
+ password:
+ description: The user's password in clear text. Only used when registering
+ the user. The maximum limi is 72 bytes, which may be less than 72 characters.
+ This is due to the limit in the bcrypt hashing algorithm used to store passwords
+ in Vikunja.
+ maxLength: 72
+ minLength: 8
+ type: string
+ skip_email_confirm:
+ description: Activate the new user immediately without email confirmation.
+ type: boolean
+ username:
+ description: The user's username. Cannot contain anything that looks like
+ an url or whitespaces.
+ maxLength: 250
+ minLength: 3
+ type: string
+ type: object
models.DatabaseNotifications:
properties:
created:
@@ -545,6 +480,21 @@ definitions:
description: A standard message.
type: string
type: object
+ models.Overview:
+ properties:
+ license:
+ $ref: '#/definitions/license.Info'
+ projects:
+ type: integer
+ shares:
+ $ref: '#/definitions/models.ShareCounts'
+ tasks:
+ type: integer
+ teams:
+ type: integer
+ users:
+ type: integer
+ type: object
models.Permission:
enum:
- 0
@@ -627,6 +577,9 @@ definitions:
type: object
models.ProjectDuplicate:
properties:
+ duplicate_shares:
+ description: Whether to copy the project's shares to the duplicate
+ type: boolean
duplicated_project:
allOf:
- $ref: '#/definitions/models.Project'
@@ -741,6 +694,7 @@ definitions:
value:
description: The actual reaction. This can be any valid utf character or text,
up to a length of 20.
+ maxLength: 20
type: string
type: object
models.ReactionMap:
@@ -834,6 +788,15 @@ definitions:
this value.
type: string
type: object
+ models.ShareCounts:
+ properties:
+ link_shares:
+ type: integer
+ team_shares:
+ type: integer
+ user_shares:
+ type: integer
+ type: object
models.SharingType:
enum:
- 0
@@ -1000,6 +963,10 @@ definitions:
description: |-
The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.
Will only returned when retrieving one task.
+ time_entries_count:
+ description: Time entry count of this task. Only present when fetching tasks
+ with the `expand` parameter set to `time_entries_count`.
+ type: integer
title:
description: The task text. This is what you'll see in the project.
minLength: 1
@@ -1130,7 +1097,9 @@ definitions:
relation_kind:
allOf:
- $ref: '#/definitions/models.RelationKind'
- description: The kind of the relation.
+ description: |-
+ The kind of the relation.
+ The enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.
task_id:
description: The ID of the "base" task, the task which has a relation to another.
type: integer
@@ -1326,6 +1295,47 @@ definitions:
this value.
type: string
type: object
+ models.UserExportStatus:
+ properties:
+ created:
+ type: string
+ expires:
+ type: string
+ id:
+ type: integer
+ size:
+ type: integer
+ type: object
+ models.UserGeneralSettings:
+ properties:
+ default_project_id:
+ type: integer
+ discoverable_by_email:
+ type: boolean
+ discoverable_by_name:
+ type: boolean
+ email_reminders_enabled:
+ type: boolean
+ extra_settings_links:
+ additionalProperties: {}
+ description: Server/OpenID-provided; populated on read, ignored on write.
+ type: object
+ frontend_settings: {}
+ language:
+ type: string
+ name:
+ type: string
+ overdue_tasks_reminders_enabled:
+ type: boolean
+ overdue_tasks_reminders_time:
+ type: string
+ timezone:
+ type: string
+ week_start:
+ maximum: 6
+ minimum: 0
+ type: integer
+ type: object
models.UserWithPermission:
properties:
bot_owner_id:
@@ -1437,6 +1447,141 @@ definitions:
receiving a 412 with error code 1017. See GHSA-8jvc-mcx6-r4cg.
type: string
type: object
+ shared.AdminUser:
+ properties:
+ auth_provider:
+ type: string
+ bot_owner_id:
+ description: |-
+ BotOwnerID is the ID of the owning (human) user if this user is a bot.
+ A non-zero value means this user is a bot and cannot authenticate via password.
+ type: integer
+ created:
+ description: A timestamp when this task was created. You cannot change this
+ value.
+ type: string
+ email:
+ description: The user's email address.
+ maxLength: 250
+ type: string
+ id:
+ description: The unique, numeric id of this user.
+ type: integer
+ is_admin:
+ type: boolean
+ issuer:
+ type: string
+ name:
+ description: The full name of the user.
+ type: string
+ status:
+ $ref: '#/definitions/user.Status'
+ subject:
+ type: string
+ updated:
+ description: A timestamp when this task was last updated. You cannot change
+ this value.
+ type: string
+ username:
+ description: The username of the user. Is always unique.
+ maxLength: 250
+ minLength: 1
+ type: string
+ type: object
+ shared.AuthInfo:
+ properties:
+ ldap:
+ $ref: '#/definitions/shared.LdapAuthInfo'
+ local:
+ $ref: '#/definitions/shared.LocalAuthInfo'
+ openid_connect:
+ $ref: '#/definitions/shared.OpenIDAuthInfo'
+ type: object
+ shared.LdapAuthInfo:
+ properties:
+ enabled:
+ type: boolean
+ type: object
+ shared.LegalInfo:
+ properties:
+ imprint_url:
+ type: string
+ privacy_policy_url:
+ type: string
+ type: object
+ shared.LocalAuthInfo:
+ properties:
+ enabled:
+ type: boolean
+ registration_enabled:
+ type: boolean
+ type: object
+ shared.OpenIDAuthInfo:
+ properties:
+ enabled:
+ type: boolean
+ providers:
+ items:
+ $ref: '#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider'
+ type: array
+ type: object
+ shared.VikunjaInfos:
+ properties:
+ allow_icon_changes:
+ type: boolean
+ auth:
+ $ref: '#/definitions/shared.AuthInfo'
+ available_migrators:
+ items:
+ type: string
+ type: array
+ caldav_enabled:
+ type: boolean
+ concurrent_writes:
+ description: ConcurrentWrites reports whether the configured database can
+ handle concurrent writes. It is false on SQLite, where overlapping write
+ transactions deadlock, so clients should serialize batched writes instead
+ of firing them in parallel.
+ type: boolean
+ demo_mode_enabled:
+ type: boolean
+ email_reminders_enabled:
+ type: boolean
+ enabled_background_providers:
+ items:
+ type: string
+ type: array
+ enabled_pro_features:
+ items:
+ $ref: '#/definitions/license.Feature'
+ type: array
+ frontend_url:
+ type: string
+ legal:
+ $ref: '#/definitions/shared.LegalInfo'
+ link_sharing_enabled:
+ type: boolean
+ max_file_size:
+ type: string
+ max_items_per_page:
+ type: integer
+ motd:
+ type: string
+ public_teams_enabled:
+ type: boolean
+ task_attachments_enabled:
+ type: boolean
+ task_comments_enabled:
+ type: boolean
+ totp_enabled:
+ type: boolean
+ user_deletion_enabled:
+ type: boolean
+ version:
+ type: string
+ webhooks_enabled:
+ type: boolean
+ type: object
todoist.Migration:
properties:
code:
@@ -1569,6 +1714,15 @@ definitions:
password:
type: string
type: object
+ v1.LogoutResponse:
+ properties:
+ message:
+ type: string
+ oidc_logout_url:
+ description: RP-Initiated Logout URL the frontend redirects to. Empty for
+ non-OIDC sessions.
+ type: string
+ type: object
v1.UserAvatarProvider:
properties:
avatar_provider:
@@ -1583,17 +1737,6 @@ definitions:
token:
type: string
type: object
- v1.UserExportStatus:
- properties:
- created:
- type: string
- expires:
- type: string
- id:
- type: integer
- size:
- type: integer
- type: object
v1.UserPassword:
properties:
new_password:
@@ -1633,53 +1776,6 @@ definitions:
minLength: 3
type: string
type: object
- v1.UserSettings:
- properties:
- default_project_id:
- description: |-
- If a task is created without a specified project this value should be used. Applies
- to tasks made directly in API and from clients.
- type: integer
- discoverable_by_email:
- description: If true, the user can be found when searching for their exact
- email.
- type: boolean
- discoverable_by_name:
- description: If true, this user can be found by their name or parts of it
- when searching for it.
- type: boolean
- email_reminders_enabled:
- description: If enabled, sends email reminders of tasks to the user.
- type: boolean
- extra_settings_links:
- additionalProperties: {}
- description: Additional settings links as provided by openid
- type: object
- frontend_settings:
- description: Additional settings only used by the frontend
- language:
- description: The user's language
- type: string
- name:
- description: The new name of the current user.
- type: string
- overdue_tasks_reminders_enabled:
- description: If enabled, the user will get an email for their overdue tasks
- each morning.
- type: boolean
- overdue_tasks_reminders_time:
- description: The time when the daily summary of overdue tasks will be sent
- via email.
- type: string
- timezone:
- description: The user's time zone. Used to send task reminders in the time
- zone of the user.
- type: string
- week_start:
- description: The day when the week starts for this user. 0 = sunday, 1 = monday,
- etc.
- type: integer
- type: object
v1.UserWithSettings:
properties:
auth_provider:
@@ -1710,7 +1806,7 @@ definitions:
description: The full name of the user.
type: string
settings:
- $ref: '#/definitions/v1.UserSettings'
+ $ref: '#/definitions/models.UserGeneralSettings'
updated:
description: A timestamp when this task was last updated. You cannot change
this value.
@@ -1721,94 +1817,6 @@ definitions:
minLength: 1
type: string
type: object
- v1.authInfo:
- properties:
- ldap:
- $ref: '#/definitions/v1.ldapAuthInfo'
- local:
- $ref: '#/definitions/v1.localAuthInfo'
- openid_connect:
- $ref: '#/definitions/v1.openIDAuthInfo'
- type: object
- v1.ldapAuthInfo:
- properties:
- enabled:
- type: boolean
- type: object
- v1.legalInfo:
- properties:
- imprint_url:
- type: string
- privacy_policy_url:
- type: string
- type: object
- v1.localAuthInfo:
- properties:
- enabled:
- type: boolean
- registration_enabled:
- type: boolean
- type: object
- v1.openIDAuthInfo:
- properties:
- enabled:
- type: boolean
- providers:
- items:
- $ref: '#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider'
- type: array
- type: object
- v1.vikunjaInfos:
- properties:
- allow_icon_changes:
- type: boolean
- auth:
- $ref: '#/definitions/v1.authInfo'
- available_migrators:
- items:
- type: string
- type: array
- caldav_enabled:
- type: boolean
- demo_mode_enabled:
- type: boolean
- email_reminders_enabled:
- type: boolean
- enabled_background_providers:
- items:
- type: string
- type: array
- enabled_pro_features:
- items:
- $ref: '#/definitions/license.Feature'
- type: array
- frontend_url:
- type: string
- legal:
- $ref: '#/definitions/v1.legalInfo'
- link_sharing_enabled:
- type: boolean
- max_file_size:
- type: string
- max_items_per_page:
- type: integer
- motd:
- type: string
- public_teams_enabled:
- type: boolean
- task_attachments_enabled:
- type: boolean
- task_comments_enabled:
- type: boolean
- totp_enabled:
- type: boolean
- user_deletion_enabled:
- type: boolean
- version:
- type: string
- webhooks_enabled:
- type: boolean
- type: object
web.HTTPError:
properties:
code:
@@ -2011,7 +2019,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/admin.Overview'
+ $ref: '#/definitions/models.Overview'
"404":
description: Not Found
schema:
@@ -2119,7 +2127,7 @@ paths:
description: OK
schema:
items:
- $ref: '#/definitions/admin.User'
+ $ref: '#/definitions/shared.AdminUser'
type: array
"404":
description: Not Found
@@ -2141,14 +2149,14 @@ paths:
name: body
required: true
schema:
- $ref: '#/definitions/admin.CreateUserBody'
+ $ref: '#/definitions/models.CreateUserBody'
produces:
- application/json
responses:
"200":
description: OK
schema:
- $ref: '#/definitions/admin.User'
+ $ref: '#/definitions/shared.AdminUser'
"400":
description: Bad Request
schema:
@@ -2216,7 +2224,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/admin.User'
+ $ref: '#/definitions/shared.AdminUser'
"400":
description: Bad Request
schema:
@@ -2253,7 +2261,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/admin.User'
+ $ref: '#/definitions/shared.AdminUser'
"400":
description: Bad Request
schema:
@@ -2531,7 +2539,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/v1.vikunjaInfos'
+ $ref: '#/definitions/shared.VikunjaInfos'
summary: Info
tags:
- service
@@ -4713,9 +4721,10 @@ paths:
consumes:
- application/json
description: Copies the project, tasks, files, kanban data, assignees, comments,
- attachments, labels, relations, backgrounds, user/team permissions and link
- shares from one project to a new one. The user needs read access in the project
- and write access in the parent of the new project.
+ attachments, labels, relations and backgrounds from one project to a new one.
+ User/team permissions and link shares are only copied when duplicate_shares
+ is set to true. The user needs read access in the project and write access
+ in the parent of the new project.
parameters:
- description: The project ID to duplicate
in: path
@@ -6870,7 +6879,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/v1.UserExportStatus'
+ $ref: '#/definitions/models.UserExportStatus'
security:
- JWTKeyAuth: []
summary: Get current user data export
@@ -6945,13 +6954,15 @@ paths:
/user/logout:
post:
description: Destroys the current session and clears the refresh token cookie.
+ For OpenID Connect sessions the response includes an `oidc_logout_url` the
+ client should redirect to so the provider session is ended too.
produces:
- application/json
responses:
"200":
description: Successfully logged out.
schema:
- $ref: '#/definitions/models.Message'
+ $ref: '#/definitions/v1.LogoutResponse'
summary: Logout
tags:
- auth
@@ -7192,7 +7203,7 @@ paths:
name: avatar
required: true
schema:
- $ref: '#/definitions/v1.UserSettings'
+ $ref: '#/definitions/models.UserGeneralSettings'
produces:
- application/json
responses:
diff --git a/pkg/user/events.go b/pkg/user/events.go
index ff7866149..12b17a957 100644
--- a/pkg/user/events.go
+++ b/pkg/user/events.go
@@ -25,3 +25,34 @@ type CreatedEvent struct {
func (t *CreatedEvent) Name() string {
return "user.created"
}
+
+// LoginSucceededEvent is fired after a user successfully authenticated,
+// regardless of the auth provider (local, LDAP, OpenID).
+type LoginSucceededEvent struct {
+ User *User `json:"user"`
+}
+
+// Name defines the name for LoginSucceededEvent
+func (t *LoginSucceededEvent) Name() string {
+ return "user.login.succeeded"
+}
+
+// LoginFailedEvent is fired for every failed password check of a known user.
+type LoginFailedEvent struct {
+ User *User `json:"user"`
+}
+
+// Name defines the name for LoginFailedEvent
+func (t *LoginFailedEvent) Name() string {
+ return "user.login.failed"
+}
+
+// LogoutEvent is fired when a user destroys their session.
+type LogoutEvent struct {
+ UserID int64 `json:"user_id"`
+}
+
+// Name defines the name for LogoutEvent
+func (t *LogoutEvent) Name() string {
+ return "user.logout"
+}
diff --git a/pkg/user/token.go b/pkg/user/token.go
index 565270289..ee4e844da 100644
--- a/pkg/user/token.go
+++ b/pkg/user/token.go
@@ -41,12 +41,12 @@ const (
// Token is a token a user can use to do things like verify their email or resetting their password
type Token struct {
- ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
+ ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this token."`
UserID int64 `xorm:"not null" json:"-"`
Token string `xorm:"varchar(450) not null index" json:"-"`
- ClearTextToken string `xorm:"-" json:"token"`
+ ClearTextToken string `xorm:"-" json:"token" readOnly:"true" doc:"The token in clear text. Only returned once when the token is created; never on subsequent reads."`
Kind TokenKind `xorm:"not null" json:"-"`
- Created time.Time `xorm:"created not null" json:"created"`
+ Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this token was created. You cannot change this value."`
}
// TableName returns the real table name for user tokens
diff --git a/pkg/user/totp.go b/pkg/user/totp.go
index 66abb813c..98c4327cb 100644
--- a/pkg/user/totp.go
+++ b/pkg/user/totp.go
@@ -17,8 +17,10 @@
package user
import (
+ "bytes"
"fmt"
"image"
+ "image/jpeg"
"strconv"
"time"
@@ -37,11 +39,11 @@ import (
type TOTP struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"`
UserID int64 `xorm:"bigint not null" json:"-"`
- Secret string `xorm:"text not null" json:"secret"`
+ Secret string `xorm:"text not null" json:"secret" readOnly:"true" doc:"The shared secret used to generate passcodes, generated by the server on enrollment."`
// The totp entry will only be enabled after the user verified they have a working totp setup.
- Enabled bool `xorm:"null" json:"enabled"`
+ Enabled bool `xorm:"null" json:"enabled" readOnly:"true" doc:"Whether totp is fully activated. Set to true only after the user confirms a passcode."`
// The totp url used to be able to enroll the user later
- URL string `xorm:"text null" json:"url"`
+ URL string `xorm:"text null" json:"url" readOnly:"true" doc:"The otpauth:// url, generated by the server, used to enroll the user in an authenticator app."`
}
// TableName holds the table name for totp secrets
@@ -198,6 +200,21 @@ func GetTOTPQrCodeForUser(s *xorm.Session, user *User) (qrcode image.Image, err
return key.Image(300, 300)
}
+// GetTOTPQrCodeAsJpegForUser renders the user's totp qr code to jpeg bytes, the
+// wire format both API versions serve.
+func GetTOTPQrCodeAsJpegForUser(s *xorm.Session, user *User) ([]byte, error) {
+ qrcode, err := GetTOTPQrCodeForUser(s, user)
+ if err != nil {
+ return nil, err
+ }
+
+ buff := &bytes.Buffer{}
+ if err := jpeg.Encode(buff, qrcode, nil); err != nil {
+ return nil, err
+ }
+ return buff.Bytes(), nil
+}
+
// HandleFailedTOTPAuth records a failed TOTP attempt and locks the account
// after 10 consecutive failures.
//
diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go
index 73e104682..26606c152 100644
--- a/pkg/user/update_email.go
+++ b/pkg/user/update_email.go
@@ -17,6 +17,8 @@
package user
import (
+ "context"
+
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
"xorm.io/xorm"
@@ -31,6 +33,17 @@ type EmailUpdate struct {
Password string `json:"password"`
}
+// ChangeUserEmail verifies the user's password, then sets a new email address
+// (kicking off confirmation when the mailer is enabled). Shared by the v1 and
+// v2 email-update handlers; only HTTP input binding stays in the handlers.
+func ChangeUserEmail(ctx context.Context, s *xorm.Session, u *User, password, newEmail string) error {
+ verified, err := CheckUserCredentials(ctx, s, &Login{Username: u.Username, Password: password})
+ if err != nil {
+ return err
+ }
+ return UpdateEmail(s, &EmailUpdate{User: verified, NewEmail: newEmail})
+}
+
// UpdateEmail lets a user update their email address
func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) {
diff --git a/pkg/user/user.go b/pkg/user/user.go
index 1aec85853..09fef2565 100644
--- a/pkg/user/user.go
+++ b/pkg/user/user.go
@@ -17,6 +17,7 @@
package user
import (
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -27,6 +28,7 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/notifications"
@@ -362,8 +364,9 @@ func getUserByUsernameOrEmail(s *xorm.Session, usernameOrEmail string) (u *User,
return
}
-// CheckUserCredentials checks user credentials
-func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) {
+// CheckUserCredentials checks user credentials. The context carries request
+// metadata for the audit trail of failed attempts.
+func CheckUserCredentials(ctx context.Context, s *xorm.Session, u *Login) (*User, error) {
// Check if we have any credentials
if u.Password == "" || u.Username == "" {
return nil, ErrNoUsernamePassword{}
@@ -390,7 +393,7 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) {
err = CheckUserPassword(user, u.Password)
if err != nil {
if IsErrWrongUsernameOrPassword(err) {
- handleFailedPassword(user)
+ handleFailedPassword(ctx, user)
}
return user, err
}
@@ -410,7 +413,11 @@ func (u *User) IsLocalUser() bool {
return u.Issuer == IssuerLocal
}
-func handleFailedPassword(user *User) {
+func handleFailedPassword(ctx context.Context, user *User) {
+ if err := events.DispatchWithContext(ctx, &LoginFailedEvent{User: user}); err != nil {
+ log.Errorf("Could not dispatch login failed event: %s", err)
+ }
+
key := user.GetFailedPasswordAttemptsKey()
err := keyvalue.IncrBy(key, 1)
if err != nil {
diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go
index 776a60b5d..38287c6e0 100644
--- a/pkg/user/user_test.go
+++ b/pkg/user/user_test.go
@@ -17,6 +17,7 @@
package user
import (
+ "context"
"testing"
"code.vikunja.io/api/pkg/db"
@@ -357,7 +358,7 @@ func TestCheckUserCredentials(t *testing.T) {
s := db.NewSession()
defer s.Close()
- _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345678"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345678"})
require.NoError(t, err)
})
t.Run("unverified email", func(t *testing.T) {
@@ -365,7 +366,7 @@ func TestCheckUserCredentials(t *testing.T) {
s := db.NewSession()
defer s.Close()
- _, err := CheckUserCredentials(s, &Login{Username: "user5", Password: "12345678"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user5", Password: "12345678"})
require.Error(t, err)
assert.True(t, IsErrEmailNotConfirmed(err))
})
@@ -374,7 +375,7 @@ func TestCheckUserCredentials(t *testing.T) {
s := db.NewSession()
defer s.Close()
- _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345"})
require.Error(t, err)
assert.True(t, IsErrWrongUsernameOrPassword(err))
})
@@ -383,7 +384,7 @@ func TestCheckUserCredentials(t *testing.T) {
s := db.NewSession()
defer s.Close()
- _, err := CheckUserCredentials(s, &Login{Username: "dfstestuu", Password: "12345678"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "dfstestuu", Password: "12345678"})
require.Error(t, err)
assert.True(t, IsErrWrongUsernameOrPassword(err))
})
@@ -392,7 +393,7 @@ func TestCheckUserCredentials(t *testing.T) {
s := db.NewSession()
defer s.Close()
- _, err := CheckUserCredentials(s, &Login{Username: "user1"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1"})
require.Error(t, err)
assert.True(t, IsErrNoUsernamePassword(err))
})
@@ -401,7 +402,7 @@ func TestCheckUserCredentials(t *testing.T) {
s := db.NewSession()
defer s.Close()
- _, err := CheckUserCredentials(s, &Login{Password: "12345678"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Password: "12345678"})
require.Error(t, err)
assert.True(t, IsErrNoUsernamePassword(err))
})
@@ -410,7 +411,7 @@ func TestCheckUserCredentials(t *testing.T) {
s := db.NewSession()
defer s.Close()
- _, err := CheckUserCredentials(s, &Login{Username: "user1@example.com", Password: "12345678"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1@example.com", Password: "12345678"})
require.NoError(t, err)
})
t.Run("disabled user", func(t *testing.T) {
@@ -419,7 +420,7 @@ func TestCheckUserCredentials(t *testing.T) {
defer s.Close()
// user17 is disabled (status=2), password is "12345678"
- _, err := CheckUserCredentials(s, &Login{Username: "user17", Password: "12345678"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user17", Password: "12345678"})
require.Error(t, err)
assert.True(t, IsErrAccountDisabled(err))
})
@@ -429,7 +430,7 @@ func TestCheckUserCredentials(t *testing.T) {
defer s.Close()
// user18 is locked (status=3), password is "12345678"
- _, err := CheckUserCredentials(s, &Login{Username: "user18", Password: "12345678"})
+ _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user18", Password: "12345678"})
require.Error(t, err)
assert.True(t, IsErrAccountLocked(err))
})
diff --git a/pkg/user/users_project.go b/pkg/user/users_project.go
index ab94a4bdf..c08cc23c2 100644
--- a/pkg/user/users_project.go
+++ b/pkg/user/users_project.go
@@ -34,6 +34,20 @@ type ProjectUserOpts struct {
MatchFuzzily bool
}
+// SearchUsers performs the global user search shared by both API versions:
+// it lists users matching the search string and obfuscates their email
+// addresses before returning.
+func SearchUsers(s *xorm.Session, search string, currentUser *User) (users []*User, err error) {
+ users, err = ListUsers(s, search, currentUser, nil)
+ if err != nil {
+ return nil, err
+ }
+ for i := range users {
+ users[i].Email = ""
+ }
+ return users, nil
+}
+
// ListUsers returns a list with all users, filtered by an optional search string
func ListUsers(s *xorm.Session, search string, currentUser *User, opts *ProjectUserOpts) (users []*User, err error) {
if opts == nil {
diff --git a/pkg/web/files/file.go b/pkg/web/files/file.go
new file mode 100644
index 000000000..461fe2780
--- /dev/null
+++ b/pkg/web/files/file.go
@@ -0,0 +1,61 @@
+// 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 files
+
+import (
+ "io"
+ "mime"
+ "net/http"
+ "strconv"
+
+ "code.vikunja.io/api/pkg/files"
+)
+
+// WriteFileDownload streams a loaded file (its .File reader must be open) to the
+// response as an attachment download: http.ServeContent for seekable local files
+// (Range + If-Modified-Since for free), a manual 304 + io.Copy otherwise. It does
+// not close the reader; the caller owns it.
+func WriteFileDownload(w http.ResponseWriter, r *http.Request, f *files.File) {
+ // Downloads must never be cached. no-cache overrides the global no-store
+ // directive so revalidation (If-Modified-Since) still works.
+ w.Header().Set("Cache-Control", "no-cache")
+
+ mimeToReturn := f.Mime
+ if mimeToReturn == "" {
+ mimeToReturn = "application/octet-stream"
+ }
+ w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": f.Name}))
+ w.Header().Set("Content-Type", mimeToReturn)
+ w.Header().Set("Content-Length", strconv.FormatUint(f.Size, 10))
+ w.Header().Set("Last-Modified", f.Created.UTC().Format(http.TimeFormat))
+
+ // Local files are *os.File (seekable), so ServeContent gives Range +
+ // If-Modified-Since for free; s3 (and the in-memory test storage) return a
+ // non-seekable reader, so check If-Modified-Since manually and io.Copy.
+ if seeker, ok := f.File.(io.ReadSeeker); ok {
+ http.ServeContent(w, r, f.Name, f.Created, seeker)
+ return
+ }
+
+ if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" {
+ if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !f.Created.UTC().After(t) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ }
+ _, _ = io.Copy(w, f.File)
+}
diff --git a/pkg/web/files/project_background.go b/pkg/web/files/project_background.go
new file mode 100644
index 000000000..aeda725a7
--- /dev/null
+++ b/pkg/web/files/project_background.go
@@ -0,0 +1,55 @@
+// 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 files
+
+import (
+ "io"
+ "net/http"
+ "os"
+
+ "code.vikunja.io/api/pkg/files"
+)
+
+// WriteProjectBackground streams a project's background file (its .File reader must be
+// open) to the response, shared by the v1 and v2 background handlers. It does not close
+// the reader; the caller owns it.
+//
+// The wire shape differs from WriteFileDownload on purpose and must stay byte-identical
+// to v1: backgrounds are always served as image/jpg (no Content-Disposition, no
+// Content-Length), with a cache-revalidation Last-Modified from the storage modtime
+// rather than the file's DB Created timestamp.
+func WriteProjectBackground(w http.ResponseWriter, r *http.Request, bgFile *files.File, stat os.FileInfo) {
+ // Override the global no-store directive so browsers can cache background images.
+ // no-cache allows caching but requires revalidation via If-Modified-Since.
+ w.Header().Set("Cache-Control", "no-cache")
+
+ if stat != nil {
+ modTime := stat.ModTime().UTC()
+ w.Header().Set("Last-Modified", modTime.Format(http.TimeFormat))
+
+ if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" {
+ if t, err := http.ParseTime(ifModSince); err == nil && !modTime.After(t) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ }
+ }
+
+ w.Header().Set("Content-Type", "image/jpg")
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.Copy(w, bgFile.File)
+}
diff --git a/pkg/web/files/task_attachment.go b/pkg/web/files/task_attachment.go
new file mode 100644
index 000000000..55945fe23
--- /dev/null
+++ b/pkg/web/files/task_attachment.go
@@ -0,0 +1,79 @@
+// 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 files holds the HTTP-layer glue for serving task attachments —
+// the upload-result DTOs and the download response writer — shared by the
+// v1 and v2 handlers. The domain logic stays in pkg/models; this package
+// only translates it to and from the wire.
+package files
+
+import (
+ "net/http"
+ "strconv"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web"
+)
+
+// AttachmentUploadError is a per-file upload failure.
+type AttachmentUploadError struct {
+ Code int `json:"code,omitempty" doc:"Vikunja numeric error code, when the failure carries one."`
+ Message string `json:"message" doc:"A human-readable description of why this file failed."`
+}
+
+// AttachmentUploadResult is the outcome of an attachment upload: files are
+// processed independently, so a per-file failure lands in Errors while the
+// rest still succeed.
+type AttachmentUploadResult struct {
+ Errors []AttachmentUploadError `json:"errors" doc:"Per-file failures. A file that fails here does not fail the whole request; the others still upload."`
+ Success []*models.TaskAttachment `json:"success" doc:"The attachments that were created successfully."`
+}
+
+// BuildUploadResult turns the domain function's plain return values into the
+// wire DTO, mapping each failure to its numeric code when it carries one.
+func BuildUploadResult(success []*models.TaskAttachment, failures []error) *AttachmentUploadResult {
+ r := &AttachmentUploadResult{Success: success}
+ for _, err := range failures {
+ r.Errors = append(r.Errors, toAttachmentUploadError(err))
+ }
+ return r
+}
+
+func toAttachmentUploadError(err error) AttachmentUploadError {
+ if httpErr, ok := err.(web.HTTPErrorProcessor); ok {
+ details := httpErr.HTTPError()
+ return AttachmentUploadError{Code: details.Code, Message: details.Message}
+ }
+ return AttachmentUploadError{Message: err.Error()}
+}
+
+// WriteAttachmentDownload streams the attachment (or its inline image preview) to
+// the response and closes the file reader. The non-preview path delegates to
+// WriteFileDownload, which sets Cache-Control: no-cache; the preview branch returns
+// early, so it sets the same header itself.
+func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) {
+ defer func() { _ = ta.File.File.Close() }()
+
+ if preview != nil {
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Content-Length", strconv.Itoa(len(preview)))
+ _, _ = w.Write(preview)
+ return
+ }
+
+ WriteFileDownload(w, r, ta.File)
+}
diff --git a/pkg/web/files/task_attachment_test.go b/pkg/web/files/task_attachment_test.go
new file mode 100644
index 000000000..5b282b6b6
--- /dev/null
+++ b/pkg/web/files/task_attachment_test.go
@@ -0,0 +1,56 @@
+// 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 files
+
+import (
+ "errors"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBuildUploadResult(t *testing.T) {
+ t.Run("maps a domain error to its numeric code", func(t *testing.T) {
+ // ErrTaskAttachmentIsTooLarge is an HTTPErrorProcessor, so its Code must surface.
+ r := BuildUploadResult(nil, []error{models.ErrTaskAttachmentIsTooLarge{Size: 99}})
+ assert.Empty(t, r.Success)
+ if assert.Len(t, r.Errors, 1) {
+ assert.Equal(t, models.ErrCodeTaskAttachmentIsTooLarge, r.Errors[0].Code)
+ assert.NotEmpty(t, r.Errors[0].Message)
+ }
+ })
+
+ t.Run("plain error has no code, just the message", func(t *testing.T) {
+ r := BuildUploadResult(nil, []error{errors.New("boom")})
+ if assert.Len(t, r.Errors, 1) {
+ assert.Zero(t, r.Errors[0].Code)
+ assert.Equal(t, "boom", r.Errors[0].Message)
+ }
+ })
+
+ t.Run("preserves success and failure order", func(t *testing.T) {
+ success := []*models.TaskAttachment{{ID: 1}, {ID: 2}}
+ r := BuildUploadResult(success, []error{errors.New("first"), errors.New("second")})
+ assert.Equal(t, success, r.Success)
+ if assert.Len(t, r.Errors, 2) {
+ assert.Equal(t, "first", r.Errors[0].Message)
+ assert.Equal(t, "second", r.Errors[1].Message)
+ }
+ })
+}
diff --git a/pkg/web/handler/core.go b/pkg/web/handler/core.go
index 01474b874..25c91c069 100644
--- a/pkg/web/handler/core.go
+++ b/pkg/web/handler/core.go
@@ -28,7 +28,7 @@ import (
// DoCreate runs the permission check + model Create + commit pipeline for a
// CObject. Framework-agnostic: callable from both Echo (CreateWeb) and Huma.
// Caller is responsible for body/path binding and validation before calling.
-func DoCreate(_ context.Context, obj CObject, a web.Auth) error {
+func DoCreate(ctx context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
@@ -60,7 +60,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error {
return err
}
- events.DispatchPending(s)
+ events.DispatchPending(ctx, s)
return nil
}
@@ -68,7 +68,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error {
// CObject. obj should have its identifying fields set before call. On success,
// obj is fully populated. maxPermission is exposed via the x-max-permission
// header in the Echo wrapper; Huma wrapper may ignore it.
-func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, err error) {
+func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, err error) {
s := db.NewSession()
defer func() {
if cerr := s.Close(); cerr != nil {
@@ -100,7 +100,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e
return 0, err
}
- events.DispatchPending(s)
+ events.DispatchPending(ctx, s)
return maxPermission, nil
}
@@ -108,7 +108,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e
// scoping context (e.g., TaskID on LabelTask). Returns the result slice/
// interface, the result count, and total count. Pagination header math and
// nil-slice normalization remain the caller's responsibility.
-func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) {
+func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) {
s := db.NewSession()
defer func() {
if cerr := s.Close(); cerr != nil {
@@ -128,14 +128,14 @@ func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page,
return nil, 0, 0, err
}
- events.DispatchPending(s)
+ events.DispatchPending(ctx, s)
return result, resultCount, total, nil
}
// DoUpdate runs the permission check + model Update + commit pipeline for a
// CObject. Framework-agnostic. Caller is responsible for body/path binding
// and validation before calling.
-func DoUpdate(_ context.Context, obj CObject, a web.Auth) error {
+func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
@@ -167,14 +167,14 @@ func DoUpdate(_ context.Context, obj CObject, a web.Auth) error {
return err
}
- events.DispatchPending(s)
+ events.DispatchPending(ctx, s)
return nil
}
// DoDelete runs the permission check + model Delete + commit pipeline for a
// CObject. Framework-agnostic. Caller is responsible for path binding before
// calling.
-func DoDelete(_ context.Context, obj CObject, a web.Auth) error {
+func DoDelete(ctx context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
@@ -206,6 +206,6 @@ func DoDelete(_ context.Context, obj CObject, a web.Auth) error {
return err
}
- events.DispatchPending(s)
+ events.DispatchPending(ctx, s)
return nil
}
diff --git a/pkg/websocket/connection.go b/pkg/websocket/connection.go
index 0438b6fef..a2fb54d47 100644
--- a/pkg/websocket/connection.go
+++ b/pkg/websocket/connection.go
@@ -264,6 +264,9 @@ func (c *Connection) WriteLoop(ctx context.Context, cancel context.CancelFunc) {
// validEvents is the set of event names clients are allowed to subscribe to.
var validEvents = map[string]bool{
"notification.created": true,
+ "timer.created": true,
+ "timer.updated": true,
+ "timer.deleted": true,
}
func isValidEvent(event string) bool {
diff --git a/pkg/websocket/connection_test.go b/pkg/websocket/connection_test.go
index f5bccdac2..d05ab5c1e 100644
--- a/pkg/websocket/connection_test.go
+++ b/pkg/websocket/connection_test.go
@@ -80,6 +80,20 @@ func TestConnectionRejectsInvalidEvent(t *testing.T) {
assert.False(t, conn.IsSubscribed("notifications"))
}
+func TestConnectionAllowsTimerEvents(t *testing.T) {
+ conn := &Connection{
+ userID: 1,
+ authenticated: true,
+ subscriptions: make(map[string]bool),
+ send: make(chan OutgoingMessage, 16),
+ }
+
+ for _, event := range []string{"timer.created", "timer.updated"} {
+ conn.handleMessage(context.Background(), IncomingMessage{Action: ActionSubscribe, Event: event})
+ assert.True(t, conn.IsSubscribed(event), "client must be able to subscribe to %s", event)
+ }
+}
+
func TestConnectionRejectsActionsBeforeAuth(t *testing.T) {
conn := &Connection{
userID: 0, // not authenticated
diff --git a/pkg/websocket/listener.go b/pkg/websocket/listener.go
index 3250d1c45..e46759a71 100644
--- a/pkg/websocket/listener.go
+++ b/pkg/websocket/listener.go
@@ -21,7 +21,9 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/notifications"
"github.com/ThreeDotsLabs/watermill/message"
@@ -67,10 +69,50 @@ func (n *NotificationListener) Handle(msg *message.Message) error {
return nil
}
+// TimeEntryListener pushes a user's own timer changes to their WebSocket
+// connections. wsEvent is "timer.created", "timer.updated" or "timer.deleted";
+// the payload is the full entry, so the running-elsewhere badge reads end_time
+// to know whether a timer is active (and the id to drop a deleted one). Not
+// emitted on unlicensed instances.
+type TimeEntryListener struct {
+ wsEvent string
+}
+
+func (l *TimeEntryListener) Name() string { return "websocket.push." + l.wsEvent }
+
+func (l *TimeEntryListener) Handle(msg *message.Message) error {
+ if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
+ return nil
+ }
+
+ // All TimeEntry events share the {time_entry, doer} shape; only the entry is needed.
+ var event struct {
+ TimeEntry *models.TimeEntry `json:"time_entry"`
+ }
+ if err := json.Unmarshal(msg.Payload, &event); err != nil {
+ return err
+ }
+ if event.TimeEntry == nil {
+ return nil
+ }
+
+ hub := GetHub()
+ if hub == nil {
+ log.Warningf("WebSocket: hub not initialized, skipping timer push")
+ return nil
+ }
+
+ hub.PublishForUser(event.TimeEntry.UserID, l.wsEvent, event.TimeEntry)
+ return nil
+}
+
// RegisterListeners registers WebSocket event listeners.
func RegisterListeners() {
events.RegisterListener(
(¬ifications.NotificationCreatedEvent{}).Name(),
&NotificationListener{},
)
+ events.RegisterListener((&models.TimeEntryCreatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.created"})
+ events.RegisterListener((&models.TimeEntryUpdatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.updated"})
+ events.RegisterListener((&models.TimeEntryDeletedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.deleted"})
}
diff --git a/pkg/websocket/main_test.go b/pkg/websocket/main_test.go
index 7740dc0e0..21243069b 100644
--- a/pkg/websocket/main_test.go
+++ b/pkg/websocket/main_test.go
@@ -20,10 +20,14 @@ import (
"os"
"testing"
+ "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/modules/keyvalue"
)
func TestMain(m *testing.M) {
log.InitLogger()
+ config.InitDefaultConfig()
+ keyvalue.InitStorage() // license.SetForTests persists state through keyvalue
os.Exit(m.Run())
}
diff --git a/pkg/websocket/time_entry_listener_test.go b/pkg/websocket/time_entry_listener_test.go
new file mode 100644
index 000000000..8f67df8b9
--- /dev/null
+++ b/pkg/websocket/time_entry_listener_test.go
@@ -0,0 +1,113 @@
+// 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 websocket
+
+import (
+ "testing"
+
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func timerConn(userID int64) *Connection {
+ return &Connection{
+ userID: userID,
+ subscriptions: map[string]bool{"timer.created": true, "timer.updated": true, "timer.deleted": true},
+ send: make(chan OutgoingMessage, 16),
+ }
+}
+
+func TestTimeEntryListener(t *testing.T) {
+ t.Run("a create pushes timer.created with the entry to its owner", func(t *testing.T) {
+ InitHub()
+ conn := timerConn(1)
+ GetHub().Register(conn)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ ev := &models.TimeEntryCreatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
+ events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.created"})
+
+ require.Len(t, conn.send, 1)
+ msg := <-conn.send
+ assert.Equal(t, "timer.created", msg.Event)
+ te, ok := msg.Data.(*models.TimeEntry)
+ require.True(t, ok, "payload must be the time entry itself")
+ assert.Equal(t, int64(4), te.ID)
+ })
+
+ t.Run("an update pushes timer.updated", func(t *testing.T) {
+ InitHub()
+ conn := timerConn(1)
+ GetHub().Register(conn)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
+ events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"})
+
+ require.Len(t, conn.send, 1)
+ assert.Equal(t, "timer.updated", (<-conn.send).Event)
+ })
+
+ t.Run("a delete pushes timer.deleted so other tabs drop it", func(t *testing.T) {
+ InitHub()
+ conn := timerConn(1)
+ GetHub().Register(conn)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ ev := &models.TimeEntryDeletedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
+ events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.deleted"})
+
+ require.Len(t, conn.send, 1)
+ msg := <-conn.send
+ assert.Equal(t, "timer.deleted", msg.Event)
+ te, ok := msg.Data.(*models.TimeEntry)
+ require.True(t, ok)
+ assert.Equal(t, int64(4), te.ID)
+ })
+
+ t.Run("does not push when the feature is disabled", func(t *testing.T) {
+ InitHub()
+ conn := timerConn(1)
+ GetHub().Register(conn)
+ license.ResetForTests() // free mode
+
+ ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
+ events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"})
+
+ assert.Empty(t, conn.send)
+ })
+
+ t.Run("only pushes to the entry owner", func(t *testing.T) {
+ InitHub()
+ other := timerConn(2)
+ GetHub().Register(other)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
+ events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"})
+
+ assert.Empty(t, other.send, "a different user must not receive the timer update")
+ })
+}
diff --git a/pkg/webtests/huma_admin_actions_test.go b/pkg/webtests/huma_admin_actions_test.go
new file mode 100644
index 000000000..806036a32
--- /dev/null
+++ b/pkg/webtests/huma_admin_actions_test.go
@@ -0,0 +1,387 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Gate behaviour (404 on non-admin/unlicensed, 401 unauthenticated) is shared by
+// every /api/v2/admin route; covered once here against the overview endpoint.
+func TestHumaAdminOverview(t *testing.T) {
+ t.Run("non-admin user gets 404", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+ require.False(t, u.IsAdmin, "fixture precondition: user1 is not an admin")
+
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", u, "")
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+
+ t.Run("admin without the feature gets 404", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "")
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+
+ t.Run("unauthenticated caller gets 401", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", nil, "")
+ assert.Equal(t, http.StatusUnauthorized, res.Code)
+ })
+
+ t.Run("admin with the feature sees the overview", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+ res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "")
+ require.Equal(t, http.StatusOK, res.Code, res.Body.String())
+ body := res.Body.String()
+ assert.Contains(t, body, `"users"`)
+ assert.Contains(t, body, `"projects"`)
+ assert.Contains(t, body, `"tasks"`)
+ assert.Contains(t, body, `"shares"`)
+ assert.Contains(t, body, `"license"`)
+ assert.Contains(t, body, `"licensed":true`)
+ assert.Contains(t, body, `"instance_id"`)
+ })
+}
+
+func TestHumaAdminCreateUser(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ // Admin endpoint must bypass the public-registration toggle.
+ prev := config.ServiceEnableRegistration.GetBool()
+ config.ServiceEnableRegistration.Set(false)
+ defer config.ServiceEnableRegistration.Set(prev)
+
+ admin := promoteToAdmin(t, 1)
+
+ t.Run("creates a plain user and returns 201", func(t *testing.T) {
+ body := `{"username":"v2adm-create-1","password":"averyl0ngpassword","email":"v2adm-create-1@example.com"}`
+ res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
+ assert.Equal(t, http.StatusCreated, res.Code, res.Body.String())
+ assert.Contains(t, res.Body.String(), `"username":"v2adm-create-1"`)
+ })
+
+ t.Run("creates an is_admin user", func(t *testing.T) {
+ body := `{"username":"v2adm-create-2","password":"averyl0ngpassword","email":"v2adm-create-2@example.com","is_admin":true}`
+ res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
+ require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByUsername(s, "v2adm-create-2")
+ require.NoError(t, err)
+ assert.True(t, u.IsAdmin, "new user should have been promoted")
+ })
+
+ t.Run("skip_email_confirm forces Status=Active", func(t *testing.T) {
+ body := `{"username":"v2adm-create-3","password":"averyl0ngpassword","email":"v2adm-create-3@example.com","skip_email_confirm":true}`
+ res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
+ require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByUsername(s, "v2adm-create-3")
+ require.NoError(t, err)
+ assert.Equal(t, user.StatusActive, u.Status)
+ })
+
+ t.Run("persists the name field", func(t *testing.T) {
+ body := `{"username":"v2adm-create-4","password":"averyl0ngpassword","email":"v2adm-create-4@example.com","name":"Adm Create"}`
+ res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
+ require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByUsername(s, "v2adm-create-4")
+ require.NoError(t, err)
+ assert.Equal(t, "Adm Create", u.Name)
+ })
+
+ t.Run("rejects an invalid body with 422", func(t *testing.T) {
+ // Password below the 8-char minimum fails govalidator before the create.
+ body := `{"username":"v2adm-invalid","password":"short","email":"v2adm-invalid@example.com"}`
+ res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
+ assert.Equal(t, http.StatusUnprocessableEntity, res.Code, res.Body.String())
+ })
+
+ t.Run("non-admin caller gets 404", func(t *testing.T) {
+ s := db.NewSession()
+ u2, err := user.GetUserByID(s, 2)
+ require.NoError(t, err)
+ require.False(t, u2.IsAdmin, "fixture precondition: user2 is not an admin")
+ s.Close()
+
+ body := `{"username":"v2nonadmin","password":"averyl0ngpassword","email":"v2nonadmin@example.com"}`
+ res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", u2, body)
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+}
+
+func TestHumaAdminPatchAdmin(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+
+ t.Run("promote a non-admin user", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`)
+ assert.Equal(t, http.StatusOK, res.Code, res.Body.String())
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 2)
+ require.NoError(t, err)
+ assert.True(t, u.IsAdmin)
+ })
+
+ t.Run("demote when another admin exists is allowed", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":false}`)
+ assert.Equal(t, http.StatusOK, res.Code)
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 2)
+ require.NoError(t, err)
+ assert.False(t, u.IsAdmin)
+ })
+
+ t.Run("last-admin guard refuses demotion with 400", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/admin", admin, `{"is_admin":false}`)
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+ assert.True(t, u.IsAdmin, "last admin must remain admin after refused demotion")
+ })
+
+ t.Run("unknown user returns 404", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/9999999/admin", admin, `{"is_admin":true}`)
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+
+ t.Run("omitted is_admin is rejected rather than demoting", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`)
+ require.Equal(t, http.StatusOK, res.Code)
+
+ res = adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{}`)
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 2)
+ require.NoError(t, err)
+ assert.True(t, u.IsAdmin, "omitted is_admin must not silently demote")
+ })
+}
+
+func TestHumaAdminPatchStatus(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":2}`)
+ assert.Equal(t, http.StatusOK, res.Code, res.Body.String())
+
+ // GetUserByID refuses disabled accounts, so assert against the raw row.
+ s := db.NewSession()
+ defer s.Close()
+ var row struct {
+ Status int `xorm:"status"`
+ }
+ _, err = s.Table("users").Where("id = ?", 2).Get(&row)
+ require.NoError(t, err)
+ assert.Equal(t, 2, row.Status)
+
+ t.Run("last-admin guard refuses self-disable with 400", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/status", admin, `{"status":2}`)
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+
+ var row struct {
+ Status int `xorm:"status"`
+ }
+ _, err := s.Table("users").Where("id = ?", 1).Get(&row)
+ require.NoError(t, err)
+ assert.Equal(t, int(user.StatusActive), row.Status, "last admin must stay active after refused disable")
+ })
+
+ t.Run("rejects invalid status value with 400", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":99}`)
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+ assert.Contains(t, res.Body.String(), "invalid status")
+ })
+
+ t.Run("omitted status is rejected rather than reactivating", func(t *testing.T) {
+ // User 2 was disabled above; an empty body must leave that intact.
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{}`)
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+
+ var row struct {
+ Status int `xorm:"status"`
+ }
+ _, err := s.Table("users").Where("id = ?", 2).Get(&row)
+ require.NoError(t, err)
+ assert.Equal(t, int(user.StatusDisabled), row.Status, "omitted status must not silently reactivate")
+ })
+}
+
+func TestHumaAdminDeleteUser(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+
+ t.Run("mode=now deletes a regular user immediately with 204", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/15?mode=now", admin, "")
+ assert.Equal(t, http.StatusNoContent, res.Code)
+
+ s := db.NewSession()
+ defer s.Close()
+ _, err := user.GetUserByID(s, 15)
+ assert.Error(t, err, "deleted user must no longer be fetchable")
+ })
+
+ t.Run("mode=scheduled keeps the user row", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/16?mode=scheduled", admin, "")
+ assert.Equal(t, http.StatusNoContent, res.Code)
+
+ s := db.NewSession()
+ defer s.Close()
+ u := &user.User{ID: 16}
+ has, err := s.Get(u)
+ require.NoError(t, err)
+ assert.True(t, has, "scheduled deletion must not remove the user row")
+ })
+
+ t.Run("default (no mode) is scheduled", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/2", admin, "")
+ assert.Equal(t, http.StatusNoContent, res.Code)
+
+ s := db.NewSession()
+ defer s.Close()
+ u := &user.User{ID: 2}
+ has, err := s.Get(u)
+ require.NoError(t, err)
+ assert.True(t, has, "default mode must not remove the user row")
+ })
+
+ t.Run("rejects invalid mode with 400", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/3?mode=bogus", admin, "")
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+ })
+
+ t.Run("mode=now last-admin guard refuses self-delete with 400", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/1?mode=now", admin, "")
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+ })
+
+ t.Run("unknown user returns 404", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/9999999?mode=now", admin, "")
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+}
+
+func TestHumaAdminReassignProjectOwner(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureAdminPanel})
+ defer license.ResetForTests()
+
+ admin := promoteToAdmin(t, 1)
+
+ t.Run("updates owner_id", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":2}`)
+ assert.Equal(t, http.StatusOK, res.Code, res.Body.String())
+
+ s := db.NewSession()
+ defer s.Close()
+ var row struct {
+ OwnerID int64 `xorm:"owner_id"`
+ }
+ _, err := s.Table("projects").Where("id = ?", 2).Get(&row)
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), row.OwnerID)
+ })
+
+ t.Run("rejects nonexistent owner with 404", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":99999}`)
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+
+ t.Run("nonexistent project returns 404", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/99999/owner", admin, `{"owner_id":1}`)
+ assert.Equal(t, http.StatusNotFound, res.Code)
+ })
+
+ t.Run("rejects disabled user as new owner with 412", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":17}`)
+ assert.Equal(t, http.StatusPreconditionFailed, res.Code)
+ })
+
+ t.Run("rejects locked user as new owner with 412", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":18}`)
+ assert.Equal(t, http.StatusPreconditionFailed, res.Code)
+ })
+
+ t.Run("rejects deletion-scheduled user as new owner with 400", func(t *testing.T) {
+ res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":20}`)
+ assert.Equal(t, http.StatusBadRequest, res.Code)
+ })
+}
diff --git a/pkg/webtests/huma_auth_login_test.go b/pkg/webtests/huma_auth_login_test.go
new file mode 100644
index 000000000..48931effa
--- /dev/null
+++ b/pkg/webtests/huma_auth_login_test.go
@@ -0,0 +1,196 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/pquerna/otp/totp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// refreshCookie returns the Set-Cookie value for the refresh-token cookie, or ""
+// if the response set no such cookie.
+func refreshCookie(rec *httptest.ResponseRecorder) *http.Cookie {
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == auth.RefreshTokenCookieName {
+ return c
+ }
+ }
+ return nil
+}
+
+// TestHumaLogin ports the v1 login coverage to /api/v2: it asserts the token
+// response, the HttpOnly refresh cookie, the no-store header, and the credential
+// and TOTP gates.
+func TestHumaLogin(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ login := func(body string) *httptest.ResponseRecorder {
+ return humaRequest(t, e, http.MethodPost, "/api/v2/login", body, "", "")
+ }
+
+ t.Run("normal login", func(t *testing.T) {
+ rec := login(`{"username":"user1","password":"12345678"}`)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"token":"`)
+
+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
+
+ cookie := refreshCookie(rec)
+ require.NotNil(t, cookie, "login must set the refresh-token cookie")
+ assert.NotEmpty(t, cookie.Value)
+ assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly")
+ })
+
+ t.Run("wrong password", func(t *testing.T) {
+ rec := login(`{"username":"user1","password":"wrong"}`)
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec))
+ assert.Nil(t, refreshCookie(rec), "a failed login must not set a refresh cookie")
+ })
+
+ t.Run("nonexistent user", func(t *testing.T) {
+ rec := login(`{"username":"userWhichDoesNotExist","password":"12345678"}`)
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec))
+ })
+
+ t.Run("unconfirmed email", func(t *testing.T) {
+ rec := login(`{"username":"user5","password":"12345678"}`)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ assert.Equal(t, user.ErrCodeEmailNotConfirmed, problemCode(t, rec))
+ })
+
+ t.Run("disabled account", func(t *testing.T) {
+ rec := login(`{"username":"user17","password":"12345678"}`)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ assert.Equal(t, user.ErrCodeAccountDisabled, problemCode(t, rec))
+ })
+
+ t.Run("locked account", func(t *testing.T) {
+ rec := login(`{"username":"user18","password":"12345678"}`)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ assert.Equal(t, user.ErrCodeAccountLocked, problemCode(t, rec))
+ })
+
+ t.Run("TOTP required but missing", func(t *testing.T) {
+ rec := login(`{"username":"user10","password":"12345678"}`)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec))
+ })
+
+ t.Run("TOTP wrong", func(t *testing.T) {
+ rec := login(`{"username":"user10","password":"12345678","totp_passcode":"000000"}`)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec))
+ })
+
+ t.Run("TOTP correct", func(t *testing.T) {
+ code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
+ require.NoError(t, err)
+ rec := login(`{"username":"user10","password":"12345678","totp_passcode":"` + code + `"}`)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"token":"`)
+ assert.NotNil(t, refreshCookie(rec))
+ })
+}
+
+// TestHumaLogout proves the v2 logout deletes the session server-side and clears
+// the refresh-token cookie.
+func TestHumaLogout(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ // Create a session so logout has something to delete, then mint a JWT whose
+ // sid claim points at it.
+ s := db.NewSession()
+ session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false, nil)
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+ require.NoError(t, s.Close())
+
+ token, err := auth.NewUserJWTAuthtoken(&testuser1, session.ID)
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/logout", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "Successfully logged out.")
+
+ cookie := refreshCookie(rec)
+ require.NotNil(t, cookie, "logout must clear the refresh cookie")
+ assert.Empty(t, cookie.Value, "cleared cookie has no value")
+ assert.Negative(t, cookie.MaxAge, "cleared cookie is expired")
+
+ // The session must be gone.
+ check := db.NewSession()
+ defer check.Close()
+ exists, err := check.Where("id = ?", session.ID).Exist(&models.Session{})
+ require.NoError(t, err)
+ assert.False(t, exists, "logout must delete the session")
+}
+
+// TestHumaLoginUnauthenticated proves login needs no token (it is a public op).
+func TestHumaLoginUnauthenticated(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/login", `{"username":"user1","password":"12345678"}`, "", "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+}
+
+// TestHumaOpenIDGating proves the OIDC callback route only exists when OpenID is
+// enabled, mirroring the registrar gate.
+func TestHumaOpenIDGating(t *testing.T) {
+ body := `{"code":"abc","redirect_url":"https://example.com"}`
+
+ t.Run("disabled returns 404", func(t *testing.T) {
+ config.AuthOpenIDEnabled.Set(false)
+
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/test/callback", body, "", "")
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ })
+
+ t.Run("enabled does not require auth", func(t *testing.T) {
+ config.AuthOpenIDEnabled.Set(true)
+ defer config.AuthOpenIDEnabled.Set(false)
+
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ // No provider is configured, so the call fails downstream — but it must
+ // not 404 as an unknown route nor 401 for missing auth, which proves the
+ // public route is registered.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/doesnotexist/callback", body, "", "")
+ assert.NotEqual(t, http.StatusNotFound, rec.Code)
+ assert.NotEqual(t, http.StatusUnauthorized, rec.Code)
+ })
+}
diff --git a/pkg/webtests/huma_auth_refresh_test.go b/pkg/webtests/huma_auth_refresh_test.go
new file mode 100644
index 000000000..48fad31c6
--- /dev/null
+++ b/pkg/webtests/huma_auth_refresh_test.go
@@ -0,0 +1,92 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/api/pkg/modules/auth"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// refreshRequest posts to the v2 refresh endpoint with the given refresh-token
+// cookie value (empty value omits the cookie entirely), driving the full
+// echo+Huma stack so cookie reading and Set-Cookie writing are exercised.
+func refreshRequest(e *echo.Echo, refreshToken string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, "/api/v2/user/token/refresh", strings.NewReader(""))
+ req.Header.Set("Content-Type", "application/json")
+ if refreshToken != "" {
+ req.AddCookie(&http.Cookie{Name: auth.RefreshTokenCookieName, Value: refreshToken})
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// TestHumaRefreshToken ports the v1 refresh-token coverage to /api/v2: a valid
+// cookie yields a new JWT and a rotated HttpOnly cookie, the old token then stops
+// working, and missing/invalid cookies map to the same 401 v1 returns.
+func TestHumaRefreshToken(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("valid refresh token", func(t *testing.T) {
+ rec := refreshRequest(e, "testtoken_session1")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"token":"`)
+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
+
+ cookie := refreshCookie(rec)
+ require.NotNil(t, cookie, "refresh must set a new refresh-token cookie")
+ assert.NotEmpty(t, cookie.Value)
+ assert.NotEqual(t, "testtoken_session1", cookie.Value, "refresh token must be rotated")
+ assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly")
+ })
+
+ t.Run("rotation invalidates the old token", func(t *testing.T) {
+ // session2 is a separate session so this case does not depend on the
+ // one above. The first refresh succeeds and rotates the token.
+ first := refreshRequest(e, "testtoken_session2")
+ require.Equal(t, http.StatusOK, first.Code, first.Body.String())
+ newCookie := refreshCookie(first)
+ require.NotNil(t, newCookie)
+
+ // Replaying the now-rotated token must fail.
+ replay := refreshRequest(e, "testtoken_session2")
+ assert.Equal(t, http.StatusUnauthorized, replay.Code)
+
+ // The freshly rotated token still works.
+ next := refreshRequest(e, newCookie.Value)
+ assert.Equal(t, http.StatusOK, next.Code, next.Body.String())
+ })
+
+ t.Run("missing cookie", func(t *testing.T) {
+ rec := refreshRequest(e, "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+
+ t.Run("invalid cookie", func(t *testing.T) {
+ rec := refreshRequest(e, "garbage")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+}
diff --git a/pkg/webtests/huma_auth_test.go b/pkg/webtests/huma_auth_test.go
new file mode 100644
index 000000000..404f89f00
--- /dev/null
+++ b/pkg/webtests/huma_auth_test.go
@@ -0,0 +1,293 @@
+// 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 webtests
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/modules/auth/oauth2server"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaAuthPublic ports the v1 coverage of the public local-account flows
+// (register, password reset, email confirm) to /api/v2. These endpoints opt out
+// of the global auth, so requests carry no token.
+func TestHumaAuthPublic(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ post := func(path, body string) *httptest.ResponseRecorder {
+ return humaRequest(t, e, http.MethodPost, path, body, "", "")
+ }
+
+ t.Run("Register", func(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ rec := post("/api/v2/register", `{"username":"newhumauser","password":"12345678","email":"newhuma@example.com"}`)
+ require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"username":"newhumauser"`)
+ })
+ t.Run("already existing username", func(t *testing.T) {
+ rec := post("/api/v2/register", `{"username":"user1","password":"12345678","email":"x@example.com"}`)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+ t.Run("empty username", func(t *testing.T) {
+ rec := post("/api/v2/register", `{"username":"","password":"12345678","email":"x@example.com"}`)
+ assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest)
+ })
+ })
+
+ t.Run("Request password reset token", func(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ rec := post("/api/v2/user/password/token", `{"email":"user1@example.com"}`)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "Token was sent.")
+ })
+ t.Run("no user with that email", func(t *testing.T) {
+ rec := post("/api/v2/user/password/token", `{"email":"user1000@example.com"}`)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ })
+ })
+
+ t.Run("Reset password", func(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ rec := post("/api/v2/user/password/reset", `{"token":"passwordresettesttoken","new_password":"12345678"}`)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "The password was updated successfully.")
+ })
+ t.Run("invalid token", func(t *testing.T) {
+ rec := post("/api/v2/user/password/reset", `{"token":"invalidtoken","new_password":"12345678"}`)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ })
+ })
+
+ t.Run("Confirm email", func(t *testing.T) {
+ t.Run("normal", func(t *testing.T) {
+ rec := post("/api/v2/user/confirm", `{"token":"tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael"}`)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "The email was confirmed successfully.")
+ })
+ t.Run("invalid token", func(t *testing.T) {
+ rec := post("/api/v2/user/confirm", `{"token":"invalidToken"}`)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ })
+ })
+}
+
+// TestHumaRegisterDisabled proves the registration endpoint 404s when
+// registration is disabled, mirroring v1.
+func TestHumaRegisterDisabled(t *testing.T) {
+ config.ServiceEnableRegistration.Set(false)
+ defer config.ServiceEnableRegistration.Set(true)
+
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/register",
+ `{"username":"nope","password":"12345678","email":"nope@example.com"}`, "", "")
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+// TestHumaLinkShareAuth ports the v1 link-share auth coverage to /api/v2.
+func TestHumaLinkShareAuth(t *testing.T) {
+ config.ServiceEnableLinkSharing.Set(true)
+
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ post := func(share, body string) *httptest.ResponseRecorder {
+ return humaRequest(t, e, http.MethodPost, "/api/v2/shares/"+share+"/auth", body, "", "")
+ }
+
+ t.Run("without password", func(t *testing.T) {
+ rec := post("test", ``)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"token":"`)
+ assert.Contains(t, rec.Body.String(), `"project_id":1`)
+ })
+ t.Run("with password, correct", func(t *testing.T) {
+ rec := post("testWithPassword", `{"password":"12345678"}`)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"token":"`)
+ })
+ t.Run("with password, missing", func(t *testing.T) {
+ rec := post("testWithPassword", ``)
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
+ assert.Equal(t, models.ErrCodeLinkSharePasswordRequired, problemCode(t, rec))
+ })
+ t.Run("with password, wrong", func(t *testing.T) {
+ rec := post("testWithPassword", `{"password":"wrong"}`)
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ assert.Equal(t, models.ErrCodeLinkSharePasswordInvalid, problemCode(t, rec))
+ })
+}
+
+// TestHumaTokenMeta ports the token-introspection and link-share renew
+// endpoints to /api/v2.
+func TestHumaTokenMeta(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ userToken := humaTokenFor(t, &testuser1)
+
+ t.Run("token test (GET) returns ok", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", userToken, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"ok"`)
+ })
+ t.Run("token check (POST) returns 200, not 418", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/token/test", "", userToken, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"ok"`)
+ })
+ t.Run("token check unauthenticated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+ t.Run("routes lists token routes", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/routes", "", userToken, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ var routes map[string]map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &routes))
+ assert.Contains(t, routes, "tasks")
+ })
+
+ t.Run("renew link-share token", func(t *testing.T) {
+ share := &models.LinkSharing{
+ ID: 1,
+ Hash: "test",
+ ProjectID: 1,
+ Permission: models.PermissionRead,
+ SharingType: models.SharingTypeWithoutPassword,
+ SharedByID: 1,
+ }
+ shareToken, err := auth.NewLinkShareJWTAuthtoken(share)
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", shareToken, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"token":"`)
+ })
+ t.Run("renew rejects user token", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", userToken, "")
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+}
+
+// TestHumaOAuth ports the OAuth 2.0 token and authorize flows to /api/v2 and
+// exercises both the JSON and the spec-compliant form-urlencoded encodings of
+// the token endpoint.
+func TestHumaOAuth(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("authorize requires authentication", func(t *testing.T) {
+ body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "abc", "S256", "s")
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+
+ t.Run("full code flow with PKCE (JSON token request)", func(t *testing.T) {
+ verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ challenge := pkceChallenge(verifier)
+ code := authorizeV2(t, e, challenge, "xyz")
+
+ body, _ := json.Marshal(map[string]string{ //nolint:errchkjson
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": "vikunja",
+ "redirect_uri": "vikunja-flutter://callback",
+ "code_verifier": verifier,
+ })
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", string(body), "", "application/json")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
+
+ var resp oauth2server.TokenResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.NotEmpty(t, resp.AccessToken)
+ assert.Equal(t, "bearer", resp.TokenType)
+ assert.NotEmpty(t, resp.RefreshToken)
+ })
+
+ t.Run("full code flow with PKCE (form-urlencoded token request)", func(t *testing.T) {
+ verifier := "form-encoded-flow-verifier"
+ challenge := pkceChallenge(verifier)
+ code := authorizeV2(t, e, challenge, "")
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "client_id": {"vikunja"},
+ "redirect_uri": {"vikunja-flutter://callback"},
+ "code_verifier": {verifier},
+ }
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+
+ var resp oauth2server.TokenResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.NotEmpty(t, resp.AccessToken)
+ assert.NotEmpty(t, resp.RefreshToken)
+ })
+
+ t.Run("invalid grant type", func(t *testing.T) {
+ form := url.Values{"grant_type": {"password"}, "client_id": {"vikunja"}}
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded")
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+}
+
+func pkceChallenge(verifier string) string {
+ h := sha256.Sum256([]byte(verifier))
+ return base64.RawURLEncoding.EncodeToString(h[:])
+}
+
+// authorizeV2 runs the v2 authorize step for testuser1 and returns the code.
+func authorizeV2(t *testing.T, e *echo.Echo, challenge, state string) string {
+ t.Helper()
+ token := humaTokenFor(t, &testuser1)
+ body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", challenge, "S256", state)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), token, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+
+ var resp oauth2server.AuthorizeResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.NotEmpty(t, resp.Code)
+ return resp.Code
+}
+
+// problemCode pulls the Vikunja numeric error code out of an RFC 9457 body.
+func problemCode(t *testing.T, rec *httptest.ResponseRecorder) int {
+ t.Helper()
+ var body struct {
+ Code int `json:"code"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
+ return body.Code
+}
diff --git a/pkg/webtests/huma_background_download_test.go b/pkg/webtests/huma_background_download_test.go
new file mode 100644
index 000000000..e4c542a26
--- /dev/null
+++ b/pkg/webtests/huma_background_download_test.go
@@ -0,0 +1,162 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/routes"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// getBackgroundRequest issues a GET against the background download route with an
+// optional If-Modified-Since header (humaRequest can't set arbitrary headers).
+func getBackgroundRequest(t *testing.T, e *echo.Echo, project, token, ifModifiedSince string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/projects/"+project+"/background", nil)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ if ifModifiedSince != "" {
+ req.Header.Set("If-Modified-Since", ifModifiedSince)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// TestHumaProjectBackgroundDownload covers GET /projects/{project}/background. The
+// fixture file row (project 35, background_file_id 1) carries no bytes, so the happy
+// path uploads a real background first (the "upload-then-download" pattern) before
+// fetching it back.
+func TestHumaProjectBackgroundDownload(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("Owner uploads then downloads the background", func(t *testing.T) {
+ // testuser1 owns project 1, which starts without a background.
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ up := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
+ require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String())
+
+ rec := getBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Equal(t, "image/jpg", rec.Header().Get("Content-Type"))
+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
+ assert.NotEmpty(t, rec.Body.Bytes(), "the download must return the stored bytes")
+ })
+
+ t.Run("If-Modified-Since returns 304", func(t *testing.T) {
+ // The in-memory test storage reports a zero modtime, so any valid
+ // If-Modified-Since is not-before it and yields a 304.
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ up := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
+ require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String())
+
+ rec := getBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), "Wed, 21 Oct 2015 07:28:00 GMT")
+ assert.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String())
+ assert.Empty(t, rec.Body.Bytes(), "a 304 must not carry a body")
+ })
+
+ t.Run("Project without a background returns 404", func(t *testing.T) {
+ // testuser1 owns project 21, which has no background and isn't uploaded to
+ // by any other subtest (project 1 is, and subtests share this env).
+ rec := getBackgroundRequest(t, e, "21", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Read-only user may download", func(t *testing.T) {
+ // testuser6 owns project 35 and uploads a real background; testuser15 has
+ // read-only access, which CanRead allows for the download. Uploading first
+ // gives the file real bytes (the fixture row has none).
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ up := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser6), body, contentType)
+ require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String())
+
+ rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.NotEmpty(t, rec.Body.Bytes(), "the read-only user must receive the bytes")
+ })
+
+ t.Run("No access at all is forbidden", func(t *testing.T) {
+ // testuser1 has no access to project 35.
+ rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Unauthenticated", func(t *testing.T) {
+ rec := getBackgroundRequest(t, e, "35", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaProjectBackgroundDownloadDisabledByConfig verifies the download route is
+// absent (404) when project backgrounds are disabled.
+func TestHumaProjectBackgroundDownloadDisabledByConfig(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ config.BackgroundsEnabled.Set(false)
+ defer config.BackgroundsEnabled.Set(true)
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+
+ rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser6), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when backgrounds are disabled; body: %s", rec.Body.String())
+}
+
+// TestHumaUnsplashProxy covers the Unsplash image/thumb proxy routes' gating and auth.
+// They only register when the unsplash provider is enabled (off by default), so the
+// router is rebuilt with the flag on. The proxy's happy path needs the live Unsplash
+// API and is therefore not covered here, matching v1 (which has no proxy tests).
+func TestHumaUnsplashProxy(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("Routes absent when unsplash is disabled", func(t *testing.T) {
+ // Unsplash is disabled by default; the proxy routes must not exist.
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc", "", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "image proxy must be absent when unsplash is disabled; body: %s", rec.Body.String())
+
+ rec = humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc/thumb", "", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "thumb proxy must be absent when unsplash is disabled; body: %s", rec.Body.String())
+ })
+
+ t.Run("Proxies require auth when unsplash is enabled", func(t *testing.T) {
+ config.BackgroundsUnsplashEnabled.Set(true)
+ defer config.BackgroundsUnsplashEnabled.Set(false)
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "image proxy body: %s", rec.Body.String())
+
+ rec = humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc/thumb", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "thumb proxy body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_background_test.go b/pkg/webtests/huma_background_test.go
new file mode 100644
index 000000000..8efdc3c2b
--- /dev/null
+++ b/pkg/webtests/huma_background_test.go
@@ -0,0 +1,112 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/routes"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaProjectBackgroundDelete covers removing a project background. It
+// mirrors the v1 background_test.go matrix: the owner clears the background
+// (and keeps the title), a read-only user is refused.
+func TestHumaProjectBackgroundDelete(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("Owner clears the background, title preserved", func(t *testing.T) {
+ // testuser6 owns project 35 (title "Test35 with background", background_file_id 1).
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ s := db.NewSession()
+ defer s.Close()
+ project := models.Project{ID: 35}
+ has, err := s.Get(&project)
+ require.NoError(t, err)
+ require.True(t, has)
+ assert.Equal(t, "Test35 with background", project.Title)
+ assert.Equal(t, int64(0), project.BackgroundFileID)
+ })
+ t.Run("Read-only user is forbidden", func(t *testing.T) {
+ // testuser15 has read-only (permission 0) access to project 35.
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser15), "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("No access at all is forbidden", func(t *testing.T) {
+ // testuser1 has no access to project 35.
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaBackgroundDisabledByConfig verifies the registrar early-returns when
+// project backgrounds are disabled: the DELETE route is then absent (404).
+func TestHumaBackgroundDisabledByConfig(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ config.BackgroundsEnabled.Set(false)
+ defer config.BackgroundsEnabled.Set(true)
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when backgrounds are disabled; body: %s", rec.Body.String())
+}
+
+// TestHumaUnsplashBackground covers the Unsplash routes' auth and permission
+// gates. They are only registered when the unsplash provider is enabled (off by
+// default), so the router is rebuilt with the flag on. The set route's
+// permission check runs before any Unsplash network call, so the negative cases
+// are exercised without hitting the real API; the happy path needs the network
+// and is therefore not covered here (matching v1).
+func TestHumaUnsplashBackground(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ config.BackgroundsEnabled.Set(true)
+ config.BackgroundsUnsplashEnabled.Set(true)
+ defer config.BackgroundsUnsplashEnabled.Set(false)
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+
+ t.Run("Search requires auth", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/search?q=mountain", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Set requires auth", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Set forbidden for read-only user", func(t *testing.T) {
+ // testuser15 has read-only access to project 35; CanUpdate fails before
+ // p.Set reaches Unsplash.
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, humaTokenFor(t, &testuser15), "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_background_upload_test.go b/pkg/webtests/huma_background_upload_test.go
new file mode 100644
index 000000000..68755f53a
--- /dev/null
+++ b/pkg/webtests/huma_background_upload_test.go
@@ -0,0 +1,151 @@
+// 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 webtests
+
+import (
+ "bytes"
+ "encoding/json"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/routes"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// multipartFileBody builds a multipart body with a single file part under the
+// given field name. CreateFormFile sets the part Content-Type to
+// application/octet-stream, mirroring how many programmatic clients upload.
+func multipartFileBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
+ t.Helper()
+ buf := &bytes.Buffer{}
+ w := multipart.NewWriter(buf)
+ fw, err := w.CreateFormFile(fieldName, filename)
+ require.NoError(t, err)
+ _, err = fw.Write(content)
+ require.NoError(t, err)
+ require.NoError(t, w.Close())
+ return buf, w.FormDataContentType()
+}
+
+func uploadBackgroundRequest(t *testing.T, e *echo.Echo, project, token string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPut, "/api/v2/projects/"+project+"/backgrounds/upload", body)
+ req.Header.Set("Content-Type", contentType)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestHumaProjectBackgroundUpload(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("Owner uploads a background", func(t *testing.T) {
+ // testuser1 owns project 1, which starts without a background.
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ s := db.NewSession()
+ defer s.Close()
+ project := models.Project{ID: 1}
+ has, err := s.Get(&project)
+ require.NoError(t, err)
+ require.True(t, has)
+ assert.NotZero(t, project.BackgroundFileID, "the upload must set a background file id")
+ assert.NotEmpty(t, project.BackgroundBlurHash, "the upload must compute a blur hash")
+ })
+
+ t.Run("Non-image rejected with 400", func(t *testing.T) {
+ body, contentType := multipartFileBody(t, "background", "not-an-image.txt", []byte("this is plain text, not an image"))
+ rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
+ require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Read-only user is forbidden", func(t *testing.T) {
+ // testuser15 has read-only access to project 35.
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), body, contentType)
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("No access at all is forbidden", func(t *testing.T) {
+ // testuser1 has no access to project 35.
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), body, contentType)
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Unauthenticated", func(t *testing.T) {
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ rec := uploadBackgroundRequest(t, e, "1", "", body, contentType)
+ require.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Renders as multipart in the OpenAPI spec", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var spec map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec))
+
+ paths, _ := spec["paths"].(map[string]any)
+ op, _ := paths["/projects/{project}/backgrounds/upload"].(map[string]any)
+ put, ok := op["put"].(map[string]any)
+ require.True(t, ok, "PUT /projects/{project}/backgrounds/upload must be in the spec")
+ content, _ := put["requestBody"].(map[string]any)
+ contentMap, _ := content["content"].(map[string]any)
+ mp, ok := contentMap["multipart/form-data"].(map[string]any)
+ require.True(t, ok, "background upload must be modeled as multipart/form-data")
+ schema, _ := mp["schema"].(map[string]any)
+ props, _ := schema["properties"].(map[string]any)
+ bgProp, ok := props["background"].(map[string]any)
+ require.True(t, ok, "the background field must appear in the multipart schema")
+ assert.Equal(t, "binary", bgProp["format"], "background field must be a binary file in the spec")
+ })
+}
+
+// TestHumaProjectBackgroundUploadDisabledByConfig verifies the upload route is
+// absent (404) when the upload provider is disabled, even though backgrounds
+// themselves are enabled.
+func TestHumaProjectBackgroundUploadDisabledByConfig(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ config.BackgroundsUploadEnabled.Set(false)
+ defer config.BackgroundsUploadEnabled.Set(true)
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+
+ body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
+ rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
+ assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when background upload is disabled; body: %s", rec.Body.String())
+}
diff --git a/pkg/webtests/huma_bulk_task_test.go b/pkg/webtests/huma_bulk_task_test.go
new file mode 100644
index 000000000..f61141541
--- /dev/null
+++ b/pkg/webtests/huma_bulk_task_test.go
@@ -0,0 +1,74 @@
+// 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 webtests
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestBulkTaskV2 covers PUT /tasks/bulk. It drives the Echo+Huma stack directly
+// (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL only models
+// base[/{id}] paths, not action sub-paths.
+func TestBulkTaskV2(t *testing.T) {
+ t.Run("updates multiple tasks the user can write", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Tasks 1 and 2 both live in project 1, which testuser1 owns.
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk",
+ `{"task_ids":[1,2],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ db.AssertExists(t, "tasks", map[string]interface{}{"id": 1, "title": "bulkupdated"}, false)
+ db.AssertExists(t, "tasks", map[string]interface{}{"id": 2, "title": "bulkupdated"}, false)
+ })
+
+ t.Run("forbidden when missing write on one involved project", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Task 1 is in project 1 (owned), task 32 in project 3 (read-only share).
+ // CanUpdate fans the write check across both projects, so the whole
+ // request is rejected and neither task changes.
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk",
+ `{"task_ids":[1,32],"fields":["title"],"values":{"title":"shouldnothappen"}}`, token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+
+ db.AssertMissing(t, "tasks", map[string]interface{}{"title": "shouldnothappen"})
+ })
+
+ t.Run("empty task_ids is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk",
+ `{"task_ids":[],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "")
+ require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBulkTasksNeedAtLeastOne), "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_caldav_token_test.go b/pkg/webtests/huma_caldav_token_test.go
new file mode 100644
index 000000000..f8e2663ee
--- /dev/null
+++ b/pkg/webtests/huma_caldav_token_test.go
@@ -0,0 +1,165 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaCalDAVToken covers the v2 CalDAV token lifecycle. All calls share one
+// echo env because setupTestEnv rotates the JWT signing key per call, which would
+// 401 a token minted against an earlier env.
+//
+// Fixture (pkg/db/fixtures/user_tokens.yml): token id 6, kind 4 (CalDAV),
+// belongs to user10. user1 starts with no CalDAV tokens.
+func TestHumaCalDAVToken(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ user1Token := humaTokenFor(t, &testuser1)
+ user10Token := humaTokenFor(t, &testuser10)
+
+ t.Run("Create returns the clear-text token", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", user1Token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+
+ var created struct {
+ ID int64 `json:"id"`
+ Token string `json:"token"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created), "body: %s", rec.Body.String())
+ assert.NotZero(t, created.ID)
+ assert.NotEmpty(t, created.Token, "the clear-text token must be returned on create")
+ })
+
+ t.Run("List omits the token value", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ ids := caldavTokenIDsFromList(t, rec.Body.Bytes())
+ assert.NotEmpty(t, ids, "the token created above must show up in the list")
+ assert.Empty(t, caldavTokenValuesFromList(t, rec.Body.Bytes()),
+ "the clear-text token must never appear in the list; body: %s", rec.Body.String())
+ })
+
+ t.Run("List is scoped to the current user", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6),
+ "user10's fixture token #6 must be listed; body: %s", rec.Body.String())
+ })
+
+ t.Run("Delete removes the token", func(t *testing.T) {
+ listRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "")
+ require.Equal(t, http.StatusOK, listRec.Code, "body: %s", listRec.Body.String())
+ ids := caldavTokenIDsFromList(t, listRec.Body.Bytes())
+ require.NotEmpty(t, ids)
+
+ del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/"+strconv.FormatInt(ids[0], 10), "", user1Token, "")
+ require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String())
+
+ afterRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "")
+ require.Equal(t, http.StatusOK, afterRec.Code, "body: %s", afterRec.Body.String())
+ assert.NotContains(t, caldavTokenIDsFromList(t, afterRec.Body.Bytes()), ids[0],
+ "the deleted token must be gone; body: %s", afterRec.Body.String())
+ })
+
+ t.Run("Delete is scoped to the current user", func(t *testing.T) {
+ // Token #6 belongs to user10; user1 deleting it is a no-op (204), not an error.
+ del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", user1Token, "")
+ require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String())
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6),
+ "user10's token #6 must survive a delete attempt by another user; body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaCalDAVToken_LinkShareForbidden ports v1's implicit guard: a link share
+// is not a user, so create / list / delete all refuse it (403).
+func TestHumaCalDAVToken_LinkShareForbidden(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{
+ ID: 1,
+ Hash: "test",
+ ProjectID: 1,
+ Permission: models.PermissionRead,
+ SharingType: models.SharingTypeWithoutPassword,
+ SharedByID: 1,
+ })
+ require.NoError(t, err)
+
+ t.Run("create", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("list", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("delete", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+func caldavTokenIDsFromList(t *testing.T, body []byte) []int64 {
+ t.Helper()
+ items := caldavTokenItemsFromList(t, body)
+ ids := make([]int64, 0, len(items))
+ for _, it := range items {
+ ids = append(ids, it.ID)
+ }
+ return ids
+}
+
+func caldavTokenValuesFromList(t *testing.T, body []byte) []string {
+ t.Helper()
+ values := []string{}
+ for _, it := range caldavTokenItemsFromList(t, body) {
+ if it.Token != "" {
+ values = append(values, it.Token)
+ }
+ }
+ return values
+}
+
+func caldavTokenItemsFromList(t *testing.T, body []byte) []struct {
+ ID int64 `json:"id"`
+ Token string `json:"token"`
+} {
+ t.Helper()
+ var resp struct {
+ Items []struct {
+ ID int64 `json:"id"`
+ Token string `json:"token"`
+ } `json:"items"`
+ }
+ require.NoError(t, json.Unmarshal(body, &resp), "list body must be a paginated envelope: %s", string(body))
+ return resp.Items
+}
diff --git a/pkg/webtests/huma_info_test.go b/pkg/webtests/huma_info_test.go
new file mode 100644
index 000000000..d9f2684a9
--- /dev/null
+++ b/pkg/webtests/huma_info_test.go
@@ -0,0 +1,47 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaInfo covers the public instance-info endpoint. It needs no auth and
+// always reports the running version.
+func TestHumaInfo(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/info", "", "", "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
+ assert.Contains(t, body, "version")
+ assert.Contains(t, body, "auth")
+ assert.Contains(t, body, "available_migrators")
+
+ require.Contains(t, body, "concurrent_writes")
+ assert.Equal(t, config.DatabaseType.GetString() != "sqlite", body["concurrent_writes"])
+}
diff --git a/pkg/webtests/huma_label_task_bulk_test.go b/pkg/webtests/huma_label_task_bulk_test.go
new file mode 100644
index 000000000..3ee42e45f
--- /dev/null
+++ b/pkg/webtests/huma_label_task_bulk_test.go
@@ -0,0 +1,117 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestLabelTaskBulk_V2 ports the v1 bulk-replace matrix
+// (pkg/webtests/label_task_test.go) onto PUT /api/v2/tasks/{projecttask}/labels/bulk.
+// The body is the full target label set; the call adds missing labels and
+// removes any not listed.
+//
+// Permission topology for testuser1 (see pkg/db/fixtures):
+// - task 1 (project 1): owned by user1 → write. Has label #4 attached.
+// - task 15 (project 6): shared via team 2 read-only → no write.
+// - task 16 (project 7): shared via team 3 with write.
+// - task 34 (project 20): private to user13 → no access.
+//
+// Labels: #1 own; #3 (user2, attached to no visible task) is invisible to user1.
+func TestLabelTaskBulk_V2(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ put := func(taskID, body string) (*v2ProblemJSON, []int64, int) {
+ t.Helper()
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/"+taskID+"/labels/bulk", body, token, "")
+ if rec.Code >= 400 {
+ var p v2ProblemJSON
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p), "error body: %s", rec.Body.String())
+ return &p, nil, rec.Code
+ }
+ var resp struct {
+ Labels []struct {
+ ID int64 `json:"id"`
+ } `json:"labels"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "body: %s", rec.Body.String())
+ ids := make([]int64, 0, len(resp.Labels))
+ for _, l := range resp.Labels {
+ ids = append(ids, l.ID)
+ }
+ return nil, ids, rec.Code
+ }
+
+ t.Run("Replace adds and removes", func(t *testing.T) {
+ // task 1 starts with label #4; replacing with [#1] must add #1 and drop #4.
+ p, ids, code := put("1", `{"labels":[{"id":1}]}`)
+ require.Nil(t, p)
+ assert.Equal(t, http.StatusOK, code)
+ assert.ElementsMatch(t, []int64{1}, ids,
+ "task 1's labels must be exactly {1} after replace")
+ })
+ t.Run("Empty list clears all labels", func(t *testing.T) {
+ // task 16 (write-shared) gets a label, then an empty replace removes it.
+ _, ids, code := put("16", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusOK, code)
+ assert.ElementsMatch(t, []int64{1}, ids)
+
+ p, ids, code := put("16", `{"labels":[]}`)
+ require.Nil(t, p)
+ assert.Equal(t, http.StatusOK, code)
+ assert.Empty(t, ids, "empty replace must remove every label")
+ })
+ t.Run("Write share can replace", func(t *testing.T) {
+ _, ids, code := put("16", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusOK, code)
+ assert.ElementsMatch(t, []int64{1}, ids)
+ })
+ t.Run("Read-only share is forbidden", func(t *testing.T) {
+ p, _, code := put("15", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusForbidden, code)
+ require.NotNil(t, p)
+ })
+ t.Run("Forbidden task", func(t *testing.T) {
+ // task 34 is private to user13.
+ p, _, code := put("34", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusForbidden, code)
+ require.NotNil(t, p)
+ })
+ t.Run("Nonexisting task", func(t *testing.T) {
+ p, _, code := put("9999", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusNotFound, code)
+ require.NotNil(t, p)
+ assert.Equal(t, models.ErrCodeTaskDoesNotExist, p.Code)
+ })
+ t.Run("Label the user cannot see is rejected", func(t *testing.T) {
+ // label #3 (user2's, attached to no task user1 can see) is invisible to
+ // user1; attaching it to a writable task must be refused.
+ p, _, code := put("1", `{"labels":[{"id":3}]}`)
+ assert.Equal(t, http.StatusForbidden, code)
+ require.NotNil(t, p)
+ assert.Equal(t, models.ErrCodeUserHasNoAccessToLabel, p.Code)
+ })
+}
diff --git a/pkg/webtests/huma_label_test.go b/pkg/webtests/huma_label_test.go
index 02b314dc5..eb59ea675 100644
--- a/pkg/webtests/huma_label_test.go
+++ b/pkg/webtests/huma_label_test.go
@@ -24,10 +24,17 @@ import (
"strings"
"testing"
+ "code.vikunja.io/api/pkg/user"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+// testuser22 is the second bot owner from pkg/db/fixtures/users.yml; user22
+// owns bot 24. Paired with testuser21 to assert bot-owner isolation: each
+// owner sees and acts on their own bots' resources, never the other's.
+var testuser22 = user.User{ID: 22, Username: "user_bot_owner_b", Issuer: "local"}
+
// TestHumaLabel mirrors v1's TestProject shape so v2 contract parity is
// readable side-by-side. Labels has no v1 webtest; coverage is ported 1:1
// from the model-level matrix in pkg/models/label_test.go so the v2 HTTP
@@ -228,6 +235,65 @@ func TestHumaLabel(t *testing.T) {
})
}
+// TestHumaLabel_BotOwner asserts that bot owners can read, update, and delete
+// labels that were created by bots they own. Fixture label #9 is owned by
+// bot 23, whose owner is user 21 (testuser21); user 22 owns a different bot
+// and must not see or touch it.
+func TestHumaLabel_BotOwner(t *testing.T) {
+ botOwner := webHandlerTestV2{
+ user: &testuser21,
+ basePath: "/api/v2/labels",
+ idParam: "label",
+ t: t,
+ }
+ require.NoError(t, botOwner.ensureEnv())
+ otherOwner := webHandlerTestV2{
+ user: &testuser22,
+ basePath: "/api/v2/labels",
+ idParam: "label",
+ t: t,
+ e: botOwner.e,
+ }
+
+ t.Run("ReadOne - bot owner can read label created by their bot", func(t *testing.T) {
+ rec, err := botOwner.testReadOneWithUser(nil, map[string]string{"label": "9"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"Label #9 - created by bot 23 owned by user 21"`)
+ })
+ t.Run("ReadOne - non-owner cannot read another owner's bot's label", func(t *testing.T) {
+ _, err := otherOwner.testReadOneWithUser(nil, map[string]string{"label": "9"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("ReadAll - bot owner's listing surfaces their bot's labels", func(t *testing.T) {
+ rec, err := botOwner.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ ids := labelIDsFromReadAll(t, rec.Body.Bytes())
+ assert.Contains(t, ids, int64(9), "label #9 (created by user 21's bot) must be listed")
+ })
+ t.Run("Update - bot owner can update label created by their bot", func(t *testing.T) {
+ rec, err := botOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"renamed by owner"}`)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"renamed by owner"`)
+ })
+ t.Run("Update - non-owner cannot update another owner's bot's label", func(t *testing.T) {
+ _, err := otherOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"hijack"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Delete - non-owner cannot delete another owner's bot's label", func(t *testing.T) {
+ _, err := otherOwner.testDeleteWithUser(nil, map[string]string{"label": "9"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Delete - bot owner can delete label created by their bot", func(t *testing.T) {
+ // Run last so the earlier subtests still have label #9 to operate on.
+ rec, err := botOwner.testDeleteWithUser(nil, map[string]string{"label": "9"})
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ })
+}
+
// labelIDsFromReadAll extracts the label IDs from a v2 paginated list body so
// the visible set can be asserted exactly rather than via substring matching.
func labelIDsFromReadAll(t *testing.T, body []byte) []int64 {
diff --git a/pkg/webtests/huma_migration_csv_test.go b/pkg/webtests/huma_migration_csv_test.go
new file mode 100644
index 000000000..ff269f46e
--- /dev/null
+++ b/pkg/webtests/huma_migration_csv_test.go
@@ -0,0 +1,125 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const csvTestFile = `Title,Description,Done,Priority
+Task 1,Description 1,true,high
+Task 2,Description 2,false,low`
+
+const csvTestConfig = `{"delimiter":",","quote_char":"\"","date_format":"2006-01-02","mapping":[` +
+ `{"column_index":0,"column_name":"Title","attribute":"title"},` +
+ `{"column_index":1,"column_name":"Description","attribute":"description"},` +
+ `{"column_index":2,"column_name":"Done","attribute":"done"},` +
+ `{"column_index":3,"column_name":"Priority","attribute":"priority"}]}`
+
+// TestHumaMigrationCSV covers the generic CSV importer's v2 endpoints:
+// status, detect, preview and migrate. No v1 webtest exists to mirror.
+func TestHumaMigrationCSV(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("status - never migrated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String())
+ })
+
+ t.Run("detect returns columns and a suggested mapping", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil)
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, token)
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"columns"`)
+ assert.Contains(t, rec.Body.String(), `"suggested_mapping"`)
+ assert.Contains(t, rec.Body.String(), "Title")
+ })
+
+ t.Run("preview returns tasks without importing", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig})
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/preview", body, contentType, token)
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"tasks"`)
+ assert.Contains(t, rec.Body.String(), "Task 1")
+ })
+
+ t.Run("migrate imports the file", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig})
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token)
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"Everything was migrated successfully."`)
+
+ // The status now reflects a finished migration.
+ rec = humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.NotContains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`,
+ "after migrating, the status must carry a real started_at; body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaMigrationCSV_BadInput covers the negative paths: missing config,
+// malformed config JSON, and an empty file.
+func TestHumaMigrationCSV_BadInput(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("missing config is rejected with 422", func(t *testing.T) {
+ // The config form value is required:"true", so Huma's multipart
+ // validation refuses the request before the handler runs.
+ body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil)
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token)
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("malformed config JSON is rejected with 400", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": "{not json"})
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token)
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("empty file is rejected with a domain error", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "empty.csv", []byte{}, map[string]string{"config": csvTestConfig})
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token)
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaMigrationCSV_Unauthenticated proves all CSV ops require auth.
+func TestHumaMigrationCSV_Unauthenticated(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+
+ t.Run("status", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("detect", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil)
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("migrate", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig})
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_migration_file_test.go b/pkg/webtests/huma_migration_file_test.go
new file mode 100644
index 000000000..9430127aa
--- /dev/null
+++ b/pkg/webtests/huma_migration_file_test.go
@@ -0,0 +1,128 @@
+// 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 webtests
+
+import (
+ "bytes"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// multipartImportBody builds a multipart/form-data body with the file under the
+// "import" field plus any extra string form values (e.g. the CSV "config"),
+// matching the v2 file/CSV migrator form schemas.
+func multipartImportBody(t *testing.T, filename string, content []byte, values map[string]string) (*bytes.Buffer, string) {
+ t.Helper()
+ buf := &bytes.Buffer{}
+ w := multipart.NewWriter(buf)
+ fw, err := w.CreateFormFile("import", filename)
+ require.NoError(t, err)
+ _, err = fw.Write(content)
+ require.NoError(t, err)
+ for k, v := range values {
+ require.NoError(t, w.WriteField(k, v))
+ }
+ require.NoError(t, w.Close())
+ return buf, w.FormDataContentType()
+}
+
+func migrationUploadRequest(t *testing.T, e *echo.Echo, path string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, body)
+ req.Header.Set("Content-Type", contentType)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// TestHumaMigrationFile covers the always-registered file migrators
+// (vikunja-file, ticktick, wekan) status + migrate endpoints. There is no v1
+// webtest for these handlers to mirror, so this is the parity baseline.
+func TestHumaMigrationFile(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ // payload is shaped per migrator to hit a *domain* rejection (4xx) rather
+ // than a raw parse error: a wekan board with no title/cards is "empty", a
+ // ticktick CSV with no data rows is "empty", and a vikunja-file that isn't
+ // a zip is rejected as such. (Syntactically-malformed input would surface a
+ // raw json/zip error that maps to 500 in both v1 and v2 alike.)
+ migrators := map[string][]byte{
+ "vikunja-file": []byte("not a zip archive"),
+ "ticktick": []byte("Title,Content\n"),
+ "wekan": []byte(`{"title":"","cards":[]}`),
+ }
+
+ for name, payload := range migrators {
+ t.Run(name+" status - never migrated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ // A user who never migrated has a zero-value status.
+ assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String())
+ })
+
+ t.Run(name+" migrate maps a rejected file to a 4xx domain error", func(t *testing.T) {
+ // Drives the request through the multipart binding and into the
+ // migrator, which rejects it with a domain error that
+ // translateDomainError turns into a 4xx — proving the v2 plumbing
+ // (bind, run, error bridge) is wired, not the parsing itself.
+ body, contentType := multipartImportBody(t, "bad."+name, payload, nil)
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/"+name+"/migrate", body, contentType, token)
+ assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest, "body: %s", rec.Body.String())
+ assert.Less(t, rec.Code, http.StatusInternalServerError,
+ "a rejected upload must map to a 4xx domain error, not a 500; body: %s", rec.Body.String())
+ })
+ }
+}
+
+// TestHumaMigrationFile_Unauthenticated proves the file migrator ops require auth.
+func TestHumaMigrationFile_Unauthenticated(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+
+ t.Run("status", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/ticktick/status", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("migrate", func(t *testing.T) {
+ body, contentType := multipartImportBody(t, "x.csv", []byte("x"), nil)
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", body, contentType, "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaMigrationFile_MissingFile proves the required "import" form field is
+// enforced by Huma's multipart validation (422), not a 500.
+func TestHumaMigrationFile_MissingFile(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ buf := &bytes.Buffer{}
+ w := multipart.NewWriter(buf)
+ require.NoError(t, w.Close())
+
+ rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", buf, w.FormDataContentType(), token)
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+}
diff --git a/pkg/webtests/huma_migration_oauth_test.go b/pkg/webtests/huma_migration_oauth_test.go
new file mode 100644
index 000000000..7d15c576a
--- /dev/null
+++ b/pkg/webtests/huma_migration_oauth_test.go
@@ -0,0 +1,153 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/modules/migration"
+ migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
+ "code.vikunja.io/api/pkg/routes"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// setupMigrationTestEnv builds a test env with the OAuth migrators enabled so
+// their v2 routes are registered (they are gated behind config flags that
+// default to false). setupTestEnv resets config to defaults, so the flags must
+// be set after it and the router rebuilt.
+func setupMigrationTestEnv(t *testing.T) *echo.Echo {
+ t.Helper()
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ // migration.Status is not part of models.GetTables() (pkg/models cannot
+ // import pkg/modules/migration without a cycle), so SetupTests never syncs
+ // migration_status. Create it here so the status/migrate handlers can query.
+ s := db.NewSession()
+ require.NoError(t, s.Sync2(&migration.Status{}))
+ require.NoError(t, s.Commit())
+ require.NoError(t, s.Close())
+
+ config.MigrationTodoistEnable.Set(true)
+ config.MigrationTrelloEnable.Set(true)
+ config.MigrationMicrosoftTodoEnable.Set(true)
+ t.Cleanup(func() {
+ config.MigrationTodoistEnable.Set(false)
+ config.MigrationTrelloEnable.Set(false)
+ config.MigrationMicrosoftTodoEnable.Set(false)
+ })
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+ return e
+}
+
+// TestHumaMigrationOAuth covers the three OAuth migrators' v2 endpoints. There
+// is no v1 webtest for these handlers to mirror, so this is the parity baseline.
+func TestHumaMigrationOAuth(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ // The generic registration helper wires the same three ops for every
+ // migrator, so exercising each name guards against a copy-paste regression.
+ for _, name := range []string{"todoist", "trello", "microsoft-todo"} {
+ t.Run(name+" auth url", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/auth", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"url":"http`, "auth url must be returned; body: %s", rec.Body.String())
+ })
+
+ t.Run(name+" status - never migrated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ // A user who never migrated has a zero-value status.
+ assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String())
+ })
+ }
+
+ t.Run("migrate kicks off the migration", func(t *testing.T) {
+ events.ClearDispatchedEvents()
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "")
+ // 200, not the wrapper's POST default 201: this queues a job, it does
+ // not create a REST resource.
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"Migration was started successfully."`)
+ events.AssertDispatched(t, &migrationHandler.MigrationRequestedEvent{})
+ })
+}
+
+// TestHumaMigrationOAuth_AlreadyRunning ports v1's guard: starting a migration
+// while one is already in progress (started, not finished) is refused with 412.
+func TestHumaMigrationOAuth_AlreadyRunning(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+ token := humaTokenFor(t, &testuser1)
+
+ s := db.NewSession()
+ _, err := s.Insert(&migration.Status{
+ UserID: testuser1.ID,
+ MigratorName: "todoist",
+ StartedAt: time.Now(),
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+ _ = s.Close()
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "")
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+}
+
+// TestHumaMigrationOAuth_Unauthenticated proves all three ops require auth.
+func TestHumaMigrationOAuth_Unauthenticated(t *testing.T) {
+ e := setupMigrationTestEnv(t)
+
+ t.Run("auth", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("status", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/status", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("migrate", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"x"}`, "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaMigrationOAuth_Disabled proves a migrator's routes are absent when its
+// config flag is off.
+func TestHumaMigrationOAuth_Disabled(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+ // All migration flags default to false after InitDefaultConfig.
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code,
+ "migration routes must not be registered when the flag is off; body: %s", rec.Body.String())
+}
diff --git a/pkg/webtests/huma_non_crud_aliases_test.go b/pkg/webtests/huma_non_crud_aliases_test.go
new file mode 100644
index 000000000..1377507cd
--- /dev/null
+++ b/pkg/webtests/huma_non_crud_aliases_test.go
@@ -0,0 +1,151 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// feedsTokenUser13 is a feeds-scoped API token for user 13 (see the feeds
+// fixtures); it authenticates the v2 notifications Atom feed via HTTP Basic.
+const feedsTokenUser13 = "tk_feeds_access_token_user_0013_feed0013"
+
+// TestHumaNonCRUDAliases covers the three non-REST endpoints mounted under
+// /api/v2. Health and the Atom feed are Huma operations (so they appear in the
+// OpenAPI spec); the WebSocket upgrade stays a raw echo route (OpenAPI can't
+// model WebSockets). Each authenticates itself, so the group's JWT middleware
+// must let them through.
+func TestHumaNonCRUDAliases(t *testing.T) {
+ t.Run("health is public and returns OK", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/health", "", "", "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "OK")
+ })
+
+ t.Run("ws is reachable without a JWT", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ // A plain GET without the upgrade headers makes websocket.Accept reject
+ // the request (typically 400). The point is that it reaches the handler
+ // at all — not a 401 from the JWT middleware nor a 404 for an unmounted
+ // route.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/ws", "", "", "")
+ assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "ws must not be blocked by v2 JWT auth")
+ assert.NotEqual(t, http.StatusNotFound, rec.Code, "ws must be mounted under /api/v2")
+ })
+
+ t.Run("atom feed is basic-auth-gated, not JWT-gated", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("without credentials returns a basic-auth challenge", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ // The JWT middleware skips this path, so the handler's own HTTP Basic
+ // auth gates it instead: a 401 carrying a Basic challenge, not the JWT
+ // middleware's JSON error.
+ require.Equal(t, http.StatusUnauthorized, rec.Code)
+ assert.Contains(t, strings.ToLower(rec.Header().Get(echo.HeaderWWWAuthenticate)), "basic",
+ "expected a Basic auth challenge, got %q", rec.Header().Get(echo.HeaderWWWAuthenticate))
+ })
+
+ t.Run("with a feeds API token returns an atom feed", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil)
+ req.SetBasicAuth("user13", feedsTokenUser13)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.True(t, strings.HasPrefix(rec.Header().Get(echo.HeaderContentType), "application/atom+xml"),
+ "expected atom content type, got %q", rec.Header().Get(echo.HeaderContentType))
+ assert.Contains(t, rec.Body.String(), ".
+
+package webtests
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/files"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestProjectDuplicateV2 covers POST /projects/{projectid}/duplicate. It drives
+// the Echo+Huma stack directly (humaRequest/humaTokenFor) because
+// webHandlerTestV2's buildURL only models base[/{id}] paths, not action sub-paths.
+func TestProjectDuplicateV2(t *testing.T) {
+ t.Run("duplicates an accessible project to the top level", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // Duplicating copies the source project's task attachments, so the
+ // referenced fixture file must exist in the (memory) file store.
+ files.InitTestFileFixtures(t)
+ token := humaTokenFor(t, &testuser1)
+
+ // Project 1 is owned by testuser1.
+ const sourceProjectID int64 = 1
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"duplicated_project"`)
+
+ var resp struct {
+ DuplicatedProject struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ ParentProjectID int64 `json:"parent_project_id"`
+ } `json:"duplicated_project"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.NotZero(t, resp.DuplicatedProject.ID, "duplicated project should have an id")
+ assert.NotEqual(t, sourceProjectID, resp.DuplicatedProject.ID, "duplicated project must have a new id, not the source project's")
+ assert.Contains(t, resp.DuplicatedProject.Title, "duplicate")
+ assert.Zero(t, resp.DuplicatedProject.ParentProjectID, "top-level duplicate must have no parent")
+ })
+
+ t.Run("places the duplicate under parent_project_id from the body", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ files.InitTestFileFixtures(t)
+ token := humaTokenFor(t, &testuser1)
+
+ // testuser1 owns project 1, so it may both read the source and create
+ // the copy underneath it.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{"parent_project_id":1}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+
+ var resp struct {
+ DuplicatedProject struct {
+ ID int64 `json:"id"`
+ ParentProjectID int64 `json:"parent_project_id"`
+ } `json:"duplicated_project"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.NotZero(t, resp.DuplicatedProject.ID)
+ assert.Equal(t, int64(1), resp.DuplicatedProject.ParentProjectID, "duplicate must land under the requested parent")
+ })
+
+ t.Run("nonexistent source project", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/99999/duplicate", `{}`, token, "")
+ // CanCreate loads the source via CanRead, which surfaces
+ // ErrProjectDoesNotExist (404) for a missing project rather than a 403.
+ require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist), "body must surface ErrCodeProjectDoesNotExist; body: %s", rec.Body.String())
+ })
+
+ t.Run("no read on source project is forbidden", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // testuser15 cannot read project 1 (owned by testuser1, no share).
+ token := humaTokenFor(t, &testuser15)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_reaction_test.go b/pkg/webtests/huma_reaction_test.go
new file mode 100644
index 000000000..56eac82e5
--- /dev/null
+++ b/pkg/webtests/huma_reaction_test.go
@@ -0,0 +1,103 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// reactionMapFromBody decodes the v2 reactions list body — a map keyed by
+// reaction value, each value the list of users who reacted with it.
+func reactionMapFromBody(t *testing.T, body []byte) map[string][]struct {
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+} {
+ t.Helper()
+ var m map[string][]struct {
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ }
+ require.NoError(t, json.Unmarshal(body, &m), "list body must be a reaction map: %s", string(body))
+ return m
+}
+
+// TestHumaReaction exercises the v2 reaction surface, mirroring the v1
+// model-level matrix in pkg/models/reaction_test.go. Fixture reactions.yml
+// seeds reaction #1: user1 reacted "👋" on task #1.
+func TestHumaReaction(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("List returns the map with the reacting user", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ m := reactionMapFromBody(t, rec.Body.Bytes())
+ require.Len(t, m["👋"], 1, "fixture reaction must be present; body: %s", rec.Body.String())
+ assert.Equal(t, int64(1), m["👋"][0].ID, "the reacting user is user1")
+ })
+
+ t.Run("Create then list reflects the new reaction", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions", `{"value":"🦙"}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"value":"🦙"`)
+
+ rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ m := reactionMapFromBody(t, rec.Body.Bytes())
+ require.Len(t, m["🦙"], 1, "created reaction must appear in the list; body: %s", rec.Body.String())
+ assert.Equal(t, int64(1), m["🦙"][0].ID)
+ })
+
+ t.Run("Delete removes the reaction", func(t *testing.T) {
+ // Remove the fixture reaction (user1's "👋" on task #1) and confirm via a follow-up list.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions/delete", `{"value":"👋"}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "delete is POST-with-body returning 200; body: %s", rec.Body.String())
+
+ rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ m := reactionMapFromBody(t, rec.Body.Bytes())
+ assert.NotContains(t, m, "👋", "deleted reaction must be gone; body: %s", rec.Body.String())
+ })
+
+ t.Run("Invalid entitykind is rejected", func(t *testing.T) {
+ // The enum tag on the path param makes Huma reject unknown kinds before the handler runs.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/loremipsum/1/reactions", "", token, "")
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Forbidden - no access to the entity", func(t *testing.T) {
+ // Task #34 lives in a private project user1 cannot see.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/reactions", "", token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Nonexistent entity", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/9999999/reactions", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Create forbidden - no access to the entity", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/34/reactions", `{"value":"🦙"}`, token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_task_assignee_bulk_test.go b/pkg/webtests/huma_task_assignee_bulk_test.go
new file mode 100644
index 000000000..0a54de02f
--- /dev/null
+++ b/pkg/webtests/huma_task_assignee_bulk_test.go
@@ -0,0 +1,125 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaTaskAssigneeBulk proves the v2 bulk-assignee replace contract:
+// PUT /tasks/{projecttask}/assignees/bulk swaps the task's full assignee set
+// for the posted list. Like the single-assignee test it gates on write access
+// to the task's project (CanCreate → canDoTaskAssingee → project.CanUpdate).
+//
+// Fixture topology (pkg/db/fixtures/task_assignees.yml, tasks.yml, projects.yml,
+// users_projects.yml):
+// - task 30 (project 1, owned by user1): assignees user1 (#1) and user2 (#2).
+// user2 is a fixture row only; user2 has NO access to project 1, so it can
+// be removed but never freshly added — replace cases here only remove it.
+// - tasks 16/19 (shared to user1 with write): user1 has project access, so
+// it is a valid assignee there — used for the add-from-empty case.
+// - tasks 15/18: shared read-only — write is forbidden.
+// - task 34 (project 20, user13): user1 has no access at all.
+func TestHumaTaskAssigneeBulk(t *testing.T) {
+ // One Echo env shared across users; setupTestEnv rotates the JWT secret per
+ // call, so a second env would 401 tokens minted against the first.
+ base := &webHandlerTestV2{user: &testuser1, t: t}
+ require.NoError(t, base.ensureEnv())
+
+ bulkPut := func(taskID string, u *user.User, payload string) (ids []int64, err error) {
+ h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees/bulk", t: t, e: base.e}
+ rec, err := h.serve(http.MethodPut, h.basePath, payload)
+ if err != nil {
+ return nil, err
+ }
+ // PUT defaults to 200 from the Register wrapper for a non-create verb.
+ assert.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ return assigneeIDsFromReadAll(t, rec.Body.Bytes()), nil
+ }
+ // readAssignees fetches the current assignee set so a replace is verified
+ // against persisted state, not just the response echo.
+ readAssignees := func(taskID string, u *user.User) []int64 {
+ h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees", idParam: "user", t: t, e: base.e}
+ rec, err := h.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ return assigneeIDsFromReadAll(t, rec.Body.Bytes())
+ }
+
+ t.Run("Replace removes assignees not in the list", func(t *testing.T) {
+ // task 30 starts as {1,2}; replacing with {1} must drop user2.
+ require.ElementsMatch(t, []int64{1, 2}, readAssignees("30", &testuser1))
+ _, err := bulkPut("30", &testuser1, `{"assignees":[{"id":1}]}`)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []int64{1}, readAssignees("30", &testuser1),
+ "user2 must be unassigned after the replace")
+ })
+
+ t.Run("Empty list unassigns everyone", func(t *testing.T) {
+ // task 30 now holds {1}; an empty array clears it entirely.
+ _, err := bulkPut("30", &testuser1, `{"assignees":[]}`)
+ require.NoError(t, err)
+ assert.Empty(t, readAssignees("30", &testuser1),
+ "an empty assignees array must remove all assignees")
+ })
+
+ t.Run("Replace adds new assignees", func(t *testing.T) {
+ // task 16 is shared to user1 with write access and starts with no
+ // assignees; user1 has project access, so it is a valid new assignee.
+ require.Empty(t, readAssignees("16", &testuser1))
+ _, err := bulkPut("16", &testuser1, `{"assignees":[{"id":1}]}`)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []int64{1}, readAssignees("16", &testuser1),
+ "user1 must be assigned after the replace")
+ })
+
+ t.Run("Forbidden - read-only share", func(t *testing.T) {
+ // task 18 is shared to user1 read-only; bulk replace needs write.
+ _, err := bulkPut("18", &testuser1, `{"assignees":[{"id":1}]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+
+ t.Run("Forbidden - no access at all", func(t *testing.T) {
+ // task 34 belongs to user13's private project 20.
+ _, err := bulkPut("34", &testuser1, `{"assignees":[{"id":1}]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+
+ t.Run("Forbidden - user without project access", func(t *testing.T) {
+ // user6 has no access to project 1, so it cannot write task 1.
+ _, err := bulkPut("1", &testuser6, `{"assignees":[{"id":6}]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+
+ t.Run("Nonexisting task", func(t *testing.T) {
+ // The write check resolves the project from the task, so a missing task
+ // surfaces project-does-not-exist as a 404.
+ _, err := bulkPut("99999", &testuser1, `{"assignees":[{"id":1}]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist)
+ })
+}
diff --git a/pkg/webtests/huma_task_attachment_test.go b/pkg/webtests/huma_task_attachment_test.go
new file mode 100644
index 000000000..74d4ea0ed
--- /dev/null
+++ b/pkg/webtests/huma_task_attachment_test.go
@@ -0,0 +1,258 @@
+// 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 webtests
+
+import (
+ "bytes"
+ "encoding/json"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/routes"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// multipartFilesBody builds a multipart/form-data body with one or more files
+// under the "files" field, matching the v2 upload handler's form schema.
+func multipartFilesBody(t *testing.T, files map[string][]byte) (*bytes.Buffer, string) {
+ t.Helper()
+ buf := &bytes.Buffer{}
+ w := multipart.NewWriter(buf)
+ for filename, content := range files {
+ fw, err := w.CreateFormFile("files", filename)
+ require.NoError(t, err)
+ _, err = fw.Write(content)
+ require.NoError(t, err)
+ }
+ require.NoError(t, w.Close())
+ return buf, w.FormDataContentType()
+}
+
+func uploadAttachmentRequest(t *testing.T, e *echo.Echo, taskID string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/api/v2/tasks/"+taskID+"/attachments", body)
+ req.Header.Set("Content-Type", contentType)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// uploadOneAttachment uploads a single file to task 1 and returns the created
+// attachment id, so download/delete tests have a real file in storage to act on
+// (setupTestEnv resets the mem storage, so fixture files have no bytes).
+func uploadOneAttachment(t *testing.T, e *echo.Echo, token, filename string, content []byte) int64 {
+ t.Helper()
+ body, contentType := multipartFilesBody(t, map[string][]byte{filename: content})
+ rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+
+ var resp struct {
+ Body struct {
+ Success []*models.TaskAttachment `json:"success"`
+ Errors []struct {
+ Message string `json:"message"`
+ } `json:"errors"`
+ }
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp.Body))
+ require.Empty(t, resp.Body.Errors, "upload reported per-file errors: %+v", resp.Body.Errors)
+ require.Len(t, resp.Body.Success, 1)
+ require.NotZero(t, resp.Body.Success[0].ID)
+ return resp.Body.Success[0].ID
+}
+
+func TestTaskAttachmentsV2(t *testing.T) {
+ t.Run("Upload single file", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ body, contentType := multipartFilesBody(t, map[string][]byte{"hello.txt": []byte("hello world")})
+ rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "hello.txt")
+ assert.Contains(t, rec.Body.String(), `"success"`)
+ })
+
+ t.Run("Upload multiple files", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ body, contentType := multipartFilesBody(t, map[string][]byte{
+ "one.txt": []byte("first file"),
+ "two.txt": []byte("second file"),
+ })
+ rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+
+ var resp struct {
+ Success []*models.TaskAttachment `json:"success"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.Len(t, resp.Success, 2)
+ })
+
+ t.Run("List", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Upload first so there is at least one attachment with a real file row.
+ uploadOneAttachment(t, e, token, "listed.txt", []byte("listed content"))
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ var resp struct {
+ Items []*models.TaskAttachment `json:"items"`
+ Total int64 `json:"total"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.NotEmpty(t, resp.Items)
+ assert.Positive(t, resp.Total)
+ })
+
+ t.Run("Download returns bytes and content type", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ content := []byte("downloadable content")
+ id := uploadOneAttachment(t, e, token, "download.txt", content)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Equal(t, content, rec.Body.Bytes(), "the streamed file bytes must match the original")
+ assert.NotEmpty(t, rec.Header().Get("Content-Type"))
+ assert.Contains(t, rec.Header().Get("Content-Disposition"), "download.txt")
+ // Caching headers mirror v1: a concrete length and a cacheable directive.
+ assert.Equal(t, strconv.Itoa(len(content)), rec.Header().Get("Content-Length"))
+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
+ assert.NotEmpty(t, rec.Header().Get("Last-Modified"))
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ id := uploadOneAttachment(t, e, token, "todelete.txt", []byte("bye"))
+
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
+ require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
+
+ // The download must now 404.
+ rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Upload forbidden on inaccessible task", func(t *testing.T) {
+ // Task 34 is owned by user 13 and inaccessible to testuser1 (see the v1 IDOR test).
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ body, contentType := multipartFilesBody(t, map[string][]byte{"nope.txt": []byte("nope")})
+ rec := uploadAttachmentRequest(t, e, "34", body, contentType, token)
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("List forbidden on inaccessible task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/attachments", "", token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Download nonexistent attachment", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/99999", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Cannot download attachment that does not belong to the task in the path", func(t *testing.T) {
+ // Mirrors the v1 IDOR test: attachment 4 belongs to task 34, not task 1.
+ // Requesting it under task 1 (accessible) must 404, not leak the file.
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/4", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Unauthenticated upload is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ body, contentType := multipartFilesBody(t, map[string][]byte{"x.txt": []byte("x")})
+ rec := uploadAttachmentRequest(t, e, "1", body, contentType, "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestTaskAttachmentsV2_PreviewSize covers the preview_size query param: a non-image
+// attachment ignores it and returns the original bytes (the v1 behaviour).
+func TestTaskAttachmentsV2_PreviewSize(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ content := []byte("not an image, just text")
+ id := uploadOneAttachment(t, e, token, "notimage.txt", content)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10)+"?preview_size=md", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Equal(t, content, rec.Body.Bytes(), "preview_size on a non-image must return the original file")
+}
+
+// TestTaskAttachmentsV2_Disabled proves the resource is absent when the
+// service.enabletaskattachments config flag is off.
+func TestTaskAttachmentsV2_Disabled(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ oldValue := config.ServiceEnableTaskAttachments.GetBool()
+ config.ServiceEnableTaskAttachments.Set(false)
+ defer config.ServiceEnableTaskAttachments.Set(oldValue)
+
+ // Rebuild the router so RegisterAll re-evaluates the (now disabled) flag.
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code,
+ "attachment routes must not be registered when the flag is off; body: %s", rec.Body.String())
+}
diff --git a/pkg/webtests/huma_task_bucket_test.go b/pkg/webtests/huma_task_bucket_test.go
new file mode 100644
index 000000000..e1623bf67
--- /dev/null
+++ b/pkg/webtests/huma_task_bucket_test.go
@@ -0,0 +1,127 @@
+// 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 webtests
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTaskBucketV2 covers PUT /projects/{project}/views/{view}/buckets/{bucket}/tasks.
+// It drives the Echo+Huma stack directly (humaRequest/humaTokenFor) because the
+// route is an action sub-path webHandlerTestV2's buildURL doesn't model. Fixtures
+// (project 1, view 4): bucket 1 default, bucket 2 "Doing" limit 3 (full), bucket 3 done.
+func TestTaskBucketV2(t *testing.T) {
+ const path = "/api/v2/projects/1/views/4/buckets/%d/tasks"
+
+ t.Run("moves a task into a bucket", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Task 3 starts in bucket 2; move it into bucket 1 (neither full nor done).
+ rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":3}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"task_id":3`)
+
+ db.AssertExists(t, "task_buckets", map[string]interface{}{
+ "task_id": 3,
+ "bucket_id": 1,
+ }, false)
+ })
+
+ t.Run("moving a task into the done bucket marks it done", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Bucket 3 is the done bucket on view 4; task 1 is not yet done.
+ rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 3), `{"task_id":1}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"done":true`)
+
+ db.AssertExists(t, "tasks", map[string]interface{}{
+ "id": 1,
+ "done": true,
+ }, false)
+ db.AssertExists(t, "task_buckets", map[string]interface{}{
+ "task_id": 1,
+ "bucket_id": 3,
+ }, false)
+ })
+
+ t.Run("moving a task out of the done bucket un-marks it done", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Task 2 starts in bucket 3 (done) and is done; move it to bucket 1.
+ rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":2}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"done":false`)
+
+ db.AssertExists(t, "tasks", map[string]interface{}{
+ "id": 2,
+ "done": false,
+ }, false)
+ db.AssertExists(t, "task_buckets", map[string]interface{}{
+ "task_id": 2,
+ "bucket_id": 1,
+ }, false)
+ })
+
+ t.Run("full bucket is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Bucket 2 already holds 3 tasks and has a limit of 3.
+ rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 2), `{"task_id":1}`, token, "")
+ require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBucketLimitExceeded))
+ })
+
+ t.Run("bucket on another view is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Bucket 4 lives on view 8 (project 2), so under view 4 / project 1 the
+ // permission check resolves the bucket's own view scoped by the path
+ // project and finds none → 404 before the move's own 400 can fire.
+ rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 4), `{"task_id":1}`, token, "")
+ require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectViewDoesNotExist))
+ })
+
+ t.Run("no write access is forbidden", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // testuser15 has no access to project 1.
+ token := humaTokenFor(t, &testuser15)
+
+ rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":1}`, token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_task_collection_test.go b/pkg/webtests/huma_task_collection_test.go
new file mode 100644
index 000000000..110b04b61
--- /dev/null
+++ b/pkg/webtests/huma_task_collection_test.go
@@ -0,0 +1,248 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// decodePaginatedTaskItems pulls the items slice out of a Paginated[*Task]
+// response so length assertions don't have to regex over nested task JSON.
+func decodePaginatedTaskItems(t *testing.T, rec *httptest.ResponseRecorder) []json.RawMessage {
+ t.Helper()
+ var body struct {
+ Items []json.RawMessage `json:"items"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
+ return body.Items
+}
+
+// TestHumaTaskCollection covers the v2 task-list endpoints. v2 splits v1's
+// single polymorphic /tasks endpoint into flat-task endpoints (always []*Task,
+// paginated) and a dedicated buckets-with-tasks endpoint (always []*Bucket).
+// Mirrors v1's TestTaskCollection where the surface overlaps.
+func TestHumaTaskCollection(t *testing.T) {
+ h := webHandlerTestV2{user: &testuser1, t: t}
+ require.NoError(t, h.ensureEnv())
+ tok := humaTokenFor(t, &testuser1)
+
+ get := func(path string) *httptest.ResponseRecorder {
+ return humaRequest(t, h.e, http.MethodGet, path, "", tok, "")
+ }
+
+ t.Run("project-scoped", func(t *testing.T) {
+ t.Run("returns the project's tasks", func(t *testing.T) {
+ rec := get("/api/v2/projects/1/tasks")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `"items":[`)
+ assert.Contains(t, body, `task #1`)
+ assert.Contains(t, body, `task #12`)
+ assert.NotContains(t, body, `task #13`) // other project
+ assert.NotContains(t, body, `task #14`)
+ })
+ t.Run("forbidden project", func(t *testing.T) {
+ // Project 2 is inaccessible to user1.
+ rec := get("/api/v2/projects/2/tasks")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("nonexistent project", func(t *testing.T) {
+ rec := get("/api/v2/projects/99999/tasks")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("pagination", func(t *testing.T) {
+ rec := get("/api/v2/projects/1/tasks?page=1&per_page=2")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Len(t, decodePaginatedTaskItems(t, rec), 2, "per_page caps the page to two tasks")
+ body := rec.Body.String()
+ assert.Contains(t, body, `"page":1`)
+ assert.Contains(t, body, `"per_page":2`)
+ })
+ t.Run("filter", func(t *testing.T) {
+ rec := get("/api/v2/projects/1/tasks?filter=" +
+ "start_date%20%3E%20%272018-12-11T03%3A46%3A40%2B00%3A00%27%20%7C%7C%20" +
+ "end_date%20%3C%20%272018-12-13T11%3A20%3A01%2B00%3A00%27%20%7C%7C%20" +
+ "due_date%20%3E%20%272018-11-29T14%3A00%3A00%2B00%3A00%27")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.NotContains(t, body, `task #1`)
+ assert.Contains(t, body, `task #5 `)
+ assert.Contains(t, body, `task #6 `)
+ assert.NotContains(t, body, `task #10`)
+ })
+ t.Run("invalid filter value", func(t *testing.T) {
+ // ErrInvalidTaskFilterValue carries an explicit 400; only govalidator
+ // failures map to 422 in v2.
+ rec := get("/api/v2/projects/1/tasks?filter=due_date%20%3E%20invalid")
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ })
+ })
+
+ t.Run("search via q", func(t *testing.T) {
+ // Only task #6 has the word "unique" in its description.
+ rec := get("/api/v2/projects/1/tasks?q=unique")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `task #6 `)
+ assert.NotContains(t, body, `task #1`)
+ assert.NotContains(t, body, `task #2 `)
+ })
+
+ t.Run("sort by repeated params", func(t *testing.T) {
+ // Two sort_by + two order_by prove ,explode binds every value.
+ rec := get("/api/v2/projects/1/tasks?sort_by=priority&sort_by=id&order_by=desc&order_by=asc")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ // task #3 has priority 100, the highest; desc puts it first.
+ assert.Regexp(t, `"items":\[\{"id":3,`, rec.Body.String())
+ })
+
+ t.Run("invalid sort field", func(t *testing.T) {
+ // A 400 (not 200) proves sort_by binds: the model validated the field
+ // and rejected it. ErrInvalidTaskField carries an explicit 400.
+ rec := get("/api/v2/projects/1/tasks?sort_by=loremipsum")
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("cross-project", func(t *testing.T) {
+ // /tasks returns tasks from every project the user can see, including
+ // shared ones, but not tasks in projects they have no access to.
+ rec := get("/api/v2/tasks")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `task #1`) // own project
+ assert.Contains(t, body, `task #15`) // shared via team readonly
+ assert.Contains(t, body, `task #21`) // shared via parent project team
+ assert.NotContains(t, body, `task #13`) // no access
+ assert.NotContains(t, body, `task #14`)
+ })
+
+ t.Run("view-scoped", func(t *testing.T) {
+ t.Run("list view returns flat tasks", func(t *testing.T) {
+ rec := get("/api/v2/projects/1/views/1/tasks")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `task #1`)
+ assert.NotContains(t, body, `testbucket`) // not buckets
+ })
+ t.Run("kanban view still returns flat tasks", func(t *testing.T) {
+ // View 4 is project 1's kanban view. v1 would return buckets here;
+ // v2's tasks endpoint forces flat tasks.
+ rec := get("/api/v2/projects/1/views/4/tasks")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `"items":[`)
+ assert.Contains(t, body, `task #1`)
+ assert.NotContains(t, body, `testbucket`)
+ })
+ t.Run("forbidden view", func(t *testing.T) {
+ // Project 2 (and its view 8) is inaccessible to user1.
+ rec := get("/api/v2/projects/2/views/8/tasks")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ })
+
+ t.Run("saved filter project", func(t *testing.T) {
+ // Project -2 maps to saved filter #1, whose stored filter matches the
+ // date-range tasks. Recurses inside the model.
+ rec := get("/api/v2/projects/-2/tasks")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `task #5 `)
+ assert.Contains(t, body, `task #6 `)
+ assert.NotContains(t, body, `task #1`)
+ assert.NotContains(t, body, `task #10`)
+ })
+}
+
+// TestHumaTaskCollection_Expand proves expand binds every repeated value
+// (,explode) and routes through parseTaskExpand.
+func TestHumaTaskCollection_Expand(t *testing.T) {
+ h := webHandlerTestV2{user: &testuser1, t: t}
+ require.NoError(t, h.ensureEnv())
+ tok := humaTokenFor(t, &testuser1)
+
+ get := func(path string) *httptest.ResponseRecorder {
+ return humaRequest(t, h.e, http.MethodGet, path, "", tok, "")
+ }
+
+ t.Run("repeated expand applies every value", func(t *testing.T) {
+ rec := get("/api/v2/projects/1/tasks?expand=comment_count&expand=reactions")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `"comment_count":`)
+ assert.Contains(t, body, `"reactions":`)
+ })
+ t.Run("invalid expand rejected", func(t *testing.T) {
+ rec := get("/api/v2/projects/1/tasks?expand=bogus")
+ // enum on the query param makes Huma reject before the handler.
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaTaskCollection_Buckets covers the dedicated buckets-with-tasks
+// endpoint: a kanban view returns []*Bucket with each bucket's tasks populated,
+// not paginated.
+func TestHumaTaskCollection_Buckets(t *testing.T) {
+ h := webHandlerTestV2{user: &testuser1, t: t}
+ require.NoError(t, h.ensureEnv())
+ tok := humaTokenFor(t, &testuser1)
+
+ get := func(path string) *httptest.ResponseRecorder {
+ return humaRequest(t, h.e, http.MethodGet, path, "", tok, "")
+ }
+
+ t.Run("kanban view returns buckets with tasks", func(t *testing.T) {
+ rec := get("/api/v2/projects/1/views/4/buckets/tasks")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, `testbucket1`)
+ assert.Contains(t, body, `testbucket2`)
+ assert.Contains(t, body, `testbucket3`)
+ assert.NotContains(t, body, `testbucket4`) // belongs to project 2's view
+ // Tasks are nested under their bucket, not at the top level.
+ assert.Contains(t, body, `"tasks":[`)
+ assert.Contains(t, body, `task #1`)
+ // total counts buckets, not tasks.
+ assert.Contains(t, body, `"total":3`)
+ })
+
+ t.Run("forbidden project", func(t *testing.T) {
+ rec := get("/api/v2/projects/2/views/8/buckets/tasks")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("non-kanban view is a 400, not a 500", func(t *testing.T) {
+ // View 1 is project 1's list view; it has no bucket configuration, so
+ // the model returns flat tasks and the handler refuses cleanly.
+ rec := get("/api/v2/projects/1/views/1/buckets/tasks")
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("static tasks segment does not collide with the bucket-update route", func(t *testing.T) {
+ // PUT .../buckets/{bucket}/tasks exists; GET .../buckets/tasks must hit
+ // this handler, not parse "tasks" as a bucket id.
+ rec := get("/api/v2/projects/1/views/4/buckets/tasks")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `testbucket1`)
+ })
+}
diff --git a/pkg/webtests/huma_task_position_test.go b/pkg/webtests/huma_task_position_test.go
new file mode 100644
index 000000000..da10768e4
--- /dev/null
+++ b/pkg/webtests/huma_task_position_test.go
@@ -0,0 +1,94 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTaskPositionV2 covers PUT /tasks/{task}/position. It drives the Echo+Huma
+// stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL
+// only models base[/{id}] paths, not action sub-paths.
+func TestTaskPositionV2(t *testing.T) {
+ t.Run("updates the position of a writable task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Task 1 lives in project 1, which testuser1 owns; view 1 belongs to project 1.
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":256}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ var resp models.TaskPosition
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.Equal(t, int64(1), resp.TaskID, "task id is taken from the URL")
+ assert.Equal(t, int64(1), resp.ProjectViewID)
+ assert.InDelta(t, 256.0, resp.Position, 0)
+ })
+
+ t.Run("path task id wins over the body", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Body names task 2, URL names task 1; the URL must win.
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"task_id":2,"project_view_id":1,"position":300}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ var resp models.TaskPosition
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.Equal(t, int64(1), resp.TaskID)
+ })
+
+ t.Run("nonexistent task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/position", `{"project_view_id":1,"position":1}`, token, "")
+ require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist), "body must surface ErrCodeTaskDoesNotExist; body: %s", rec.Body.String())
+ })
+
+ t.Run("no access to the task is forbidden", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // testuser15 cannot access task 1 (project 1, owned by testuser1).
+ token := humaTokenFor(t, &testuser15)
+
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":1}`, token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("read but no write on the task is forbidden", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // Task 32 lives in project 3, on which testuser1 has read-only access.
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/32/position", `{"project_view_id":1,"position":1}`, token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_task_relation_test.go b/pkg/webtests/huma_task_relation_test.go
new file mode 100644
index 000000000..65166402c
--- /dev/null
+++ b/pkg/webtests/huma_task_relation_test.go
@@ -0,0 +1,197 @@
+// 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 webtests
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTaskRelationV2 covers POST /tasks/{task}/relations and
+// DELETE /tasks/{task}/relations/{relationKind}/{otherTask}. It drives the
+// Echo+Huma stack directly (humaRequest/humaTokenFor) because the action
+// sub-paths aren't modelled by webHandlerTestV2's buildURL. Coverage mirrors
+// the v1 model matrix in pkg/models/task_relation_test.go.
+func TestTaskRelationV2(t *testing.T) {
+ t.Run("Create", func(t *testing.T) {
+ t.Run("creates forward and inverse rows", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"other_task_id":2,"relation_kind":"subtask"}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"relation_kind":"subtask"`)
+ assert.Contains(t, rec.Body.String(), `"task_id":1`)
+ assert.Contains(t, rec.Body.String(), `"other_task_id":2`)
+
+ // Create must store both directions: the forward subtask and the
+ // automatically derived inverse parenttask.
+ db.AssertExists(t, "task_relations", map[string]interface{}{
+ "task_id": 1,
+ "other_task_id": 2,
+ "relation_kind": models.RelationKindSubtask,
+ "created_by_id": 1,
+ }, false)
+ db.AssertExists(t, "task_relations", map[string]interface{}{
+ "task_id": 2,
+ "other_task_id": 1,
+ "relation_kind": models.RelationKindParenttask,
+ "created_by_id": 1,
+ }, false)
+ })
+
+ t.Run("path task id wins over body", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // task_id in the body is ignored; the row is created for the path task.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"task_id":999,"other_task_id":2,"relation_kind":"related"}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ db.AssertExists(t, "task_relations", map[string]interface{}{
+ "task_id": 1,
+ "other_task_id": 2,
+ "relation_kind": models.RelationKindRelated,
+ }, false)
+ })
+
+ t.Run("cycle is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // task 29 is already a subtask of task 1 (fixture); making task 1 a
+ // subtask of task 29 would close the loop.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/29/relations",
+ `{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
+ require.Equal(t, http.StatusConflict, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskRelationCycle))
+ })
+
+ t.Run("same task is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"other_task_id":1,"relation_kind":"related"}`, token, "")
+ require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationTasksCannotBeTheSame))
+ })
+
+ t.Run("invalid relation kind in body is rejected by the enum", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // relation_kind carries an enum constraint, so Huma rejects an unknown
+ // kind with 422 before the handler runs (consistent with the delete path).
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"other_task_id":2,"relation_kind":"bogus"}`, token, "")
+ require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("nonexistent base task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/999999/relations",
+ `{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
+ require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist))
+ })
+
+ t.Run("forbidden - no write on base task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // task 15 is read-only for user1, so CanCreate (needs write on base) denies.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/15/relations",
+ `{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("removes forward and inverse rows", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Fixture relation 1: task 1 -subtask-> task 29, with the inverse
+ // parenttask row (task 29 -> task 1).
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/29", "", token, "")
+ require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
+ assert.Empty(t, rec.Body.String())
+
+ db.AssertMissing(t, "task_relations", map[string]interface{}{
+ "task_id": 1,
+ "other_task_id": 29,
+ "relation_kind": models.RelationKindSubtask,
+ })
+ db.AssertMissing(t, "task_relations", map[string]interface{}{
+ "task_id": 29,
+ "other_task_id": 1,
+ "relation_kind": models.RelationKindParenttask,
+ })
+ })
+
+ t.Run("invalid relation kind in path is rejected by the enum", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // The path param carries an enum constraint, so Huma rejects an unknown
+ // kind with 422 before the handler runs.
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/bogus/29", "", token, "")
+ require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("nonexistent relation", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/2", "", token, "")
+ require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationDoesNotExist))
+ })
+
+ t.Run("forbidden - no write on base task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Fixture relation 7: task 41 -subtask-> task 43, owned by user15 in
+ // project 36, which user1 cannot access — CanDelete denies.
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/41/relations/subtask/43", "", token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ })
+}
diff --git a/pkg/webtests/huma_task_test.go b/pkg/webtests/huma_task_test.go
new file mode 100644
index 000000000..8c919e382
--- /dev/null
+++ b/pkg/webtests/huma_task_test.go
@@ -0,0 +1,281 @@
+// 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 webtests
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaTask mirrors v1's TestTask so v2 contract parity is readable
+// side-by-side. Read/update/delete address a task by its numeric id; create
+// and by-index live on project-scoped paths that don't fit the harness's
+// basePath/{id} shape, so those use humaRequest against a shared env.
+//
+// Fixture topology the matrix relies on (pkg/db/fixtures/tasks.yml +
+// project shares):
+// - #1: user1's own task in project 1 (admin) — readable/updatable/deletable.
+// - #14: project shared read-only via team — forbidden to write/delete.
+// - #34: project 20, private to user13 — invisible to user1.
+// - project 6: shared read-only; project 7/8: shared write/admin via team.
+func TestHumaTask(t *testing.T) {
+ testHandler := webHandlerTestV2{
+ user: &testuser1,
+ basePath: "/api/v2/tasks",
+ idParam: "projecttask",
+ t: t,
+ }
+
+ t.Run("ReadOne", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "1"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ assert.Contains(t, rec.Body.String(), `"title":"task #1"`)
+ assert.Contains(t, rec.Body.String(), `"max_permission":2`) // owner = admin
+ assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "99999"})
+ require.Error(t, err)
+ // CanRead resolves the task before the project check, so a missing
+ // task surfaces as 404, not the 403 the label read uses.
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Forbidden - private project", func(t *testing.T) {
+ // Task #34 lives in project 20, private to user13.
+ _, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "34"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+
+ // The v2 harness loads fixtures once and reuses the env across subtests,
+ // so each mutating subtest targets a distinct task to stay order-independent
+ // (unlike v1's webHandlerTest, which reloads fixtures per request).
+ t.Run("Update", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "3"}, `{"title":"Lorem Ipsum"}`)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
+ assert.NotContains(t, rec.Body.String(), `"title":"task #3 high prio"`)
+ })
+ t.Run("Move to another project", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "4"}, `{"project_id":7}`)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"project_id":7`)
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "99999"}, `{"title":"x"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Forbidden - read-only share", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "14"}, `{"title":"x"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Forbidden - move into a project the user can't write", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "5"}, `{"project_id":20}`)
+ require.Error(t, err)
+ assertHandlerErrorCode(t, err, models.ErrorCodeGenericForbidden)
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "2"})
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ assert.Empty(t, rec.Body.String())
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "99999"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Forbidden - read-only share", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "14"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Shared via team write", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "16"})
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ })
+ })
+}
+
+// TestHumaTask_Create covers the project-scoped create path, which the harness
+// basePath shape can't express. Mirrors v1's TestTask/Create matrix.
+func TestHumaTask_Create(t *testing.T) {
+ h := webHandlerTestV2{user: &testuser1, t: t}
+ require.NoError(t, h.ensureEnv())
+
+ create := func(project, body string) *httptest.ResponseRecorder {
+ return humaRequest(t, h.e, http.MethodPost, "/api/v2/projects/"+project+"/tasks", body, humaTokenFor(t, &testuser1), "")
+ }
+
+ t.Run("Normal", func(t *testing.T) {
+ rec := create("1", `{"title":"Lorem Ipsum"}`)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
+ assert.Contains(t, rec.Body.String(), `"project_id":1`)
+ })
+ t.Run("Project id from body is ignored - URL wins", func(t *testing.T) {
+ rec := create("1", `{"title":"url wins","project_id":7}`)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"project_id":1`)
+ assert.NotContains(t, rec.Body.String(), `"project_id":7`)
+ })
+ t.Run("Nonexisting project", func(t *testing.T) {
+ rec := create("9999", `{"title":"x"}`)
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist))
+ })
+ t.Run("Forbidden - private project", func(t *testing.T) {
+ rec := create("20", `{"title":"x"}`)
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Forbidden - read-only share", func(t *testing.T) {
+ rec := create("6", `{"title":"x"}`)
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Shared via team write", func(t *testing.T) {
+ rec := create("7", `{"title":"Lorem Ipsum"}`)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
+ })
+ t.Run("Empty title is rejected", func(t *testing.T) {
+ rec := create("1", `{"title":""}`)
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaTask_ReadByIndex covers the by-index route, including the textual
+// project-identifier resolution that v1 does in echo middleware. Mirrors v1's
+// TestTaskByProjectIndex and TestTask/ReadOneByIndex.
+func TestHumaTask_ReadByIndex(t *testing.T) {
+ h := webHandlerTestV2{user: &testuser1, t: t}
+ require.NoError(t, h.ensureEnv())
+
+ get := func(project, index string) *httptest.ResponseRecorder {
+ return humaRequest(t, h.e, http.MethodGet,
+ fmt.Sprintf("/api/v2/projects/%s/tasks/by-index/%s", project, index), "", humaTokenFor(t, &testuser1), "")
+ }
+
+ t.Run("By numeric project id", func(t *testing.T) {
+ rec := get("1", "1")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ assert.Contains(t, rec.Body.String(), `"index":1`)
+ })
+ t.Run("By textual project identifier", func(t *testing.T) {
+ // Project 1 has identifier "TEST1".
+ rec := get("TEST1", "1")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ })
+ t.Run("Identifier match is case-insensitive", func(t *testing.T) {
+ rec := get("test1", "1")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ })
+ t.Run("Unknown identifier returns ErrProjectDoesNotExist", func(t *testing.T) {
+ rec := get("does-not-exist", "1")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist))
+ })
+ t.Run("Nonexistent index returns 404", func(t *testing.T) {
+ rec := get("1", "99999")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("No permission returns 403", func(t *testing.T) {
+ // Project 2 is inaccessible to user1; must be 403, not a 404 oracle.
+ rec := get("2", "1")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaTask_Expand asserts the expand query param populates the extra,
+// more expensive fields, is repeatable (explode), and rejects unknown values.
+// comment_count and reactions are genuinely gated on the flag, so they prove
+// the param is wired through; subtasks-as-related-tasks load regardless.
+func TestHumaTask_Expand(t *testing.T) {
+ h := webHandlerTestV2{user: &testuser1, t: t}
+ require.NoError(t, h.ensureEnv())
+ tok := humaTokenFor(t, &testuser1)
+
+ t.Run("absent leaves expand-gated fields empty", func(t *testing.T) {
+ rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1", "", tok, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.NotContains(t, rec.Body.String(), `"comment_count":`)
+ assert.NotContains(t, rec.Body.String(), `"reactions":{`)
+ })
+ t.Run("comment_count", func(t *testing.T) {
+ rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=comment_count", "", tok, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"comment_count":`, "comment_count must be present: %s", rec.Body.String())
+ })
+ t.Run("reactions", func(t *testing.T) {
+ // Task #1 has reaction fixture #1.
+ rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=reactions", "", tok, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"reactions":{`, "reactions must be embedded: %s", rec.Body.String())
+ })
+ t.Run("repeated param applies every value", func(t *testing.T) {
+ // explode binding: both ?expand= values take effect, not just the first.
+ rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=comment_count&expand=reactions", "", tok, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"comment_count":`)
+ assert.Contains(t, rec.Body.String(), `"reactions":{`)
+ })
+ t.Run("invalid value is rejected", func(t *testing.T) {
+ rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=bogus", "", tok, "")
+ // enum on the query param makes Huma reject it as a 422 before the handler.
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaTask_ETagReturns304 covers the v2-only conditional-read behaviour.
+func TestHumaTask_ETagReturns304(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ etag := rec.Header().Get("ETag")
+ require.NotEmpty(t, etag)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/tasks/1", strings.NewReader(""))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("If-None-Match", etag)
+ rec = httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String())
+}
diff --git a/pkg/webtests/huma_task_unread_status_test.go b/pkg/webtests/huma_task_unread_status_test.go
new file mode 100644
index 000000000..9ea27544d
--- /dev/null
+++ b/pkg/webtests/huma_task_unread_status_test.go
@@ -0,0 +1,88 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaTaskUnreadStatus ports v1's POST /tasks/:projecttask/read (no v1
+// webtest exists). The action deletes the caller's unread entry for the task;
+// there is no fixture file for task_unread_statuses, so the table starts empty
+// and the test seeds the row it expects to clear.
+//
+// Note on the permission model: the v1 handler enforces nothing — CanUpdate is
+// a hardcoded true and Update is an unconditional DELETE on (task_id, user_id).
+// A task the caller can't see (or doesn't exist) therefore has no row to clear
+// and the call succeeds as a no-op. The only thing actually gated is auth, so
+// that is what the negative case covers.
+func TestHumaTaskUnreadStatus(t *testing.T) {
+ t.Run("Normal - clears the caller's unread entry", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ s := db.NewSession()
+ _, err = s.Insert(&models.TaskUnreadStatus{TaskID: 1, UserID: testuser1.ID})
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+ require.NoError(t, s.Close())
+
+ token := humaTokenFor(t, &testuser1)
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"success"`)
+
+ db.AssertMissing(t, "task_unread_statuses", map[string]interface{}{
+ "task_id": 1,
+ "user_id": testuser1.ID,
+ })
+ })
+
+ t.Run("No-op - already read, no entry to clear", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ token := humaTokenFor(t, &testuser1)
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"message":"success"`)
+ })
+
+ t.Run("No-op - nonexistent task (unenforced, mirrors v1)", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ token := humaTokenFor(t, &testuser1)
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/read", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Anonymous request is rejected with 401", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "anonymous must get 401; body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_testing_test.go b/pkg/webtests/huma_testing_test.go
new file mode 100644
index 000000000..480ef285f
--- /dev/null
+++ b/pkg/webtests/huma_testing_test.go
@@ -0,0 +1,223 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/files"
+ "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/keyvalue"
+ "code.vikunja.io/api/pkg/modules/migration"
+ "code.vikunja.io/api/pkg/routes"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "src.techknowlogick.com/xormigrate"
+)
+
+const testingToken = "test-testing-token"
+
+// setupTestingEnv mirrors setupTestEnv but sets the testing token before
+// registering routes, so the config-gated /api/v2/test/* endpoints mount.
+// When token is empty the endpoints stay unmounted (the disabled case).
+func setupTestingEnv(t *testing.T, token string) *echo.Echo {
+ t.Helper()
+ config.InitDefaultConfig()
+ config.ServicePublicURL.Set("https://localhost")
+ config.ServiceTestingtoken.Set(token)
+ t.Cleanup(func() { config.ServiceTestingtoken.Set("") })
+
+ log.InitLogger()
+ files.InitTests()
+ user.InitTests()
+ models.SetupTests()
+ events.Fake()
+ keyvalue.InitStorage()
+
+ // models.SetupTests only syncs models + notifications tables, but
+ // TruncateAllTables walks *every* registered table — including ones created
+ // by migration in production (license_status, migration_status) plus
+ // xormigrate's "migration" tracking table. Create them here so truncate-all
+ // doesn't hit "no such table" (the same gap that kept v1 from testing it).
+ engine, err := db.CreateTestEngine()
+ require.NoError(t, err)
+ extraTables := append(append([]any{new(xormigrate.Migration)}, license.GetTables()...), migration.GetTables()...)
+ require.NoError(t, engine.Sync2(extraTables...))
+
+ require.NoError(t, db.LoadFixtures())
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+ return e
+}
+
+// testingRequest dispatches a request to a /api/v2/test/* endpoint, sending the
+// raw token in the Authorization header (not a Bearer JWT).
+func testingRequest(e *echo.Echo, method, path, body, token string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if token != "" {
+ req.Header.Set("Authorization", token)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+func countRows(t *testing.T, table string) int {
+ t.Helper()
+ s := db.NewSession()
+ defer s.Close()
+ rows := []map[string]interface{}{}
+ require.NoError(t, s.Table(table).Find(&rows))
+ return len(rows)
+}
+
+func TestTesting(t *testing.T) {
+ t.Run("replace table contents", func(t *testing.T) {
+ e := setupTestingEnv(t, testingToken)
+ t.Cleanup(func() { _ = db.LoadFixtures() })
+
+ body := `[{"id":1,"title":"only label","created_by_id":1,"created":"2020-01-01 00:00:00","updated":"2020-01-01 00:00:00"}]`
+ rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", body, testingToken)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+
+ var data []map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &data))
+ require.Len(t, data, 1)
+ assert.EqualValues(t, "only label", data[0]["title"])
+ assert.Equal(t, 1, countRows(t, "labels"), "table should hold exactly the seeded rows")
+ })
+
+ t.Run("replace without truncate keeps existing rows", func(t *testing.T) {
+ e := setupTestingEnv(t, testingToken)
+ t.Cleanup(func() { _ = db.LoadFixtures() })
+
+ before := countRows(t, "labels")
+ require.Positive(t, before, "fixtures should seed some labels")
+
+ body := `[{"id":9999,"title":"added label","created_by_id":1,"created":"2020-01-01 00:00:00","updated":"2020-01-01 00:00:00"}]`
+ rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels?truncate=false", body, testingToken)
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+
+ assert.Equal(t, before+1, countRows(t, "labels"), "row should be added on top of existing data")
+ })
+
+ t.Run("truncate all tables", func(t *testing.T) {
+ e := setupTestingEnv(t, testingToken)
+ t.Cleanup(func() { _ = db.LoadFixtures() })
+
+ require.Positive(t, countRows(t, "labels"))
+
+ rec := testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", testingToken)
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ var resp struct {
+ Message string `json:"message"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ assert.Equal(t, "ok", resp.Message)
+ assert.Equal(t, 0, countRows(t, "labels"), "every table should be empty after truncate")
+ })
+
+ t.Run("wrong token is forbidden", func(t *testing.T) {
+ e := setupTestingEnv(t, testingToken)
+
+ rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "wrong-token")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+
+ rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "wrong-token")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("missing token is forbidden", func(t *testing.T) {
+ e := setupTestingEnv(t, testingToken)
+
+ rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+
+ rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+func TestTesting_DisabledConfig(t *testing.T) {
+ e := setupTestingEnv(t, "")
+
+ rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured")
+
+ rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured")
+}
+
+func TestTesting_BodySchemaIsArrayOfObjects(t *testing.T) {
+ e := setupTestingEnv(t, testingToken)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var spec map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec))
+
+ paths, _ := spec["paths"].(map[string]any)
+ op, _ := paths["/test/{table}"].(map[string]any)
+ put, ok := op["put"].(map[string]any)
+ require.True(t, ok, "PUT /test/{table} must be in the spec")
+
+ reqBody, _ := put["requestBody"].(map[string]any)
+ content, _ := reqBody["content"].(map[string]any)
+ appJSON, _ := content["application/json"].(map[string]any)
+ schema, _ := appJSON["schema"].(map[string]any)
+ // FieldsOptionalByDefault makes the array nullable, so `type` may be the
+ // string "array" or the list ["array","null"]. Either is honest; assert it
+ // describes an array (not, say, a base64 string as json.RawMessage would).
+ assert.Contains(t, schemaTypes(schema["type"]), "array", "request body must be modeled as an array")
+}
+
+// schemaTypes normalises an OpenAPI `type` value (a string or a list of
+// strings when nullable) into a slice for assertion.
+func schemaTypes(v any) []string {
+ switch t := v.(type) {
+ case string:
+ return []string{t}
+ case []any:
+ out := make([]string, 0, len(t))
+ for _, e := range t {
+ if s, ok := e.(string); ok {
+ out = append(out, s)
+ }
+ }
+ return out
+ default:
+ return nil
+ }
+}
diff --git a/pkg/webtests/huma_time_entry_test.go b/pkg/webtests/huma_time_entry_test.go
new file mode 100644
index 000000000..1b8b91ffb
--- /dev/null
+++ b/pkg/webtests/huma_time_entry_test.go
@@ -0,0 +1,211 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.vikunja.io/api/pkg/license"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Fixture entries (pkg/db/fixtures/time_entries.yml): 1 = user1 on task 1,
+// 2 = user1 on project 1, 3 = user3 on project 3 (user1 can read), 4 = user1's
+// running timer on task 1. user1 (testuser1) can read all four.
+
+// The gate is the one v2-specific concern with no model-level equivalent: every
+// time-tracking route 404s on an instance without the feature.
+func TestHumaTimeEntry_LicenseGate(t *testing.T) {
+ t.Run("disabled feature 404s the list", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{}) // licensed, but not time tracking
+ defer license.ResetForTests()
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ })
+
+ t.Run("disabled feature 404s timer/stop", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{})
+ defer license.ResetForTests()
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ })
+
+ t.Run("disabled feature 404s the task-scoped list", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{})
+ defer license.ResetForTests()
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/time-entries", "", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ })
+
+ t.Run("enabled feature serves the list", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ })
+}
+
+func TestHumaTimeEntry(t *testing.T) {
+ // SetForTests must come after setupTestEnv — the latter re-inits the license to free.
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ testHandler := webHandlerTestV2{
+ user: &testuser1,
+ basePath: "/api/v2/time-entries",
+ idParam: "id",
+ t: t,
+ e: e,
+ }
+
+ t.Run("ReadAll returns the readable set", func(t *testing.T) {
+ rec, err := testHandler.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []int64{1, 2, 3, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes()),
+ "body: %s", rec.Body.String())
+ })
+
+ t.Run("ReadOne", func(t *testing.T) {
+ rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "1"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ assert.Contains(t, rec.Body.String(), `"max_permission":`)
+ })
+
+ t.Run("ReadOne forbidden for a stranger", func(t *testing.T) {
+ // entry 1 is on project 1; user2 has no access to it.
+ stranger := webHandlerTestV2{user: &testuser2, basePath: "/api/v2/time-entries", idParam: "id", t: t, e: e}
+ _, err := stranger.testReadOneWithUser(nil, map[string]string{"id": "1"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+
+ t.Run("ReadOne of a missing entry is 404", func(t *testing.T) {
+ _, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "9999"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+}
+
+// Create exercises the full handler path (DoCreate → CanCreate → Create →
+// commit → DispatchPending) that the model-level tests bypass.
+func TestHumaTimeEntry_Create(t *testing.T) {
+ t.Run("saving an entry with end_time", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","end_time":"2020-01-01T10:00:00Z","comment":"work"}`
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"user_id":1`)
+ assert.Contains(t, rec.Body.String(), `"task_id":1`)
+ })
+
+ t.Run("starting a timer without end_time", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","comment":"timer"}`
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"user_id":1`)
+ // A running timer's end_time is null on the wire, not the zero-time sentinel.
+ assert.Contains(t, rec.Body.String(), `"end_time":null`)
+ assert.NotContains(t, rec.Body.String(), "0001-01-01")
+ })
+}
+
+// The filter param must wire through the route into ReadAll.
+func TestHumaTimeEntry_Filter(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("by task", func(t *testing.T) {
+ q := url.Values{"filter": {"task_id = 1"}}
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.ElementsMatch(t, []int64{1, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes()))
+ })
+
+ t.Run("running timers via null end_time", func(t *testing.T) {
+ q := url.Values{"filter": {"end_time = null"}}
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.ElementsMatch(t, []int64{4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes()))
+ })
+}
+
+func TestHumaTimeEntry_TimerStop(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ license.SetForTests([]license.Feature{license.FeatureTimeTracking})
+ defer license.ResetForTests()
+
+ t.Run("stops the caller's running timer", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"id":4`, "entry 4 is user1's running timer")
+ assert.NotContains(t, rec.Body.String(), `"end_time":"0001-01-01`, "end_time must now be set")
+ })
+
+ t.Run("404 when the caller has no running timer", func(t *testing.T) {
+ // user2 has no entries, so no running timer.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser2), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
+ })
+}
+
+func timeEntryIDsFromReadAll(t *testing.T, body []byte) []int64 {
+ t.Helper()
+ var resp struct {
+ Items []struct {
+ ID int64 `json:"id"`
+ } `json:"items"`
+ }
+ require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
+ ids := make([]int64, 0, len(resp.Items))
+ for _, it := range resp.Items {
+ ids = append(ids, it.ID)
+ }
+ return ids
+}
diff --git a/pkg/webtests/huma_user_deletion_test.go b/pkg/webtests/huma_user_deletion_test.go
new file mode 100644
index 000000000..081db594d
--- /dev/null
+++ b/pkg/webtests/huma_user_deletion_test.go
@@ -0,0 +1,194 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ userDeletionRequestPath = "/api/v2/user/deletion/request"
+ userDeletionConfirmPath = "/api/v2/user/deletion/confirm"
+ userDeletionCancelPath = "/api/v2/user/deletion/cancel"
+ // testUserPassword is the plaintext password for every local fixture user.
+ testUserPassword = "12345678"
+)
+
+// deletionTokenFor reads the cleartext account-deletion token RequestDeletion
+// stored for the user. RequestDeletion only mails the token, so the test pulls
+// it straight from user_tokens (kind 3 = TokenAccountDeletion).
+func deletionTokenFor(t *testing.T, userID int64) string {
+ t.Helper()
+ s := db.NewSession()
+ defer s.Close()
+ tok := struct {
+ Token string `xorm:"token"`
+ }{}
+ has, err := s.Table("user_tokens").
+ Where("user_id = ? AND kind = ?", userID, 3).
+ Get(&tok)
+ require.NoError(t, err)
+ require.True(t, has, "RequestDeletion must have stored a deletion token for user %d", userID)
+ return tok.Token
+}
+
+func deletionScheduledFor(t *testing.T, userID int64) bool {
+ t.Helper()
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, userID)
+ require.NoError(t, err)
+ return !u.DeletionScheduledAt.IsZero()
+}
+
+// TestHumaUserDeletion ports v1's account-deletion flow (request → confirm →
+// cancel) to v2. v1 returned 200/204 with a confirmation message body; v2
+// normalises all three to an empty 204 (the action returns no resource), so
+// every success here asserts 204 + empty body.
+func TestHumaUserDeletion(t *testing.T) {
+ t.Run("Request - wrong password rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"wrong"}`, token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ assert.False(t, deletionScheduledFor(t, testuser1.ID), "a rejected request must not schedule deletion")
+ })
+
+ t.Run("Confirm - invalid token rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":"not-a-real-token"}`, token, "")
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ assert.False(t, deletionScheduledFor(t, testuser1.ID))
+ })
+
+ t.Run("Confirm - missing token is a validation error", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":""}`, token, "")
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Request then confirm schedules deletion", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String())
+ assert.Empty(t, req.Body.String(), "v2 normalises the request action to an empty 204")
+ assert.False(t, deletionScheduledFor(t, testuser1.ID), "request alone must not schedule; confirmation does")
+
+ confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath,
+ `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String())
+ assert.Empty(t, confirm.Body.String())
+ assert.True(t, deletionScheduledFor(t, testuser1.ID), "confirm must schedule the deletion")
+ })
+
+ t.Run("Cancel - wrong password rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Schedule first so there is something to cancel.
+ req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String())
+ confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath,
+ `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String())
+ require.True(t, deletionScheduledFor(t, testuser1.ID))
+
+ cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"wrong"}`, token, "")
+ assert.Equal(t, http.StatusForbidden, cancel.Code, "body: %s", cancel.Body.String())
+ assert.True(t, deletionScheduledFor(t, testuser1.ID), "a rejected cancel must leave the deletion scheduled")
+ })
+
+ t.Run("Cancel - correct password clears the schedule", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String())
+ confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath,
+ `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String())
+ require.True(t, deletionScheduledFor(t, testuser1.ID))
+
+ cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"`+testUserPassword+`"}`, token, "")
+ require.Equal(t, http.StatusNoContent, cancel.Code, "body: %s", cancel.Body.String())
+ assert.Empty(t, cancel.Body.String())
+ assert.False(t, deletionScheduledFor(t, testuser1.ID), "cancel must clear the scheduled deletion")
+ })
+
+ t.Run("Unauthenticated", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ for _, path := range []string{userDeletionRequestPath, userDeletionConfirmPath, userDeletionCancelPath} {
+ rec := humaRequest(t, e, http.MethodPost, path, `{}`, "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "%s body: %s", path, rec.Body.String())
+ }
+ })
+}
+
+// TestHumaUserDeletion_LinkShareForbidden asserts a link share — which has no
+// account — is refused (403) on every deletion action.
+func TestHumaUserDeletion_LinkShareForbidden(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{
+ ID: 1,
+ Hash: "test",
+ ProjectID: 1,
+ Permission: models.PermissionRead,
+ SharingType: models.SharingTypeWithoutPassword,
+ SharedByID: 1,
+ })
+ require.NoError(t, err)
+
+ for _, tc := range []struct {
+ name string
+ path string
+ body string
+ }{
+ {"request", userDeletionRequestPath, `{"password":"` + testUserPassword + `"}`},
+ {"confirm", userDeletionConfirmPath, `{"token":"x"}`},
+ {"cancel", userDeletionCancelPath, `{"password":"` + testUserPassword + `"}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, tc.path, tc.body, token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ }
+}
diff --git a/pkg/webtests/huma_user_export_test.go b/pkg/webtests/huma_user_export_test.go
new file mode 100644
index 000000000..ee9104d5b
--- /dev/null
+++ b/pkg/webtests/huma_user_export_test.go
@@ -0,0 +1,126 @@
+// 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 webtests
+
+import (
+ "bytes"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/files"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaUserExport covers the v2 data-export endpoints. Fixture topology
+// (pkg/db/fixtures/users.yml + files.yml):
+// - user1: local, password 12345678, export_file_id 1 (file row exists, no bytes).
+// - user14: non-local (OIDC), no password to confirm.
+// - user15: local, no export.
+func TestHumaUserExport(t *testing.T) {
+ t.Run("Request with the correct password", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request",
+ `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "requested data export")
+ })
+
+ t.Run("Request with a wrong password is refused", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request",
+ `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "")
+ require.NotEqual(t, http.StatusOK, rec.Code,
+ "a wrong password must not start an export; body: %s", rec.Body.String())
+ })
+
+ t.Run("Request as a non-local user skips the password", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request",
+ `{}`, humaTokenFor(t, &testuser14), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Download streams the export bytes", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // user1's export points at file 1; setupTestEnv resets storage, so write
+ // real bytes for it (size matches the fixture's declared 100 bytes).
+ content := bytes.Repeat([]byte("v"), 100)
+ require.NoError(t, (&files.File{ID: 1, Size: uint64(len(content))}).Save(bytes.NewReader(content)))
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
+ `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match")
+ assert.Contains(t, rec.Header().Get("Content-Disposition"), "test")
+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), "downloads must never be cached")
+ })
+
+ t.Run("Download with a wrong password is refused", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
+ `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "")
+ require.NotEqual(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Download without an export returns 404", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
+ `{"password":"12345678"}`, humaTokenFor(t, &testuser15), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Download with a missing physical file returns 404", func(t *testing.T) {
+ // user1 has export_file_id 1, but setupTestEnv leaves its bytes unwritten.
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download",
+ `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Status returns the export metadata", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ assert.Contains(t, rec.Body.String(), `"expires"`)
+ })
+
+ t.Run("Status without an export returns null", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser15), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.JSONEq(t, "null", rec.Body.String())
+ })
+
+ t.Run("Unauthenticated request is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/huma_user_search_test.go b/pkg/webtests/huma_user_search_test.go
new file mode 100644
index 000000000..821095ea6
--- /dev/null
+++ b/pkg/webtests/huma_user_search_test.go
@@ -0,0 +1,89 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaUserSearch covers the global user search. Emails must never leak.
+func TestHumaUserSearch(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("Search by username", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/users?q=user2", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ usernames, emails := usersFromSearch(t, rec.Body.Bytes())
+ assert.Contains(t, usernames, "user2")
+ for _, em := range emails {
+ assert.Empty(t, em, "user search must never return email addresses")
+ }
+ })
+ t.Run("Unauthenticated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/users?q=user2", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+// TestHumaProjectUserSearch covers the per-project user search used for share
+// autocomplete. It requires read access to the project.
+func TestHumaProjectUserSearch(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("Owned project", func(t *testing.T) {
+ // testuser1 owns project 1.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/users/search", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"items"`)
+ })
+ t.Run("Forbidden - no access", func(t *testing.T) {
+ // project 2 is owned by user3; testuser1 has no access.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/2/users/search", "", token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Nonexistent project", func(t *testing.T) {
+ // CanRead surfaces ErrProjectDoesNotExist (404), not a bare forbidden.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/99999/users/search", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+func usersFromSearch(t *testing.T, body []byte) (usernames, emails []string) {
+ t.Helper()
+ var resp struct {
+ Items []struct {
+ Username string `json:"username"`
+ Email string `json:"email"`
+ } `json:"items"`
+ }
+ require.NoError(t, json.Unmarshal(body, &resp), "search body must be a paginated envelope: %s", string(body))
+ for _, it := range resp.Items {
+ usernames = append(usernames, it.Username)
+ emails = append(emails, it.Email)
+ }
+ return usernames, emails
+}
diff --git a/pkg/webtests/huma_user_settings_test.go b/pkg/webtests/huma_user_settings_test.go
new file mode 100644
index 000000000..24e7469f3
--- /dev/null
+++ b/pkg/webtests/huma_user_settings_test.go
@@ -0,0 +1,195 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// All subtests in a Test* func share one env: setupTestEnv rotates the JWT
+// secret per call, so a token must be issued from the same env it's used
+// against. Where a subtest mutates the user, later subtests account for it.
+
+func TestHumaUserShow(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ body := rec.Body.String()
+ assert.Contains(t, body, `"id":1`)
+ assert.Contains(t, body, `"username":"user1"`)
+ // Like v1, /user does not disclose the email (GetUserByID strips it); the
+ // json:"email,omitempty" tag then drops the field entirely.
+ assert.NotContains(t, body, `"email":""`)
+ // Computed account facts v1 returned alongside the user object.
+ assert.Contains(t, body, `"auth_provider":"local"`)
+ assert.Contains(t, body, `"is_local_user":true`)
+ assert.Contains(t, body, `"is_admin":false`)
+ // The nested settings use the shared models.UserGeneralSettings shape.
+ assert.Contains(t, body, `"settings":`)
+ assert.Contains(t, body, `"frontend_settings":`)
+ assert.Contains(t, body, `"extra_settings_links":`)
+
+ t.Run("Unauthenticated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+}
+
+func TestHumaUserChangePassword(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("Wrong old password", func(t *testing.T) {
+ // CheckUserCredentials → ErrWrongUsernameOrPassword (403).
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password",
+ `{"old_password":"invalid","new_password":"123456789"}`, token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Empty old password", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password",
+ `{"old_password":"","new_password":"123456789"}`, token, "")
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("New password too short", func(t *testing.T) {
+ // v2 maps govalidator failures (bcrypt_password) to 422, not v1's 412.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password",
+ `{"old_password":"12345678","new_password":"1234567"}`, token, "")
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Normal - run last, it changes the password", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password",
+ `{"old_password":"12345678","new_password":"123456789"}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "The password was updated successfully.")
+ })
+}
+
+func TestHumaUserUpdateEmail(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("Wrong password", func(t *testing.T) {
+ // CheckUserCredentials → ErrWrongUsernameOrPassword (403).
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email",
+ `{"new_email":"new@example.com","password":"invalid"}`, token, "")
+ assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Missing new email", func(t *testing.T) {
+ // new_email carries valid:"...,required"; v2 maps the failure to 422.
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email",
+ `{"password":"12345678"}`, token, "")
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+ t.Run("Normal", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email",
+ `{"new_email":"new@example.com","password":"12345678"}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "confirm your email address")
+ })
+}
+
+func TestHumaUserUpdateSettings(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("Normal", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general",
+ `{"name":"New Name","week_start":1,"overdue_tasks_reminders_time":"10:00","timezone":"Europe/Berlin"}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "The settings were updated successfully.")
+
+ // The change is observable through user-show.
+ show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "")
+ require.Equal(t, http.StatusOK, show.Code)
+ assert.Contains(t, show.Body.String(), `"name":"New Name"`)
+ })
+ t.Run("Frontend settings round-trip as arbitrary JSON", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general",
+ `{"overdue_tasks_reminders_time":"09:00","frontend_settings":{"color_schema":"dark","nested":{"a":1}}}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "")
+ require.Equal(t, http.StatusOK, show.Code)
+ var resp struct {
+ Settings struct {
+ FrontendSettings map[string]any `json:"frontend_settings"`
+ } `json:"settings"`
+ }
+ require.NoError(t, json.Unmarshal(show.Body.Bytes(), &resp))
+ assert.Equal(t, "dark", resp.Settings.FrontendSettings["color_schema"])
+ })
+ t.Run("Invalid week_start", func(t *testing.T) {
+ // week_start carries valid:"range(0|6)"; out of range maps to 422.
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general",
+ `{"week_start":9,"overdue_tasks_reminders_time":"09:00"}`, token, "")
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+func TestHumaUserAvatarProvider(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ t.Run("Get", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"avatar_provider":`)
+ })
+ t.Run("Set then get reflects the change", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider",
+ `{"avatar_provider":"initials"}`, token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"avatar_provider":"initials"`)
+
+ get := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "")
+ require.Equal(t, http.StatusOK, get.Code)
+ assert.Contains(t, get.Body.String(), `"avatar_provider":"initials"`)
+ })
+ t.Run("Invalid provider", func(t *testing.T) {
+ // UpdateUser rejects unknown providers with ErrInvalidAvatarProvider (412).
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider",
+ `{"avatar_provider":"nonsense"}`, token, "")
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+ })
+}
+
+func TestHumaUserTimezones(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/timezones", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ var zones []string
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &zones))
+ assert.NotEmpty(t, zones)
+ assert.Contains(t, zones, "Europe/Berlin")
+}
diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go
new file mode 100644
index 000000000..df244a23c
--- /dev/null
+++ b/pkg/webtests/huma_user_totp_test.go
@@ -0,0 +1,144 @@
+// 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 webtests
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/pquerna/otp/totp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testuser14 is a non-local (OIDC) account; totp is local-only, so every totp
+// route must refuse it. See pkg/db/fixtures/users.yml.
+var testuser14 = user.User{ID: 14, Username: "user14", Issuer: "https://some.service.com"}
+
+// TestHumaTOTP mirrors v1's TestUserTOTPLocalUser and adds the enable/disable
+// flows, the qr-code blob endpoint, and the local-account-only guard.
+//
+// Fixture topology (pkg/db/fixtures/totp.yml + users.yml):
+// - user1: totp enrolled, not enabled (secret HXDMVJEC…).
+// - user10: totp enabled (secret JBSWY3DP…), local, password 12345678.
+// - user15: local, no totp enrollment.
+// - user14: non-local (OIDC) account.
+func TestHumaTOTP(t *testing.T) {
+ t.Run("Get status for enrolled user", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp", "", humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"secret"`)
+ assert.Contains(t, rec.Body.String(), `"enabled":false`)
+ })
+
+ t.Run("Get status without enrollment returns 412", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp", "", humaTokenFor(t, &testuser15), "")
+ require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Get qr code for enrolled user", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp/qrcode", "", humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type"))
+ assert.NotEmpty(t, rec.Body.Bytes(), "the qr code jpeg must have bytes")
+ })
+
+ t.Run("Enroll a fresh user", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // user15 has no totp enrollment in the fixtures.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser15), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"secret"`)
+ assert.Contains(t, rec.Body.String(), `"url"`)
+ assert.Contains(t, rec.Body.String(), `"enabled":false`)
+ })
+
+ t.Run("Enroll when already enrolled returns 412", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Enable with a valid passcode", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // user1's fixture secret; generate a passcode that is valid right now.
+ passcode, err := totp.GenerateCode("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", time.Now())
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable",
+ fmt.Sprintf(`{"passcode":%q}`, passcode), humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "enabled successfully")
+ })
+
+ t.Run("Enable with an invalid passcode returns 412", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable",
+ `{"passcode":"000000"}`, humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("Disable with the correct password", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ // user10 has totp enabled; 12345678 is their fixture password.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable",
+ `{"password":"12345678"}`, humaTokenFor(t, &testuser10), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), "disabled successfully")
+ })
+
+ t.Run("Disable with a wrong password is refused", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable",
+ `{"password":"wrong-password"}`, humaTokenFor(t, &testuser10), "")
+ require.NotEqual(t, http.StatusOK, rec.Code, "wrong password must not disable totp; body: %s", rec.Body.String())
+ })
+
+ t.Run("Non-local user is refused on every route", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser14)
+ for _, tc := range []struct {
+ method, path, body string
+ }{
+ {http.MethodGet, "/api/v2/user/settings/totp", ""},
+ {http.MethodGet, "/api/v2/user/settings/totp/qrcode", ""},
+ {http.MethodPost, "/api/v2/user/settings/totp/enroll", ""},
+ {http.MethodPost, "/api/v2/user/settings/totp/enable", `{"passcode":"000000"}`},
+ {http.MethodPost, "/api/v2/user/settings/totp/disable", `{"password":"12345678"}`},
+ } {
+ rec := humaRequest(t, e, tc.method, tc.path, tc.body, token, "")
+ assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
+ "%s %s must refuse a non-local account; body: %s", tc.method, tc.path, rec.Body.String())
+ }
+ })
+}
diff --git a/pkg/webtests/huma_user_webhook_test.go b/pkg/webtests/huma_user_webhook_test.go
new file mode 100644
index 000000000..8c061a6ff
--- /dev/null
+++ b/pkg/webtests/huma_user_webhook_test.go
@@ -0,0 +1,189 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/routes"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaUserWebhook ports the v1 user-webhook coverage (the per-user sibling of
+// the project webhooks tested in TestHumaWebhook) to /api/v2. User webhooks live
+// at /user/settings/webhooks{,/{webhook}} — list, events, create, update, delete;
+// there is deliberately no ReadOne (webhooks carry credentials).
+//
+// Ownership gradient — a user webhook is owned by its UserID, and every Can* boils
+// down to "are you that user". Fixtures: webhooks #6/#7 belong to user6, #8 to
+// user1. The actor is user6 (not user1): the user-webhook e2e tests dispatch
+// user-directed events only for users 1 and 2, so user6-owned fixtures never fire
+// there. The point of these cases is that user6 sees and mutates only their own
+// webhooks and is forbidden on user1's.
+func TestHumaUserWebhook(t *testing.T) {
+ // availableWebhookEvents / userDirectedWebhookEvents are populated by
+ // RegisterListeners(), which the webtests harness does not call. Register the
+ // one user-directed event the fixtures and these cases use so Create/Update
+ // validation accepts it.
+ models.RegisterUserDirectedEventForWebhook(&models.TaskReminderFiredEvent{})
+
+ owner := webHandlerTestV2{
+ user: &testuser6,
+ basePath: "/api/v2/user/settings/webhooks",
+ idParam: "webhook",
+ t: t,
+ }
+ require.NoError(t, owner.ensureEnv())
+
+ t.Run("ReadAll", func(t *testing.T) {
+ t.Run("Normal - sees only own webhooks", func(t *testing.T) {
+ rec, err := owner.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ ids := webhookIDsFromReadAll(t, rec.Body.Bytes())
+ // user6 owns #6 and #7; #8 belongs to user1 and must not appear.
+ assert.ElementsMatch(t, []int64{6, 7}, ids, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"target_url"`)
+ })
+ t.Run("Secret and basic auth credentials are never exposed", func(t *testing.T) {
+ rec, err := owner.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ assert.NotContains(t, rec.Body.String(), `uwh-secret-fixture`)
+ assert.NotContains(t, rec.Body.String(), `uwh-basicauth-user`)
+ assert.NotContains(t, rec.Body.String(), `uwh-basicauth-pass`)
+ })
+ })
+
+ t.Run("Events", func(t *testing.T) {
+ // The events route reports only user-directed events. task.reminder.fired
+ // is registered above; task.updated (project-only) must not be listed.
+ token := humaTokenFor(t, &testuser6)
+ rec := humaRequest(t, owner.e, http.MethodGet, "/api/v2/user/settings/webhooks/events", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ var events []string
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events), "body: %s", rec.Body.String())
+ assert.Contains(t, events, "task.reminder.fired")
+ assert.NotContains(t, events, "task.updated")
+ })
+
+ t.Run("Create", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := owner.testCreateWithUser(nil, nil,
+ `{"target_url":"https://example.com/new","events":["task.reminder.fired"]}`)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusCreated, rec.Code)
+ assert.Contains(t, rec.Body.String(), `"target_url":"https://example.com/new"`)
+ // Ownership comes from the token, not the body.
+ assert.Contains(t, rec.Body.String(), `"user_id":6`)
+ })
+ t.Run("Secret and basic auth are not echoed back", func(t *testing.T) {
+ rec, err := owner.testCreateWithUser(nil, nil,
+ `{"target_url":"https://example.com/secret","events":["task.reminder.fired"],"secret":"top-secret","basic_auth_user":"u","basic_auth_password":"p"}`)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusCreated, rec.Code)
+ assert.NotContains(t, rec.Body.String(), `top-secret`)
+ assert.NotContains(t, rec.Body.String(), `"basic_auth_user":"u"`)
+ assert.NotContains(t, rec.Body.String(), `"basic_auth_password":"p"`)
+ })
+ t.Run("Non user-directed event rejected", func(t *testing.T) {
+ // task.updated is a project event, not user-directed; Create rejects it
+ // → InvalidFieldError, surfaced as 422 on v2.
+ _, err := owner.testCreateWithUser(nil, nil,
+ `{"target_url":"https://example.com/x","events":["task.updated"]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
+ })
+ t.Run("Missing target url", func(t *testing.T) {
+ _, err := owner.testCreateWithUser(nil, nil, `{"events":["task.reminder.fired"]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
+ })
+ })
+
+ t.Run("Update", func(t *testing.T) {
+ t.Run("Normal - only events change", func(t *testing.T) {
+ rec, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "6"},
+ `{"events":["task.reminder.fired"],"target_url":"https://example.com/ignored"}`)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Contains(t, rec.Body.String(), `"id":6`)
+
+ rec, err = owner.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `https://example.com/user-webhook-fixture`,
+ "target_url must stay the fixture value; only events are mutable")
+ assert.NotContains(t, rec.Body.String(), `https://example.com/ignored`)
+ })
+ t.Run("Cannot update another user's webhook", func(t *testing.T) {
+ // webhook #8 belongs to user1; canDoWebhook resolves ownership from the
+ // stored row, so user6 is forbidden regardless of the URL.
+ _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "8"},
+ `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ // canDoWebhook returns false for a missing webhook → 403, not 404.
+ _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "9999"},
+ `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Cannot delete another user's webhook", func(t *testing.T) {
+ _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "8"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "9999"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "7"})
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ assert.Empty(t, rec.Body.String())
+ })
+ })
+}
+
+// TestHumaUserWebhook_DisabledByConfig confirms RegisterUserWebhookRoutes skips
+// the resource when webhooks.enabled is false, so the v2 user-webhook routes 404
+// rather than running with the feature toggled off.
+func TestHumaUserWebhook_DisabledByConfig(t *testing.T) {
+ _, err := setupTestEnv()
+ require.NoError(t, err)
+
+ config.WebhooksEnabled.Set(false)
+ defer config.WebhooksEnabled.Set(true)
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+
+ token := humaTokenFor(t, &testuser1)
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/webhooks", "", token, "")
+ assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when webhooks are disabled; body: %s", rec.Body.String())
+}
diff --git a/pkg/webtests/huma_webhook_event_test.go b/pkg/webtests/huma_webhook_event_test.go
new file mode 100644
index 000000000..6db2dbc5d
--- /dev/null
+++ b/pkg/webtests/huma_webhook_event_test.go
@@ -0,0 +1,48 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaWebhookEvents covers the available-webhook-events listing. The route
+// is only registered when webhooks are enabled (the test config default).
+func TestHumaWebhookEvents(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("Returns the events", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/webhooks/events", "", humaTokenFor(t, &testuser1), "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ var events []string
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events))
+ assert.ElementsMatch(t, models.GetAvailableWebhookEvents(), events)
+ })
+ t.Run("Unauthenticated", func(t *testing.T) {
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/webhooks/events", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
+ })
+}
diff --git a/pkg/webtests/login_test.go b/pkg/webtests/login_test.go
index f271e1727..17b0f9b07 100644
--- a/pkg/webtests/login_test.go
+++ b/pkg/webtests/login_test.go
@@ -68,6 +68,22 @@ func TestLogin(t *testing.T) {
require.Error(t, err)
assertHandlerErrorCode(t, err, user.ErrCodeEmailNotConfirmed)
})
+ t.Run("disabled account", func(t *testing.T) {
+ _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{
+ "username": "user17",
+ "password": "12345678"
+}`, nil, nil)
+ require.Error(t, err)
+ assertHandlerErrorCode(t, err, user.ErrCodeAccountDisabled)
+ })
+ t.Run("locked account", func(t *testing.T) {
+ _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{
+ "username": "user18",
+ "password": "12345678"
+}`, nil, nil)
+ require.Error(t, err)
+ assertHandlerErrorCode(t, err, user.ErrCodeAccountLocked)
+ })
}
func TestLoginTOTPLockout(t *testing.T) {
diff --git a/veans/go.mod b/veans/go.mod
index c025994dc..88e1cfca7 100644
--- a/veans/go.mod
+++ b/veans/go.mod
@@ -3,9 +3,12 @@ module code.vikunja.io/veans
go 1.25.0
require (
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
github.com/magefile/mage v1.17.2
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
+ github.com/sahilm/fuzzy v0.1.2
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/sys v0.43.0
@@ -14,8 +17,24 @@ require (
)
require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/x/ansi v0.10.1 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/text v0.3.8 // indirect
)
diff --git a/veans/go.sum b/veans/go.sum
index 3e0c9d612..5490b412a 100644
--- a/veans/go.sum
+++ b/veans/go.sum
@@ -1,3 +1,17 @@
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
@@ -5,17 +19,40 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ=
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww=
+github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -24,14 +61,22 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/veans/internal/bootstrap/bootstrap.go b/veans/internal/bootstrap/bootstrap.go
index 3a9381e6e..a1251be89 100644
--- a/veans/internal/bootstrap/bootstrap.go
+++ b/veans/internal/bootstrap/bootstrap.go
@@ -31,7 +31,6 @@ import (
"io"
"os"
"regexp"
- "sort"
"strconv"
"strings"
@@ -40,6 +39,7 @@ import (
"code.vikunja.io/veans/internal/config"
"code.vikunja.io/veans/internal/credentials"
"code.vikunja.io/veans/internal/output"
+ "code.vikunja.io/veans/internal/picker"
"code.vikunja.io/veans/internal/status"
)
@@ -388,44 +388,29 @@ func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompte
}
active = append(active, pr)
}
- sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title })
-
- // The "create a new project" option sits at len(active)+1 in the menu;
- // when the user has nothing to pick from, it's the only choice.
- createIdx := len(active) + 1
if len(active) == 0 {
fmt.Fprintln(out, "No projects yet — let's create one.")
return createProject(ctx, c, p, out)
}
- fmt.Fprintln(out, "Available projects:")
- for i, pr := range active {
- ident := pr.Identifier
- if ident == "" {
- ident = "(no identifier)"
- }
- fmt.Fprintf(out, " [%d] #%d %s — %s\n", i+1, pr.ID, pr.Title, ident)
- }
- fmt.Fprintf(out, " [%d] Create a new project\n", createIdx)
-
- choice, err := p.ReadLine("Pick a project [1]: ")
- if err != nil {
+ // picker.Pick reads os.Stdin directly via bubbletea. The prompter's
+ // buffered reader is idle here (all earlier prompts blocked at a
+ // newline in canonical mode), so there's no buffered input to lose;
+ // the terminal is restored to canonical mode when Pick returns.
+ res, err := picker.Pick(active)
+ switch {
+ case errors.Is(err, picker.ErrCanceled):
+ return nil, output.New(output.CodeValidation, "project selection canceled")
+ case errors.Is(err, picker.ErrNotATerminal):
+ return nil, output.New(output.CodeValidation, "not a terminal — pass --project ")
+ case err != nil:
return nil, err
}
- choice = strings.TrimSpace(choice)
- idx := 1
- if choice != "" {
- v, err := strconv.Atoi(choice)
- if err != nil || v < 1 || v > createIdx {
- return nil, output.New(output.CodeValidation, "invalid project choice %q", choice)
- }
- idx = v
- }
- if idx == createIdx {
+ if res.CreateNew {
return createProject(ctx, c, p, out)
}
- return active[idx-1], nil
+ return res.Project, nil
}
// createProject prompts for the new project's title and identifier and
diff --git a/veans/internal/client/types.go b/veans/internal/client/types.go
index e661f5bc2..edcabc766 100644
--- a/veans/internal/client/types.go
+++ b/veans/internal/client/types.go
@@ -46,11 +46,13 @@ type BotUserCreate struct {
// Project mirrors pkg/models/project.Project.
type Project struct {
- ID int64 `json:"id"`
- Title string `json:"title"`
- Description string `json:"description,omitempty"`
- Identifier string `json:"identifier,omitempty"`
- IsArchived bool `json:"is_archived,omitempty"`
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description,omitempty"`
+ Identifier string `json:"identifier,omitempty"`
+ IsArchived bool `json:"is_archived,omitempty"`
+ ParentProjectID int64 `json:"parent_project_id,omitempty"`
+ Position float64 `json:"position,omitempty"`
}
// ProjectView is a saved view (Kanban/List/Gantt/Table) on a project.
diff --git a/veans/internal/picker/flatten.go b/veans/internal/picker/flatten.go
new file mode 100644
index 000000000..bb9561a4a
--- /dev/null
+++ b/veans/internal/picker/flatten.go
@@ -0,0 +1,118 @@
+// 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 picker
+
+import (
+ "unicode/utf8"
+
+ "code.vikunja.io/veans/internal/client"
+ "github.com/sahilm/fuzzy"
+)
+
+// row is one visible line in the picker. matches holds rune indexes into the
+// title for highlighting; dimmed rows are kept only as context for a matching
+// descendant and are skipped by the cursor.
+type row struct {
+ project *client.Project
+ depth int
+ dimmed bool
+ matches []int
+}
+
+// flatten walks the forest depth-first into a render list. An empty query
+// returns every node undimmed. A non-empty query fuzzy-matches each title
+// (case-insensitive, via sahilm/fuzzy) and keeps a node iff it matches or any
+// descendant is kept; a kept-but-non-matching node is dimmed context.
+func flatten(forest []*node, query string) []row {
+ if query == "" {
+ var rows []row
+ var walk func(nodes []*node)
+ walk = func(nodes []*node) {
+ for _, n := range nodes {
+ rows = append(rows, row{project: n.project, depth: n.depth})
+ walk(n.children)
+ }
+ }
+ walk(forest)
+ return rows
+ }
+
+ var rows []row
+ var walk func(n *node) bool
+ walk = func(n *node) bool {
+ matches, matched := matchTitle(query, n.project.Title)
+
+ start := len(rows)
+ rows = append(rows, row{}) // placeholder; finalized only if kept
+
+ descendantKept := false
+ for _, c := range n.children {
+ if walk(c) {
+ descendantKept = true
+ }
+ }
+
+ if !matched && !descendantKept {
+ rows = rows[:start]
+ return false
+ }
+ rows[start] = row{
+ project: n.project,
+ depth: n.depth,
+ dimmed: !matched,
+ matches: matches,
+ }
+ return true
+ }
+
+ for _, n := range forest {
+ walk(n)
+ }
+ return rows
+}
+
+// matchTitle reports whether title fuzzy-matches query and, if so, the rune
+// indexes of the matched characters. sahilm/fuzzy reports byte indexes, so we
+// translate them to rune offsets for correct highlighting of multibyte titles.
+func matchTitle(query, title string) (runeMatches []int, matched bool) {
+ results := fuzzy.Find(query, []string{title})
+ if len(results) == 0 {
+ return nil, false
+ }
+ return byteToRuneIndexes(title, results[0].MatchedIndexes), true
+}
+
+func byteToRuneIndexes(s string, byteIdx []int) []int {
+ if len(byteIdx) == 0 {
+ return nil
+ }
+ want := make(map[int]bool, len(byteIdx))
+ for _, b := range byteIdx {
+ want[b] = true
+ }
+ out := make([]int, 0, len(byteIdx))
+ runePos := 0
+ for b := 0; b < len(s); {
+ if want[b] {
+ out = append(out, runePos)
+ }
+ _, size := utf8.DecodeRuneInString(s[b:])
+ b += size
+ runePos++
+ }
+ return out
+}
diff --git a/veans/internal/picker/flatten_test.go b/veans/internal/picker/flatten_test.go
new file mode 100644
index 000000000..fe60411fb
--- /dev/null
+++ b/veans/internal/picker/flatten_test.go
@@ -0,0 +1,122 @@
+// 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 picker
+
+import (
+ "reflect"
+ "testing"
+
+ "code.vikunja.io/veans/internal/client"
+)
+
+func sampleForest() []*node {
+ return buildForest([]*client.Project{
+ proj(1, 0, 1, "Backend"),
+ proj(2, 1, 1, "Frontend"),
+ proj(3, 1, 2, "Database"),
+ proj(4, 0, 2, "Marketing"),
+ })
+}
+
+func rowTitles(rows []row) []string {
+ out := make([]string, len(rows))
+ for i, r := range rows {
+ out[i] = r.project.Title
+ }
+ return out
+}
+
+func TestFlatten_EmptyQuery(t *testing.T) {
+ rows := flatten(sampleForest(), "")
+ wantTitles := []string{"Backend", "Frontend", "Database", "Marketing"}
+ if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) {
+ t.Fatalf("titles: got %v, want %v", got, wantTitles)
+ }
+ wantDepths := []int{0, 1, 1, 0}
+ for i, r := range rows {
+ if r.depth != wantDepths[i] {
+ t.Errorf("row %d depth = %d, want %d", i, r.depth, wantDepths[i])
+ }
+ if r.dimmed {
+ t.Errorf("row %d should not be dimmed on empty query", i)
+ }
+ if r.matches != nil {
+ t.Errorf("row %d should have nil matches on empty query", i)
+ }
+ }
+}
+
+func TestFlatten_DeepChildSurfacesDimmedAncestor(t *testing.T) {
+ // "Frontend" is a child of "Backend"; matching it must keep "Backend"
+ // as a dimmed context row.
+ rows := flatten(sampleForest(), "frontend")
+ wantTitles := []string{"Backend", "Frontend"}
+ if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) {
+ t.Fatalf("titles: got %v, want %v", got, wantTitles)
+ }
+ if !rows[0].dimmed {
+ t.Error("ancestor Backend should be dimmed (context only)")
+ }
+ if rows[1].dimmed {
+ t.Error("matching Frontend should not be dimmed")
+ }
+}
+
+func TestFlatten_MatchingNodeCarriesMatchIndexes(t *testing.T) {
+ rows := flatten(sampleForest(), "front")
+ var frontend *row
+ for i := range rows {
+ if rows[i].project.Title == "Frontend" {
+ frontend = &rows[i]
+ }
+ }
+ if frontend == nil {
+ t.Fatal("Frontend row missing")
+ }
+ // "front" should match the leading runes of "Frontend".
+ want := []int{0, 1, 2, 3, 4}
+ if !reflect.DeepEqual(frontend.matches, want) {
+ t.Fatalf("matches: got %v, want %v", frontend.matches, want)
+ }
+}
+
+func TestFlatten_NonMatchingSiblingsDropped(t *testing.T) {
+ // Matching "Marketing" must not pull in "Backend"/"Frontend"/"Database".
+ rows := flatten(sampleForest(), "marketing")
+ wantTitles := []string{"Marketing"}
+ if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) {
+ t.Fatalf("titles: got %v, want %v", got, wantTitles)
+ }
+}
+
+func TestFlatten_NoMatchYieldsEmpty(t *testing.T) {
+ rows := flatten(sampleForest(), "zzzzz")
+ if len(rows) != 0 {
+ t.Fatalf("expected no rows, got %v", rowTitles(rows))
+ }
+}
+
+func TestFlatten_CaseInsensitive(t *testing.T) {
+ lower := flatten(sampleForest(), "backend")
+ upper := flatten(sampleForest(), "BACKEND")
+ if !reflect.DeepEqual(rowTitles(lower), rowTitles(upper)) {
+ t.Fatalf("case sensitivity differs: %v vs %v", rowTitles(lower), rowTitles(upper))
+ }
+ if len(lower) == 0 {
+ t.Fatal("expected at least one match for 'backend'")
+ }
+}
diff --git a/veans/internal/picker/model.go b/veans/internal/picker/model.go
new file mode 100644
index 000000000..4139d0f14
--- /dev/null
+++ b/veans/internal/picker/model.go
@@ -0,0 +1,238 @@
+// 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 picker
+
+import (
+ "fmt"
+ "strings"
+
+ "code.vikunja.io/veans/internal/client"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+const maxVisibleRows = 12
+
+var (
+ dimStyle = lipgloss.NewStyle().Faint(true)
+ matchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
+ cursorMark = "❯"
+)
+
+// model is the bubbletea state for the picker. The pinned "create a new
+// project" entry is the trailing row with a nil project; it is always
+// selectable and never filtered out.
+type model struct {
+ forest []*node
+ query string
+ rows []row
+ cursor int // index into rows, always on a selectable row
+ offset int // first visible row index
+
+ result *client.Project
+ createNew bool
+ canceled bool
+}
+
+func newModel(forest []*node) *model {
+ m := &model{forest: forest}
+ m.recompute()
+ return m
+}
+
+func (m *model) recompute() {
+ rows := flatten(m.forest, m.query)
+ rows = append(rows, row{project: nil}) // pinned create row
+ m.rows = rows
+ // recompute only runs when the query changes (or on init), so snap to the
+ // first match. Keeping the old cursor could leave it on the trailing create
+ // row after the list narrows, making Enter create a project instead of
+ // picking the visible match.
+ m.cursor = 0
+ m.offset = 0
+ m.clampCursor()
+ m.ensureVisible()
+}
+
+func (r row) isCreate() bool { return r.project == nil }
+
+func (r row) selectable() bool { return r.isCreate() || !r.dimmed }
+
+func (m *model) clampCursor() {
+ if m.cursor >= len(m.rows) {
+ m.cursor = len(m.rows) - 1
+ }
+ if m.cursor < 0 {
+ m.cursor = 0
+ }
+ if m.rows[m.cursor].selectable() {
+ return
+ }
+ // Snap to the nearest selectable row, preferring downward.
+ for i := m.cursor; i < len(m.rows); i++ {
+ if m.rows[i].selectable() {
+ m.cursor = i
+ return
+ }
+ }
+ for i := m.cursor; i >= 0; i-- {
+ if m.rows[i].selectable() {
+ m.cursor = i
+ return
+ }
+ }
+}
+
+func (m *model) moveCursor(delta int) {
+ i := m.cursor
+ for {
+ i += delta
+ if i < 0 || i >= len(m.rows) {
+ return
+ }
+ if m.rows[i].selectable() {
+ m.cursor = i
+ m.ensureVisible()
+ return
+ }
+ }
+}
+
+func (m *model) ensureVisible() {
+ if m.cursor < m.offset {
+ m.offset = m.cursor
+ }
+ if m.cursor >= m.offset+maxVisibleRows {
+ m.offset = m.cursor - maxVisibleRows + 1
+ }
+ if m.offset < 0 {
+ m.offset = 0
+ }
+}
+
+func (m *model) Init() tea.Cmd { return nil }
+
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ key, ok := msg.(tea.KeyMsg)
+ if !ok {
+ return m, nil
+ }
+ switch key.String() {
+ case "ctrl+c", "esc":
+ m.canceled = true
+ return m, tea.Quit
+ case "enter":
+ sel := m.rows[m.cursor]
+ if sel.isCreate() {
+ m.createNew = true
+ } else {
+ m.result = sel.project
+ }
+ return m, tea.Quit
+ case "up":
+ m.moveCursor(-1)
+ case "down":
+ m.moveCursor(1)
+ case "backspace":
+ if m.query != "" {
+ r := []rune(m.query)
+ m.query = string(r[:len(r)-1])
+ m.recompute()
+ }
+ default:
+ // Treat printable runes and space as query input.
+ if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
+ runes := key.Runes
+ // KeySpace is not guaranteed to populate key.Runes; substitute a
+ // literal space so multi-word fuzzy queries still work.
+ if key.Type == tea.KeySpace && len(runes) == 0 {
+ runes = []rune{' '}
+ }
+ m.query += string(runes)
+ m.recompute()
+ }
+ }
+ return m, nil
+}
+
+func (m *model) View() string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "> %s\n", m.query)
+
+ end := min(m.offset+maxVisibleRows, len(m.rows))
+ for i := m.offset; i < end; i++ {
+ b.WriteString(m.renderRow(i))
+ b.WriteByte('\n')
+ }
+
+ fmt.Fprintf(&b, "%d/%d ↑↓ move ⏎ pick esc cancel\n", m.cursor+1, len(m.rows))
+ return b.String()
+}
+
+func (m *model) renderRow(i int) string {
+ r := m.rows[i]
+
+ marker := " "
+ if i == m.cursor {
+ marker = cursorMark + " "
+ }
+
+ indent := strings.Repeat(" ", r.depth)
+
+ var label string
+ switch {
+ case r.isCreate():
+ label = "Create a new project"
+ case r.dimmed:
+ label = dimStyle.Render(r.project.Title + projectSuffix(r.project))
+ default:
+ label = highlight(r.project.Title, r.matches) + dimStyle.Render(projectSuffix(r.project))
+ }
+
+ return marker + indent + label
+}
+
+// projectSuffix is the dimmed metadata appended to a project row. Titles aren't
+// unique in Vikunja, so the id (and identifier when set) keeps duplicate-titled
+// projects distinguishable during init.
+func projectSuffix(p *client.Project) string {
+ s := fmt.Sprintf(" #%d", p.ID)
+ if p.Identifier != "" {
+ s += " " + p.Identifier
+ }
+ return s
+}
+
+// highlight bolds the matched runes of title. matches are rune indexes.
+func highlight(title string, matches []int) string {
+ if len(matches) == 0 {
+ return title
+ }
+ matchSet := make(map[int]bool, len(matches))
+ for _, idx := range matches {
+ matchSet[idx] = true
+ }
+ var b strings.Builder
+ for i, r := range []rune(title) {
+ if matchSet[i] {
+ b.WriteString(matchStyle.Render(string(r)))
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
diff --git a/veans/internal/picker/picker.go b/veans/internal/picker/picker.go
new file mode 100644
index 000000000..ee373e1a4
--- /dev/null
+++ b/veans/internal/picker/picker.go
@@ -0,0 +1,71 @@
+// 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 picker
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "code.vikunja.io/veans/internal/client"
+ tea "github.com/charmbracelet/bubbletea"
+ "golang.org/x/term"
+)
+
+// Result is what the user chose: an existing project or the create-new action.
+type Result struct {
+ Project *client.Project
+ CreateNew bool
+}
+
+var (
+ // ErrCanceled is returned when the user dismisses the picker (Esc / Ctrl-C).
+ ErrCanceled = errors.New("selection canceled")
+ // ErrNotATerminal is returned when stdin is not a TTY, so the interactive
+ // picker can't run — callers should fall back to `--project `.
+ ErrNotATerminal = errors.New("not a terminal")
+)
+
+// Pick runs the interactive project picker over projects and returns the
+// user's choice. Output is written to stderr (prompts go to stderr by
+// convention) and the terminal is left in canonical mode on exit.
+func Pick(projects []*client.Project) (Result, error) {
+ // The picker reads stdin and draws to stderr; both must be a TTY, else it
+ // would run invisibly (e.g. stderr redirected to a file) and look hung.
+ if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) {
+ return Result{}, ErrNotATerminal
+ }
+
+ m := newModel(buildForest(projects))
+ prog := tea.NewProgram(m, tea.WithInput(os.Stdin), tea.WithOutput(os.Stderr))
+ final, err := prog.Run()
+ if err != nil {
+ return Result{}, fmt.Errorf("run project picker: %w", err)
+ }
+
+ fm, ok := final.(*model)
+ if !ok {
+ return Result{}, fmt.Errorf("project picker returned unexpected model type %T", final)
+ }
+ if fm.canceled {
+ return Result{}, ErrCanceled
+ }
+ if fm.createNew {
+ return Result{CreateNew: true}, nil
+ }
+ return Result{Project: fm.result}, nil
+}
diff --git a/veans/internal/picker/tree.go b/veans/internal/picker/tree.go
new file mode 100644
index 000000000..1835badea
--- /dev/null
+++ b/veans/internal/picker/tree.go
@@ -0,0 +1,78 @@
+// 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 picker renders an interactive, hierarchical, fuzzy-searchable
+// project picker for `veans init`. The pure tree/flatten logic is split from
+// the bubbletea TUI so it stays unit-testable.
+package picker
+
+import (
+ "sort"
+
+ "code.vikunja.io/veans/internal/client"
+)
+
+type node struct {
+ project *client.Project
+ depth int
+ children []*node
+}
+
+// buildForest turns a flat project slice into a depth-annotated forest. A
+// project whose ParentProjectID is absent from the input becomes a root —
+// this mirrors the frontend's effective-parent behavior so children of a
+// hidden or archived parent don't vanish. Siblings are ordered by Position,
+// tie-broken by Title.
+func buildForest(projects []*client.Project) []*node {
+ byID := make(map[int64]*node, len(projects))
+ for _, p := range projects {
+ if p == nil {
+ continue
+ }
+ byID[p.ID] = &node{project: p}
+ }
+
+ var roots []*node
+ for _, p := range projects {
+ if p == nil {
+ continue
+ }
+ n := byID[p.ID]
+ parent, ok := byID[p.ParentProjectID]
+ if p.ParentProjectID == 0 || !ok {
+ roots = append(roots, n)
+ continue
+ }
+ parent.children = append(parent.children, n)
+ }
+
+ sortAndAssignDepth(roots, 0)
+ return roots
+}
+
+func sortAndAssignDepth(nodes []*node, depth int) {
+ sort.SliceStable(nodes, func(i, j int) bool {
+ a, b := nodes[i].project, nodes[j].project
+ if a.Position != b.Position {
+ return a.Position < b.Position
+ }
+ return a.Title < b.Title
+ })
+ for _, n := range nodes {
+ n.depth = depth
+ sortAndAssignDepth(n.children, depth+1)
+ }
+}
diff --git a/veans/internal/picker/tree_test.go b/veans/internal/picker/tree_test.go
new file mode 100644
index 000000000..d2874f949
--- /dev/null
+++ b/veans/internal/picker/tree_test.go
@@ -0,0 +1,129 @@
+// 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 picker
+
+import (
+ "reflect"
+ "strconv"
+ "testing"
+
+ "code.vikunja.io/veans/internal/client"
+)
+
+func proj(id, parent int64, pos float64, title string) *client.Project {
+ return &client.Project{ID: id, ParentProjectID: parent, Position: pos, Title: title}
+}
+
+// titlesWithDepth flattens a forest depth-first into "title@depth" tokens.
+func titlesWithDepth(forest []*node) []string {
+ var out []string
+ var walk func(nodes []*node)
+ walk = func(nodes []*node) {
+ for _, n := range nodes {
+ out = append(out, n.project.Title+"@"+strconv.Itoa(n.depth))
+ walk(n.children)
+ }
+ }
+ walk(forest)
+ return out
+}
+
+func TestBuildForest_SingleRoot(t *testing.T) {
+ forest := buildForest([]*client.Project{proj(1, 0, 1, "Root")})
+ got := titlesWithDepth(forest)
+ want := []string{"Root@0"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestBuildForest_Nested(t *testing.T) {
+ forest := buildForest([]*client.Project{
+ proj(1, 0, 1, "Root"),
+ proj(2, 1, 1, "Child"),
+ proj(3, 2, 1, "Grandchild"),
+ })
+ got := titlesWithDepth(forest)
+ want := []string{"Root@0", "Child@1", "Grandchild@2"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestBuildForest_MultipleRoots(t *testing.T) {
+ forest := buildForest([]*client.Project{
+ proj(1, 0, 2, "Beta"),
+ proj(2, 0, 1, "Alpha"),
+ })
+ got := titlesWithDepth(forest)
+ // Roots are sorted by position: Alpha (pos 1) before Beta (pos 2).
+ want := []string{"Alpha@0", "Beta@0"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestBuildForest_SiblingOrderPositionThenTitle(t *testing.T) {
+ forest := buildForest([]*client.Project{
+ proj(1, 0, 0, "Root"),
+ proj(2, 1, 2, "C"),
+ proj(3, 1, 1, "B"),
+ // same position as B — tie-break by title puts A before B.
+ proj(4, 1, 1, "A"),
+ })
+ got := titlesWithDepth(forest)
+ want := []string{"Root@0", "A@1", "B@1", "C@1"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestBuildForest_OrphanBecomesRoot(t *testing.T) {
+ // Parent 99 is not in the input set — child should surface as a root.
+ forest := buildForest([]*client.Project{
+ proj(1, 0, 1, "Root"),
+ proj(2, 99, 2, "Orphan"),
+ })
+ got := titlesWithDepth(forest)
+ want := []string{"Root@0", "Orphan@0"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestBuildForest_DepthCorrectness(t *testing.T) {
+ forest := buildForest([]*client.Project{
+ proj(1, 0, 1, "A"),
+ proj(2, 1, 1, "B"),
+ proj(3, 2, 1, "C"),
+ proj(4, 3, 1, "D"),
+ })
+ depthOf := map[string]int{}
+ var walk func(nodes []*node)
+ walk = func(nodes []*node) {
+ for _, n := range nodes {
+ depthOf[n.project.Title] = n.depth
+ walk(n.children)
+ }
+ }
+ walk(forest)
+ for title, want := range map[string]int{"A": 0, "B": 1, "C": 2, "D": 3} {
+ if depthOf[title] != want {
+ t.Errorf("depth of %q = %d, want %d", title, depthOf[title], want)
+ }
+ }
+}