From a221a15ec3e691b91673a9752279f69520666875 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 14:21:29 +0200 Subject: [PATCH 01/67] feat(client): add parent_project_id and position to Project wire type The init project picker needs the parent/child relationship and sibling ordering to render projects hierarchically like the web sidebar. --- veans/internal/client/types.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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. From 3462e24ec7ba3d0d970d56e9433f50b624ca9ce8 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 14:21:29 +0200 Subject: [PATCH 02/67] feat(picker): add hierarchical fuzzy project picker Interactive bubbletea picker that renders projects as an indented tree (siblings by position then title, orphans re-parented to root) and fuzzy-filters as you type, keeping matched rows' ancestors visible as dimmed context. Pure tree/flatten logic is split from the TUI and unit-tested. --- veans/go.mod | 19 ++ veans/go.sum | 45 +++++ veans/internal/picker/flatten.go | 118 +++++++++++++ veans/internal/picker/flatten_test.go | 122 +++++++++++++ veans/internal/picker/model.go | 238 ++++++++++++++++++++++++++ veans/internal/picker/picker.go | 71 ++++++++ veans/internal/picker/tree.go | 78 +++++++++ veans/internal/picker/tree_test.go | 129 ++++++++++++++ 8 files changed, 820 insertions(+) create mode 100644 veans/internal/picker/flatten.go create mode 100644 veans/internal/picker/flatten_test.go create mode 100644 veans/internal/picker/model.go create mode 100644 veans/internal/picker/picker.go create mode 100644 veans/internal/picker/tree.go create mode 100644 veans/internal/picker/tree_test.go 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/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) + } + } +} From e271f75cad3030478af13a1cead94446f3feb61a Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 14:21:29 +0200 Subject: [PATCH 03/67] feat(init): use the hierarchical fuzzy picker for project selection Replaces the flat numbered project list during 'veans init' with the interactive picker. --project still bypasses it; non-TTY stdin fails cleanly asking for --project. --- veans/internal/bootstrap/bootstrap.go | 43 +++++++++------------------ 1 file changed, 14 insertions(+), 29 deletions(-) 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 From da3bf0e7cd1fb7f72f4ea259890f0151d2d3ffa1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:11:43 +0200 Subject: [PATCH 04/67] docs(api/v2): tag CalDAV token fields for the v2 schema --- pkg/user/token.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From a562f69f02f49b2365b32c8e0de5cdd5449cacd1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:11:43 +0200 Subject: [PATCH 05/67] feat(api/v2): add CalDAV tokens on /api/v2 --- pkg/routes/api/v2/caldav_tokens.go | 121 ++++++++++++++++++ pkg/webtests/huma_caldav_token_test.go | 165 +++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 pkg/routes/api/v2/caldav_tokens.go create mode 100644 pkg/webtests/huma_caldav_token_test.go 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/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 +} From 4afcfa44416fa0a30204d200bf105aeaef858a92 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:14:52 +0200 Subject: [PATCH 06/67] docs(api/v2): tag TOTP fields for the v2 schema --- pkg/user/totp.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/user/totp.go b/pkg/user/totp.go index 66abb813c..e18948443 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -37,11 +37,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 From 190fab8e6d7d77b5ac82ac2609bbce6af4227ea3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:14:57 +0200 Subject: [PATCH 07/67] feat(api/v2): add TOTP 2FA on /api/v2 Ports the current-user TOTP (2FA) endpoints from /api/v1 to the Huma-backed /api/v2: get status, enroll, enable, and disable. Each is a custom, current-user-scoped handler that resolves the authenticated user and refuses non-local (OIDC/LDAP) accounts, preserving v1's local-account-only guard. The image/jpeg QR-code endpoint is intentionally not ported here; it is a binary-streaming route deferred to a later wave. --- pkg/routes/api/v2/user_totp.go | 210 ++++++++++++++++++++++++++++ pkg/webtests/huma_user_totp_test.go | 135 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 pkg/routes/api/v2/user_totp.go create mode 100644 pkg/webtests/huma_user_totp_test.go diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go new file mode 100644 index 000000000..d998a524e --- /dev/null +++ b/pkg/routes/api/v2/user_totp.go @@ -0,0 +1,210 @@ +// 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 +} + +// RegisterTOTPRoutes wires the current-user totp (2FA) operations onto the Huma +// API. Totp is a local-account feature; every handler rejects OIDC/LDAP users. +// The QR-code blob endpoint is intentionally not ported here (binary streaming, +// handled in a later wave). +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) +} + +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 +} diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go new file mode 100644 index 000000000..d5cc82f15 --- /dev/null +++ b/pkg/webtests/huma_user_totp_test.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 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 plus the local-account-only guard. The QR-code endpoint is not ported +// to v2 (binary streaming, later wave), so there is no test for it here. +// +// 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("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.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()) + } + }) +} From a610ccbbac67d073600fdf49d0a2bb26b7528641 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:17:50 +0200 Subject: [PATCH 08/67] feat(api/v2): add user webhooks on /api/v2 Port the per-user webhook endpoints (/user/settings/webhooks) from /api/v1 to the Huma-backed /api/v2: list, available events, create, update, delete. They are the project-less sibling of the project webhooks (#2858) and share the webhooks.enabled gate, checked inside the registrar. Webhook.ReadAll is extended to serve the user-level list (scoped to the authenticated user) so the v2 list handler can go through handler.DoReadAll like the project list; the project branch is unchanged. Credentials are masked on read via the model's existing maskCredentials, matching #2858. --- pkg/db/fixtures/webhooks.yml | 38 +++++ pkg/models/webhooks.go | 31 ++-- pkg/routes/api/v2/user_webhooks.go | 167 ++++++++++++++++++++++ pkg/webtests/huma_user_webhook_test.go | 189 +++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 pkg/routes/api/v2/user_webhooks.go create mode 100644 pkg/webtests/huma_user_webhook_test.go diff --git a/pkg/db/fixtures/webhooks.yml b/pkg/db/fixtures/webhooks.yml index 4ec5687c7..983a03aff 100644 --- a/pkg/db/fixtures/webhooks.yml +++ b/pkg/db/fixtures/webhooks.yml @@ -41,3 +41,41 @@ created_by_id: 3 created: 2024-01-01 00:00:00 updated: 2024-01-01 00:00:00 +# Webhooks 6-8 are user-level (project_id null, user_id set) and back the v2 +# user-webhook tests. #6/#7 belong to user6; #6 carries credentials so masking +# can be asserted. #8 belongs to user1 so the owner-isolation check (user6 must +# not see or mutate another user's webhook) has a target. +# +# Event choice matters because the pkg/e2etests user-webhook suite shares these +# fixtures and dispatches real events. The WebhookListener fans a fired event out +# to ALL of the event-user's webhooks, asynchronously; a user-level fixture +# subscribed to a user-directed event the suite dispatches for its owner fires a +# real (failing) delivery to example.com, and that in-flight write then races the +# next test's fixture reload ("database table is locked: webhooks"). The suite +# dispatches user-directed events only for user1, so #6/#7 are owned by user6, and +# #8 (owned by user1) subscribes to task.updated — a project-only event the +# listener never matches for user webhooks. None of the three can fire there. +- id: 6 + target_url: "https://example.com/user-webhook-fixture" + events: '["task.reminder.fired"]' + user_id: 6 + secret: "uwh-secret-fixture" + basic_auth_user: "uwh-basicauth-user" + basic_auth_password: "uwh-basicauth-pass" + created_by_id: 6 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 +- id: 7 + target_url: "https://example.com/user-webhook-second" + events: '["task.reminder.fired"]' + user_id: 6 + created_by_id: 6 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 +- id: 8 + target_url: "https://example.com/user-webhook-other" + events: '["task.updated"]' + user_id: 1 + created_by_id: 1 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 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/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/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()) +} From b8894ac1c1cac2e38fcc7b31085e249e16461953 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:12:53 +0200 Subject: [PATCH 09/67] feat(api/v2): add user account-deletion flow on /api/v2 --- pkg/routes/api/v2/user_deletion.go | 172 +++++++++++++++++++++ pkg/webtests/huma_user_deletion_test.go | 194 ++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 pkg/routes/api/v2/user_deletion.go create mode 100644 pkg/webtests/huma_user_deletion_test.go 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/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()) + }) + } +} From 154a96674d6ffb9c131b5fb03bba7f3683776f44 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 22:34:42 +0200 Subject: [PATCH 10/67] fix(notifications): strip remote images from notification emails User-controlled fields rendered into notification emails (task title via the conversational header, comment and description bodies) were sanitized with a bluemonday UGCPolicy that permits remote sources. An attacker with write access to a shared project could therefore inject an external image that acts as a tracking pixel in a subscriber's inbox, leaking email-open time and IP. Restrict notification-email images to inline data URIs (used by avatars) by adding a RewriteSrc hook that blanks any non-data image src. The policy was duplicated in three places, so extract it into newNotificationSanitizer. Refs GHSA-2vr2-r3qw-rjvq --- pkg/notifications/mail_render.go | 33 +++++++++++--------- pkg/notifications/mail_test.go | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 15 deletions(-) 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,") + }) +} From 46b07a019cbe50d92db2e9003a0a23173ee59d2d Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 22:16:53 +0200 Subject: [PATCH 11/67] refactor(user): extract shared account orchestration into models/user/shared for v1+v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the business logic out of the v1 current-user account/settings handlers into reusable functions so both v1 and the upcoming v2 handlers call one implementation. No behavior change — the v1 handlers keep their HTTP-layer quirks (input binding, validation, error mapping); only orchestration moves. Homes are forced by the import graph: - shared.GetAuthProviderName (new pkg/routes/api/shared, above openid+user so it can combine both without a cycle; routes-only helper) - user.ChangeUserEmail (CheckUserCredentials + UpdateEmail, both in user) - models.ChangeUserPassword (needs models.DeleteAllUserSessions; user can't import models) - models.UpdateUserGeneralSettings / UpdateUserAvatarProvider (need avatar.FlushAllCaches; user can't import avatar) The general settings get a single shared wire struct, models.UserGeneralSettings (tagged for both swaggo/govalidator and Huma): it is the update request body and the nested settings on GET /user for v1 (replacing v1's UserSettings) and v2. ExtraSettingsLinks is readOnly — populated from the user on read, ignored on write. A dedicated struct is required because user.User's settings fields are json:"-" so they don't leak when it is embedded in other responses. --- .golangci.yml | 4 + pkg/models/user_settings.go | 128 ++++++++++++++++++++++ pkg/routes/api/shared/auth_provider.go | 54 +++++++++ pkg/routes/api/v1/user_settings.go | 70 +----------- pkg/routes/api/v1/user_show.go | 58 ++-------- pkg/routes/api/v1/user_update_email.go | 12 +- pkg/routes/api/v1/user_update_password.go | 18 +-- pkg/user/update_email.go | 11 ++ 8 files changed, 212 insertions(+), 143 deletions(-) create mode 100644 pkg/models/user_settings.go create mode 100644 pkg/routes/api/shared/auth_provider.go diff --git a/.golangci.yml b/.golangci.yml index 6f1a759f2..552e13cb7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -145,6 +145,10 @@ linters: - revive path: pkg/utils/* text: 'var-naming: avoid meaningless package names' + - linters: + - revive + path: pkg/routes/api/shared/* + text: 'var-naming: avoid meaningless package names' - linters: - revive text: 'var-naming: avoid package names that conflict with Go standard library package names' diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go new file mode 100644 index 000000000..cba87cdb5 --- /dev/null +++ b/pkg/models/user_settings.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 models + +import ( + "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(s *xorm.Session, u *user.User, oldPassword, newPassword string) error { + if oldPassword == "" { + return user.ErrEmptyOldPassword{} + } + + if _, err := user.CheckUserCredentials(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/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/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_update_email.go b/pkg/routes/api/v1/user_update_email.go index ea1077075..e73ba6f89 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(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..52941a48a 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(s, doer, newPW.OldPassword, newPW.NewPassword); err != nil { _ = s.Rollback() return err } diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index 73e104682..b721ba518 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -31,6 +31,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(s *xorm.Session, u *User, password, newEmail string) error { + verified, err := CheckUserCredentials(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) { From 28af57bc9362a78b2549dec9267d89fca1de1c6e Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 22:16:57 +0200 Subject: [PATCH 12/67] feat(api/v2): add user account/settings on /api/v2 Port the current-user account and settings endpoints from /api/v1 to the Huma-backed /api/v2, calling the shared orchestration extracted into models/user/openid: - GET /user current user + settings + computed auth_provider/is_local_user/is_admin - POST /user/password change password (200, creates nothing) - PUT /user/settings/email update email (kicks off confirmation) - PUT /user/settings/general update general settings - GET /user/settings/avatar/provider get avatar provider - PUT /user/settings/avatar/provider set avatar provider - GET /user/timezones list available time zones These are current-user-scoped custom handlers (no per-resource Can*): each pulls the authed user from the request context and operates on it. The avatar provider get/set live on /user/settings/avatar/provider because v2 already maps /user/settings/avatar to the binary avatar upload (PUT). --- pkg/routes/api/v2/user_settings.go | 334 ++++++++++++++++++++++++ pkg/webtests/huma_user_settings_test.go | 195 ++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 pkg/routes/api/v2/user_settings.go create mode 100644 pkg/webtests/huma_user_settings_test.go diff --git a/pkg/routes/api/v2/user_settings.go b/pkg/routes/api/v2/user_settings.go new file mode 100644 index 000000000..a1f5bbee4 --- /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(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(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/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") +} From 05b10e34d89967bc91d455074084b1483e7017f6 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Thu, 11 Jun 2026 07:42:32 +0000 Subject: [PATCH 13/67] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 100 ++++++++++++++++++--------------------- pkg/swagger/swagger.json | 100 ++++++++++++++++++--------------------- pkg/swagger/swagger.yaml | 81 +++++++++++++------------------ 3 files changed, 122 insertions(+), 159 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 874d9ec09..7921a5fe3 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -7848,7 +7848,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -10630,6 +10630,49 @@ const docTemplate = `{ } } }, + "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": { @@ -11027,59 +11070,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": { @@ -11117,7 +11107,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.", diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 1d9d15f49..fc04db4bf 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -7840,7 +7840,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -10622,6 +10622,49 @@ } } }, + "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": { @@ -11019,59 +11062,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": { @@ -11109,7 +11099,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.", diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 6bc114729..0482c4e30 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1333,6 +1333,36 @@ definitions: this value. type: string 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: @@ -1640,53 +1670,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: @@ -1717,7 +1700,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. @@ -7199,7 +7182,7 @@ paths: name: avatar required: true schema: - $ref: '#/definitions/v1.UserSettings' + $ref: '#/definitions/models.UserGeneralSettings' produces: - application/json responses: From a88aef0e47bb33b51fb80064b8b3c3dad5a0732c Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:51:41 +0200 Subject: [PATCH 14/67] fix(deps): update shell-quote to 1.8.4 --- frontend/pnpm-lock.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e790717ee..cdcc554cb 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6095,8 +6095,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.1: - resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} shiki@3.2.1: resolution: {integrity: sha512-VML/2o1/KGYkEf/stJJ+s9Ypn7jUKQPomGLGYso4JJFMFxVDyPNsjsI3MB3KLjlMOeH44gyaPdXC6rik2WXvUQ==} @@ -12034,7 +12035,7 @@ snapshots: launch-editor@2.10.0: dependencies: picocolors: 1.1.1 - shell-quote: 1.8.1 + shell-quote: 1.8.4 leven@3.1.0: {} @@ -13325,7 +13326,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.1: {} + shell-quote@1.8.4: {} shiki@3.2.1: dependencies: From 070ce192865903ad93e633caa1c9c2f2af7f96b1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:52:42 +0000 Subject: [PATCH 15/67] chore(deps): update dev-dependencies --- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 152 ++++++++++++++++++++-------------------- 2 files changed, 78 insertions(+), 78 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 2344362ae..b5243e44f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -114,7 +114,7 @@ "@tsconfig/node24": "24.0.4", "@types/codemirror": "5.60.17", "@types/is-touch-device": "1.0.3", - "@types/node": "24.13.1", + "@types/node": "24.13.2", "@types/sortablejs": "1.15.9", "@types/ws": "8.18.1", "@typescript-eslint/eslint-plugin": "8.61.0", @@ -126,7 +126,7 @@ "@vueuse/shared": "14.3.0", "autoprefixer": "10.5.0", "browserslist": "4.28.2", - "caniuse-lite": "1.0.30001797", + "caniuse-lite": "1.0.30001799", "csstype": "3.2.3", "esbuild": "0.28.0", "eslint": "9.39.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index cdcc554cb..2369c33e4 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -180,10 +180,10 @@ importers: version: 10.4.0 '@histoire/plugin-screenshot': specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3) '@histoire/plugin-vue': specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@playwright/test': specifier: 1.58.2 version: 1.58.2 @@ -192,7 +192,7 @@ importers: version: 3.6.1 '@tailwindcss/vite': specifier: 4.3.0 - version: 4.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + version: 4.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@tsconfig/node24': specifier: 24.0.4 version: 24.0.4 @@ -203,8 +203,8 @@ importers: specifier: 1.0.3 version: 1.0.3 '@types/node': - specifier: 24.13.1 - version: 24.13.1 + specifier: 24.13.2 + version: 24.13.2 '@types/sortablejs': specifier: 1.15.9 version: 1.15.9 @@ -219,7 +219,7 @@ importers: version: 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-vue': specifier: 6.0.7 - version: 6.0.7(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.7(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-typescript': specifier: 14.8.0 version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) @@ -239,8 +239,8 @@ importers: specifier: 4.28.2 version: 4.28.2 caniuse-lite: - specifier: 1.0.30001797 - version: 1.0.30001797 + specifier: 1.0.30001799 + version: 1.0.30001799 csstype: specifier: 3.2.3 version: 3.2.3 @@ -261,7 +261,7 @@ importers: version: 20.10.2 histoire: specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + version: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) otplib: specifier: 12.0.1 version: 12.0.1 @@ -312,19 +312,19 @@ importers: version: 3.0.0 vite: specifier: 7.3.5 - version: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + version: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vite-plugin-pwa: specifier: 1.3.0 - version: 1.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1) + version: 1.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1) vite-plugin-vue-devtools: specifier: 8.1.2 - version: 8.1.2(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 8.1.2(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) vite-svg-loader: specifier: 5.1.1 version: 5.1.1(vue@3.5.27(typescript@5.9.3)) vitest: specifier: 4.1.8 - version: 4.1.8(@types/node@24.13.1)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + version: 4.1.8(@types/node@24.13.2)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: specifier: 3.3.4 version: 3.3.4(typescript@5.9.3) @@ -2890,8 +2890,8 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - '@types/node@24.13.1': - resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==} + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3525,8 +3525,8 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001797: - resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} capture-website@4.2.0: resolution: {integrity: sha512-EmkSn36CXTC8tUsS6aNmvvsdpfVTYYkuRp7U5bV9gcJwcDbqqA5c0Op/iskYPKtDdOkuVp61mjn/LLywX0h7cw==} @@ -8745,17 +8745,17 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 - '@histoire/app@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/app@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 fuse.js: 7.1.0 shiki: 3.2.1 transitivePeerDependencies: - vite - '@histoire/controls@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/controls@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@codemirror/commands': 6.8.1 '@codemirror/lang-json': 6.0.1 @@ -8764,17 +8764,17 @@ snapshots: '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.36.5 - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 transitivePeerDependencies: - vite - '@histoire/plugin-screenshot@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3)': + '@histoire/plugin-screenshot@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3)': dependencies: capture-website: 4.2.0(typescript@5.9.3) defu: 6.1.7 fs-extra: 11.2.0 - histoire: 1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + histoire: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) pathe: 1.1.2 transitivePeerDependencies: - bare-buffer @@ -8783,21 +8783,21 @@ snapshots: - typescript - utf-8-validate - '@histoire/plugin-vue@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': + '@histoire/plugin-vue@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 change-case: 5.4.4 globby: 14.1.0 - histoire: 1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + histoire: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) launch-editor: 2.10.0 pathe: 1.1.2 vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - vite - '@histoire/shared@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/shared@1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@histoire/vendors': 1.0.0-beta.1 '@types/fs-extra': 11.0.4 @@ -8805,7 +8805,7 @@ snapshots: chokidar: 4.0.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@histoire/vendors@1.0.0-beta.1': {} @@ -9433,12 +9433,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@tiptap/core@3.17.0(@tiptap/pm@3.17.0)': dependencies: @@ -9670,7 +9670,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/hast@3.0.4': dependencies: @@ -9682,7 +9682,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/linkify-it@5.0.0': {} @@ -9699,7 +9699,7 @@ snapshots: '@types/minimist@1.2.5': {} - '@types/node@24.13.1': + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 @@ -9723,11 +9723,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/yauzl@2.10.3': dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 optional: true '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': @@ -9954,10 +9954,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vue: 3.5.27(typescript@5.9.3) '@vitest/expect@4.1.8': @@ -9969,13 +9969,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@vitest/mocker@4.1.8(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@vitest/pretty-format@4.1.8': dependencies: @@ -10294,7 +10294,7 @@ snapshots: autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 - caniuse-lite: 1.0.30001797 + caniuse-lite: 1.0.30001799 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.14 @@ -10413,7 +10413,7 @@ snapshots: browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.12 - caniuse-lite: 1.0.30001797 + caniuse-lite: 1.0.30001799 electron-to-chromium: 1.5.329 node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -10424,7 +10424,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 buffer@5.7.1: dependencies: @@ -10476,7 +10476,7 @@ snapshots: camelcase@8.0.0: {} - caniuse-lite@1.0.30001797: {} + caniuse-lite@1.0.30001799: {} capture-website@4.2.0(typescript@5.9.3): dependencies: @@ -11505,7 +11505,7 @@ snapshots: happy-dom@20.10.2: dependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -11566,12 +11566,12 @@ snapshots: highlight.js@11.11.1: {} - histoire@1.0.0-beta.1(@types/node@24.13.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3): + histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@akryum/tinypool': 0.3.1 - '@histoire/app': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/app': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 '@types/markdown-it': 14.1.2 birpc: 0.2.19 @@ -11596,8 +11596,8 @@ snapshots: sade: 1.8.1 shiki: 3.2.1 sirv: 3.0.2 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - '@exodus/crypto' - '@types/node' @@ -14038,23 +14038,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-dev-rpc@1.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-dev-rpc@1.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: birpc: 2.6.1 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-hot-client: 2.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-hot-client: 2.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - vite-hot-client@2.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-hot-client@2.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-node@3.2.4(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): + vite-node@3.2.4(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -14069,7 +14069,7 @@ snapshots: - tsx - yaml - vite-plugin-inspect@11.3.3(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-plugin-inspect@11.3.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: ansis: 4.1.0 debug: 4.4.3 @@ -14079,37 +14079,37 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.2 unplugin-utils: 0.3.0 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-dev-rpc: 1.1.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-dev-rpc: 1.1.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) transitivePeerDependencies: - supports-color - vite-plugin-pwa@1.3.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1): + vite-plugin-pwa@1.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) workbox-build: 7.4.1 workbox-window: 7.4.1 transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.1.2(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): + vite-plugin-vue-devtools@8.1.2(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): dependencies: '@vue/devtools-core': 8.1.2(vue@3.5.27(typescript@5.9.3)) '@vue/devtools-kit': 8.1.2 '@vue/devtools-shared': 8.1.2 sirv: 3.0.2 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-plugin-inspect: 11.3.3(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - vite-plugin-vue-inspector: 6.0.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-plugin-inspect: 11.3.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite-plugin-vue-inspector: 6.0.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) transitivePeerDependencies: - '@nuxt/kit' - supports-color - vue - vite-plugin-vue-inspector@6.0.0(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vite-plugin-vue-inspector@6.0.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: '@babel/core': 7.26.0 '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.26.0) @@ -14120,7 +14120,7 @@ snapshots: '@vue/compiler-dom': 3.5.27 kolorist: 1.8.0 magic-string: 0.30.21 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -14132,7 +14132,7 @@ snapshots: transitivePeerDependencies: - supports-color - vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): + vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: esbuild: 0.27.5 fdir: 6.5.0(picomatch@4.0.4) @@ -14141,7 +14141,7 @@ snapshots: rollup: 4.61.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 @@ -14150,10 +14150,10 @@ snapshots: terser: 5.31.6 yaml: 2.8.3 - vitest@4.1.8(@types/node@24.13.1)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vitest@4.1.8(@types/node@24.13.2)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -14170,10 +14170,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.13.1 + '@types/node': 24.13.2 happy-dom: 20.10.2 jsdom: 27.4.0 transitivePeerDependencies: From 3a84c491aeacd83080849deeb3e4b0caff9ef50c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 14:06:35 +0200 Subject: [PATCH 16/67] feat(models): let TaskCollection force a flat task list v1's TaskCollection.ReadAll is polymorphic: a kanban view returns []*Bucket, everything else []*Task. v2 splits the task list into a flat-tasks endpoint and a separate buckets-with-tasks endpoint, so the flat endpoint needs ReadAll to return tasks even for a kanban view. SetForceFlatTasks toggles that; v1 leaves it unset and keeps its shape. --- pkg/models/task_collection.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 833cc7851..bc217f7ca 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -58,6 +58,12 @@ type TaskCollection struct { isSavedFilter bool + // forceFlatTasks makes ReadAll always return []*Task, never []*Bucket, even + // for a kanban view. v1's single tasks endpoint is polymorphic; v2 splits it + // into a flat-tasks endpoint and a separate buckets-with-tasks one, and the + // former sets this so a kanban view path still yields tasks. + forceFlatTasks bool + web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` } @@ -149,8 +155,14 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie return opts, err } -func getTaskOrTasksInBuckets(s *xorm.Session, a web.Auth, projects []*Project, view *ProjectView, opts *taskSearchOptions, filteringForBucket bool) (tasks interface{}, resultCount int, totalItems int64, err error) { - if filteringForBucket { +// SetForceFlatTasks makes ReadAll return a flat []*Task even for a kanban view. +// The v2 tasks endpoint uses it; v1 leaves it unset for the polymorphic shape. +func (tf *TaskCollection) SetForceFlatTasks() { + tf.forceFlatTasks = true +} + +func getTaskOrTasksInBuckets(s *xorm.Session, a web.Auth, projects []*Project, view *ProjectView, opts *taskSearchOptions, filteringForBucket, forceFlatTasks bool) (tasks interface{}, resultCount int, totalItems int64, err error) { + if filteringForBucket || forceFlatTasks { return getTasksForProjects(s, projects, a, opts, view) } @@ -280,6 +292,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa tc.ProjectID = tf.ProjectID tc.isSavedFilter = true tc.Expand = tf.Expand + tc.forceFlatTasks = tf.forceFlatTasks if tf.Filter != "" { if tc.Filter != "" { @@ -372,7 +385,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa if err != nil { return nil, 0, 0, err } - return getTaskOrTasksInBuckets(s, a, []*Project{project}, view, opts, filteringForBucket) + return getTaskOrTasksInBuckets(s, a, []*Project{project}, view, opts, filteringForBucket, tf.forceFlatTasks) } projects, err := getRelevantProjectsFromCollection(s, a, tf) @@ -380,5 +393,5 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa return nil, 0, 0, err } - return getTaskOrTasksInBuckets(s, a, projects, view, opts, filteringForBucket) + return getTaskOrTasksInBuckets(s, a, projects, view, opts, filteringForBucket, tf.forceFlatTasks) } From 3bd75acabf2e1d4298bea28ed4da091c72b36317 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 14:06:43 +0200 Subject: [PATCH 17/67] feat(api/v2): add task collection (task lists) on /api/v2 Ports v1's task-list surface to /api/v2 as four endpoints. v1 served a single polymorphic endpoint; v2 makes it monomorphic: GET /tasks flat []*Task, all projects GET /projects/{project}/tasks flat []*Task GET /projects/{project}/views/{view}/tasks flat []*Task (even kanban) GET /projects/{project}/views/{view}/buckets/tasks []*Bucket with tasks The three task endpoints force flat tasks via TaskCollection so a kanban view path no longer returns buckets; the dedicated buckets endpoint keeps the polymorphic kanban branch and is not paginated (bounded by the view's bucket config). Search is exposed as q; multi-value sort_by/order_by/expand use ,explode. Hitting the buckets endpoint with a non-kanban view is a 400 rather than a type-mismatch 500. --- pkg/routes/api/v2/task_collection.go | 243 +++++++++++++++++++++ pkg/webtests/huma_task_collection_test.go | 248 ++++++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 pkg/routes/api/v2/task_collection.go create mode 100644 pkg/webtests/huma_task_collection_test.go diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go new file mode 100644 index 000000000..a0cdda758 --- /dev/null +++ b/pkg/routes/api/v2/task_collection.go @@ -0,0 +1,243 @@ +// 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."` + } +} + +// The three task-list input structs below each repeat ListParams + the six +// query fields INLINE. They can't share that set through an embed: in this Huma +// version a second/nested anonymous embed alongside ListParams is silently +// dropped from request binding and the OpenAPI spec (verified against the +// generated spec). A single shared input struct doesn't work either — Huma +// lists every path:"" field regardless of the route template, so a shared +// project/view field leaks onto a narrower route as a phantom path param. The +// structs differ only in their path params; taskListViewInput is shared by both +// view-scoped endpoints. + +type taskListAllInput 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 taskListProjectInput struct { + ProjectID int64 `path:"project" doc:"The numeric id of the project."` + 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 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."` + 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."` +} + +// 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/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`) + }) +} From 809ac118f9854350866a361ef15feb974accfd04 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:15:09 +0200 Subject: [PATCH 18/67] refactor(api/v2): dedup task collection query params via exported embed --- pkg/routes/api/v2/task_collection.go | 42 +++++++++++----------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go index a0cdda758..1a379dbe6 100644 --- a/pkg/routes/api/v2/task_collection.go +++ b/pkg/routes/api/v2/task_collection.go @@ -43,17 +43,17 @@ type bucketsWithTasksBody struct { } } -// The three task-list input structs below each repeat ListParams + the six -// query fields INLINE. They can't share that set through an embed: in this Huma -// version a second/nested anonymous embed alongside ListParams is silently -// dropped from request binding and the OpenAPI spec (verified against the -// generated spec). A single shared input struct doesn't work either — Huma -// lists every path:"" field regardless of the route template, so a shared -// project/view field leaks onto a narrower route as a phantom path param. The -// structs differ only in their path params; taskListViewInput is shared by both -// view-scoped endpoints. - -type taskListAllInput struct { +// 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\"."` @@ -63,27 +63,19 @@ type taskListAllInput struct { 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."` - 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."` + 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."` - 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."` + TaskListQueryParams } // taskListFilters is the bound query carried into the shared collection builder. From 9c3c1047ac309c3e6524d84eeeffc4b97c98cf0e Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:29:34 +0200 Subject: [PATCH 19/67] feat(api/v2): port OAuth migrators (Todoist, Trello, Microsoft To-Do) Add /api/v2 auth/status/migrate endpoints for the three OAuth-based migrators. One generic helper registers all three ops per migrator behind its static config gate, so there's no copy-pasted block per migrator. The migrate kick-off orchestration (already-running guard + event dispatch) is extracted into migrationHandler.StartMigration so v1 and v2 share it; v1's wire output is unchanged. The guard now surfaces as a typed migration.ErrMigrationAlreadyRunning (412) so v2 can translate it through the standard error bridge. --- pkg/modules/migration/errors.go | 23 +++ pkg/modules/migration/handler/handler.go | 31 +++- pkg/modules/migration/migration_status.go | 8 +- pkg/routes/api/v2/migration_oauth.go | 167 ++++++++++++++++++++++ pkg/webtests/huma_migration_oauth_test.go | 153 ++++++++++++++++++++ 5 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 pkg/routes/api/v2/migration_oauth.go create mode 100644 pkg/webtests/huma_migration_oauth_test.go 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/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/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/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()) +} From e25f997281a1b7ff3aeb9b303507af052513f6a7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:41:39 +0200 Subject: [PATCH 20/67] refactor(admin): extract shared admin overview, user-create and user-view helpers Move the admin overview computation and struct into models.BuildOverview / models.Overview, the admin create-user flow into models.CreateUserAsAdmin / models.CreateUserBody, and the admin user response view into a new pkg/routes/api/shared package (shared.AdminUser / shared.NewAdminUser) so both the v1 and v2 admin routes call the same code. The v1 handlers are refactored onto these helpers and stay byte-identical on the wire. --- pkg/models/admin_overview.go | 83 ++++++++++++++++++++++++++ pkg/models/admin_user_create.go | 80 +++++++++++++++++++++++++ pkg/routes/api/shared/admin_user.go | 66 ++++++++++++++++++++ pkg/routes/api/v1/admin/overview.go | 60 ++----------------- pkg/routes/api/v1/admin/user_create.go | 63 ++----------------- pkg/routes/api/v1/admin/users.go | 47 ++------------- pkg/routes/api/v1/admin/users_admin.go | 6 +- pkg/routes/api/v1/admin/users_mgmt.go | 6 +- 8 files changed, 252 insertions(+), 159 deletions(-) create mode 100644 pkg/models/admin_overview.go create mode 100644 pkg/models/admin_user_create.go create mode 100644 pkg/routes/api/shared/admin_user.go diff --git a/pkg/models/admin_overview.go b/pkg/models/admin_overview.go new file mode 100644 index 000000000..082d6c81d --- /dev/null +++ b/pkg/models/admin_overview.go @@ -0,0 +1,83 @@ +// 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 ( + "code.vikunja.io/api/pkg/license" + + "xorm.io/xorm" +) + +type ShareCounts struct { + LinkShares int64 `json:"link_shares" readOnly:"true" doc:"Number of link shares across all projects."` + TeamShares int64 `json:"team_shares" readOnly:"true" doc:"Number of team-project shares."` + UserShares int64 `json:"user_shares" readOnly:"true" doc:"Number of user-project shares."` +} + +type Overview struct { + Users int64 `json:"users" readOnly:"true" doc:"Total number of user accounts."` + Projects int64 `json:"projects" readOnly:"true" doc:"Total number of projects."` + Tasks int64 `json:"tasks" readOnly:"true" doc:"Total number of tasks."` + Teams int64 `json:"teams" readOnly:"true" doc:"Total number of teams."` + Shares ShareCounts `json:"shares" readOnly:"true" doc:"Aggregate share counts."` + License license.Info `json:"license" readOnly:"true" doc:"Snapshot of the instance license state."` +} + +// BuildOverview returns aggregate instance counts plus the current license snapshot. +func BuildOverview(s *xorm.Session) (*Overview, error) { + users, err := s.Table("users").Count() + if err != nil { + return nil, err + } + projects, err := s.Table("projects").Count() + if err != nil { + return nil, err + } + tasks, err := s.Table("tasks").Count() + if err != nil { + return nil, err + } + teams, err := s.Table("teams").Count() + if err != nil { + return nil, err + } + linkShares, err := s.Table("link_shares").Count() + if err != nil { + return nil, err + } + teamShares, err := s.Table("team_projects").Count() + if err != nil { + return nil, err + } + userShares, err := s.Table("users_projects").Count() + if err != nil { + return nil, err + } + + return &Overview{ + Users: users, + Projects: projects, + Tasks: tasks, + Teams: teams, + Shares: ShareCounts{ + LinkShares: linkShares, + TeamShares: teamShares, + UserShares: userShares, + }, + License: license.CurrentInfo(), + }, nil +} diff --git a/pkg/models/admin_user_create.go b/pkg/models/admin_user_create.go new file mode 100644 index 000000000..a54d328a0 --- /dev/null +++ b/pkg/models/admin_user_create.go @@ -0,0 +1,80 @@ +// 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 ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// CreateUserBody wraps user.APIUserPassword with admin-only fields. +type CreateUserBody struct { + // The full name of the new user. Optional. + Name string `json:"name" doc:"The full name of the new user. Optional."` + // 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:"IETF BCP 47 language code; must exist in Vikunja."` + user.APIUserPassword + // Mark the new user as an instance admin. + IsAdmin bool `json:"is_admin" doc:"Mark the new user as an instance admin."` + // Activate the new user immediately without email confirmation. + SkipEmailConfirm bool `json:"skip_email_confirm" doc:"Activate the new user immediately, skipping email confirmation."` +} + +// CreateUserAsAdmin provisions a new local account on behalf of an instance admin, +// honouring the admin-only is_admin and skip_email_confirm fields and bypassing the +// public-registration toggle. It commits s and returns the persisted user reloaded +// so the status reflects what was actually stored. +func CreateUserAsAdmin(s *xorm.Session, body *CreateUserBody) (*user.User, error) { + newUser, err := RegisterUser(s, &user.User{ + Username: body.Username, + Password: body.Password, + Email: body.Email, + Name: body.Name, + Language: body.Language, + }) + if err != nil { + return nil, err + } + + if body.IsAdmin { + if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil { + return nil, 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 { + return nil, err + } + newUser.Status = user.StatusActive + } + + if err := s.Commit(); err != nil { + return nil, err + } + + // Reload on a fresh session so the returned status reflects what was actually + // persisted (e.g. StatusEmailConfirmationRequired on mail-enabled instances). + rs := db.NewSession() + defer rs.Close() + return user.GetUserByID(rs, newUser.ID) +} diff --git a/pkg/routes/api/shared/admin_user.go b/pkg/routes/api/shared/admin_user.go new file mode 100644 index 000000000..eeae1c794 --- /dev/null +++ b/pkg/routes/api/shared/admin_user.go @@ -0,0 +1,66 @@ +// 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 route helpers used by both /api/v1 and /api/v2 so the two +// versions render identical responses without one importing the other. +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/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..30b25917c 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] @@ -92,5 +94,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..489df40e8 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] @@ -96,7 +98,7 @@ func PatchStatus(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)) } // DeleteUser removes a user either immediately or through the self-deletion flow. From 5579daa4529c4e24c226963362425d22d4b36096 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:41:47 +0200 Subject: [PATCH 21/67] feat(api/v2): add admin actions on /api/v2 Port the admin action endpoints to the Huma-backed /api/v2: - GET /admin/overview instance counts + license snapshot - POST /admin/users create a user (201) - PATCH /admin/users/{id}/admin promote/demote (*bool, nil = unchanged) - PATCH /admin/users/{id}/status set status (*Status, nil = unchanged) - DELETE /admin/users/{id} delete (mode=now|scheduled, 204) - PATCH /admin/projects/{id}/owner reassign project owner All sit behind the existing gateV2AdminRoutes path middleware (admin + license gate, 404 on failure), so no per-handler permission checks are added. The hand-registered PATCH routes carry genuine partial semantics, which AutoPatch does not synthesise. The admin user response reuses the existing pkg/routes/api/shared package. --- pkg/routes/api/shared/admin_user.go | 2 - pkg/routes/api/v2/admin_projects.go | 45 +++ pkg/routes/api/v2/admin_users.go | 274 +++++++++++++++++ pkg/webtests/huma_admin_actions_test.go | 387 ++++++++++++++++++++++++ 4 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 pkg/routes/api/v2/admin_users.go create mode 100644 pkg/webtests/huma_admin_actions_test.go diff --git a/pkg/routes/api/shared/admin_user.go b/pkg/routes/api/shared/admin_user.go index eeae1c794..4459f2698 100644 --- a/pkg/routes/api/shared/admin_user.go +++ b/pkg/routes/api/shared/admin_user.go @@ -14,8 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package shared holds route helpers used by both /api/v1 and /api/v2 so the two -// versions render identical responses without one importing the other. package shared import ( 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..347636444 --- /dev/null +++ b/pkg/routes/api/v2/admin_users.go @@ -0,0 +1,274 @@ +// 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 +} + +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"}) + } + + s := db.NewSession() + defer s.Close() + + target, err := adminLoadUser(s, in.ID) + if err != nil { + return nil, translateDomainError(err) + } + + if !*in.Body.IsAdmin { + if err := user.GuardLastAdmin(s, target); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + target.IsAdmin = *in.Body.IsAdmin + if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return adminUserResponse(target) //nolint:contextcheck // see adminUserResponse. +} + +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"}) + } + + s := db.NewSession() + defer s.Close() + + target, err := adminLoadUser(s, in.ID) + if err != nil { + return nil, translateDomainError(err) + } + + // 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 nil, translateDomainError(err) + } + } + + if err := user.SetUserStatus(s, target, newStatus); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + // Refresh locally since GetUserByID refuses disabled accounts. + target.Status = newStatus + return adminUserResponse(target) //nolint:contextcheck // see adminUserResponse. +} + +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() + + target, err := adminLoadUser(s, in.ID) + if err != nil { + return nil, translateDomainError(err) + } + + if err := user.GuardLastAdmin(s, target); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if mode == "now" { + err = models.DeleteUser(s, target) + } else { + err = user.RequestDeletion(s, target) + } + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +// adminLoadUser fetches a user by ID, returning ErrUserDoesNotExist for an +// invalid ID or a missing row (matching v1's 404). +func adminLoadUser(s *xorm.Session, id int64) (*user.User, error) { + if id < 1 { + return nil, user.ErrUserDoesNotExist{UserID: id} + } + target := &user.User{ID: id} + has, err := s.Get(target) + if err != nil { + return nil, err + } + if !has { + return nil, user.ErrUserDoesNotExist{UserID: id} + } + return target, nil +} + +// adminUserResponse builds the admin user view from an already-mutated user. +func adminUserResponse(target *user.User) (*adminUserBody, error) { + 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/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) + }) +} From 5b3ee89edd8957522a3f9ade159deb5e2c2ee850 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 20:49:03 +0200 Subject: [PATCH 22/67] refactor(api/v2): dedup the admin user-mutation handlers The patch-admin, patch-status and delete-user handlers each repeated the same session open/load/commit/rollback scaffold. Extract it into adminMutateUser, which owns the transaction and takes a closure for each handler's distinct guard-and-write step. --- pkg/routes/api/v2/admin_users.go | 101 ++++++++++++++----------------- 1 file changed, 44 insertions(+), 57 deletions(-) diff --git a/pkg/routes/api/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go index 347636444..172586d76 100644 --- a/pkg/routes/api/v2/admin_users.go +++ b/pkg/routes/api/v2/admin_users.go @@ -138,28 +138,18 @@ func adminUsersPatchAdmin(_ context.Context, in *struct { return nil, translateDomainError(models.ErrInvalidData{Message: "is_admin is required"}) } - s := db.NewSession() - defer s.Close() - - target, err := adminLoadUser(s, in.ID) - if err != nil { - return nil, translateDomainError(err) - } - - if !*in.Body.IsAdmin { - if err := user.GuardLastAdmin(s, target); err != nil { - _ = s.Rollback() - return nil, translateDomainError(err) + target, err := adminMutateUser(in.ID, func(s *xorm.Session, target *user.User) error { + if !*in.Body.IsAdmin { + if err := user.GuardLastAdmin(s, target); err != nil { + return err + } } - } - - target.IsAdmin = *in.Body.IsAdmin - if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil { - _ = s.Rollback() - return nil, translateDomainError(err) - } - if err := s.Commit(); err != nil { - return nil, translateDomainError(err) + target.IsAdmin = *in.Body.IsAdmin + _, err := s.ID(target.ID).Cols("is_admin").Update(target) + return err + }) + if err != nil { + return nil, err } return adminUserResponse(target) //nolint:contextcheck // see adminUserResponse. @@ -177,28 +167,17 @@ func adminUsersPatchStatus(_ context.Context, in *struct { return nil, translateDomainError(models.ErrInvalidData{Message: "invalid status"}) } - s := db.NewSession() - defer s.Close() - - target, err := adminLoadUser(s, in.ID) - if err != nil { - return nil, translateDomainError(err) - } - - // 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 nil, translateDomainError(err) + target, err := adminMutateUser(in.ID, func(s *xorm.Session, target *user.User) error { + // 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 { + return err + } } - } - - if err := user.SetUserStatus(s, target, newStatus); err != nil { - _ = s.Rollback() - return nil, translateDomainError(err) - } - if err := s.Commit(); err != nil { - return nil, translateDomainError(err) + return user.SetUserStatus(s, target, newStatus) + }) + if err != nil { + return nil, err } // Refresh locally since GetUserByID refuses disabled accounts. @@ -218,33 +197,41 @@ func adminUsersDelete(_ context.Context, in *struct { return nil, translateDomainError(models.ErrInvalidData{Message: "invalid mode, expected 'now' or 'scheduled'"}) } + _, err := adminMutateUser(in.ID, func(s *xorm.Session, target *user.User) error { + if err := user.GuardLastAdmin(s, target); err != nil { + return err + } + if mode == "now" { + return models.DeleteUser(s, target) + } + return user.RequestDeletion(s, target) + }) + if err != nil { + return nil, err + } + return &emptyBody{}, nil +} + +// adminMutateUser opens a session, loads the user by ID, runs mutate against it, +// then commits — owning the transaction so each handler only supplies its +// distinct guard-and-write step. mutate must not commit or rollback. Errors +// (load, mutate, commit) are translated to RFC 9457 responses. +func adminMutateUser(id int64, mutate func(s *xorm.Session, target *user.User) error) (*user.User, error) { s := db.NewSession() defer s.Close() - target, err := adminLoadUser(s, in.ID) + target, err := adminLoadUser(s, id) if err != nil { return nil, translateDomainError(err) } - - if err := user.GuardLastAdmin(s, target); err != nil { + if err := mutate(s, target); err != nil { _ = s.Rollback() return nil, translateDomainError(err) } - - if mode == "now" { - err = models.DeleteUser(s, target) - } else { - err = user.RequestDeletion(s, target) - } - if err != nil { - _ = s.Rollback() - return nil, translateDomainError(err) - } - if err := s.Commit(); err != nil { return nil, translateDomainError(err) } - return &emptyBody{}, nil + return target, nil } // adminLoadUser fetches a user by ID, returning ErrUserDoesNotExist for an From 53d1fa0735d377f708c9cea23c72e17b5b5f9a48 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:10:16 +0200 Subject: [PATCH 23/67] refactor(admin): share user-mutation logic between v1 and v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin set-admin-flag, set-status and delete-user operations were implemented twice — once in the v1 echo handlers, once in the v2 Huma handlers. Extract the load/guard/mutate logic into models.SetUserAdminFlag, models.SetUserStatusAsAdmin and models.DeleteUserAsAdmin so both APIs call the same code; each handler keeps only its own request binding, validation and response shape. v1 stays byte-identical on the wire. --- pkg/models/admin_user_actions.go | 106 +++++++++++++++++++++++++ pkg/routes/api/v1/admin/users_admin.go | 18 +---- pkg/routes/api/v1/admin/users_mgmt.go | 44 +--------- pkg/routes/api/v2/admin_users.go | 91 +++++---------------- 4 files changed, 127 insertions(+), 132 deletions(-) create mode 100644 pkg/models/admin_user_actions.go diff --git a/pkg/models/admin_user_actions.go b/pkg/models/admin_user_actions.go new file mode 100644 index 000000000..9918eaafb --- /dev/null +++ b/pkg/models/admin_user_actions.go @@ -0,0 +1,106 @@ +// 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 ( + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// loadAdminTargetUser fetches a user by ID for the admin actions, returning +// ErrUserDoesNotExist for an invalid ID or a missing row. +func loadAdminTargetUser(s *xorm.Session, id int64) (*user.User, error) { + if id < 1 { + return nil, user.ErrUserDoesNotExist{UserID: id} + } + target := &user.User{ID: id} + has, err := s.Get(target) + if err != nil { + return nil, err + } + if !has { + return nil, user.ErrUserDoesNotExist{UserID: id} + } + return target, nil +} + +// SetUserAdminFlag sets a user's instance-admin flag. Demoting the last +// reachable admin is refused via GuardLastAdmin. It does not commit; the caller +// owns the transaction. +func SetUserAdminFlag(s *xorm.Session, id int64, isAdmin bool) (*user.User, error) { + target, err := loadAdminTargetUser(s, id) + if err != nil { + return nil, err + } + + if !isAdmin { + if err := user.GuardLastAdmin(s, target); err != nil { + return nil, err + } + } + + target.IsAdmin = isAdmin + if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil { + return nil, err + } + return target, nil +} + +// SetUserStatusAsAdmin sets a user's account status. Moving the last reachable +// admin out of Active is refused via GuardLastAdmin (any non-Active status +// blocks login, so it is equivalent to demotion). It does not commit; the caller +// owns the transaction. +func SetUserStatusAsAdmin(s *xorm.Session, id int64, status user.Status) (*user.User, error) { + target, err := loadAdminTargetUser(s, id) + if err != nil { + return nil, err + } + + if target.IsAdmin && status != user.StatusActive { + if err := user.GuardLastAdmin(s, target); err != nil { + return nil, err + } + } + + if err := user.SetUserStatus(s, target, status); err != nil { + return nil, err + } + // Reflect the change on the returned struct; GetUserByID refuses disabled accounts. + target.Status = status + return target, nil +} + +// DeleteUserAsAdmin removes a user. mode "now" deletes immediately; any other +// value triggers the email-confirmation self-deletion flow. Deleting the last +// reachable admin is refused via GuardLastAdmin. It does not commit; the caller +// owns the transaction. +func DeleteUserAsAdmin(s *xorm.Session, id int64, mode string) error { + target, err := loadAdminTargetUser(s, id) + if err != nil { + return err + } + + if err := user.GuardLastAdmin(s, target); err != nil { + return err + } + + if mode == "now" { + return DeleteUser(s, target) + } + return user.RequestDeletion(s, target) +} diff --git a/pkg/routes/api/v1/admin/users_admin.go b/pkg/routes/api/v1/admin/users_admin.go index 30b25917c..195bb2092 100644 --- a/pkg/routes/api/v1/admin/users_admin.go +++ b/pkg/routes/api/v1/admin/users_admin.go @@ -65,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 } diff --git a/pkg/routes/api/v1/admin/users_mgmt.go b/pkg/routes/api/v1/admin/users_mgmt.go index 489df40e8..1e72fa173 100644 --- a/pkg/routes/api/v1/admin/users_mgmt.go +++ b/pkg/routes/api/v1/admin/users_mgmt.go @@ -67,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 } @@ -92,8 +76,6 @@ 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 @@ -130,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/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go index 172586d76..1588b1643 100644 --- a/pkg/routes/api/v2/admin_users.go +++ b/pkg/routes/api/v2/admin_users.go @@ -137,22 +137,9 @@ func adminUsersPatchAdmin(_ context.Context, in *struct { if in.Body.IsAdmin == nil { return nil, translateDomainError(models.ErrInvalidData{Message: "is_admin is required"}) } - - target, err := adminMutateUser(in.ID, func(s *xorm.Session, target *user.User) error { - if !*in.Body.IsAdmin { - if err := user.GuardLastAdmin(s, target); err != nil { - return err - } - } - target.IsAdmin = *in.Body.IsAdmin - _, err := s.ID(target.ID).Cols("is_admin").Update(target) - return err + return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser. + return models.SetUserAdminFlag(s, in.ID, *in.Body.IsAdmin) }) - if err != nil { - return nil, err - } - - return adminUserResponse(target) //nolint:contextcheck // see adminUserResponse. } func adminUsersPatchStatus(_ context.Context, in *struct { @@ -166,23 +153,9 @@ func adminUsersPatchStatus(_ context.Context, in *struct { if newStatus < user.StatusActive || newStatus > user.StatusAccountLocked { return nil, translateDomainError(models.ErrInvalidData{Message: "invalid status"}) } - - target, err := adminMutateUser(in.ID, func(s *xorm.Session, target *user.User) error { - // 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 { - return err - } - } - return user.SetUserStatus(s, target, newStatus) + return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser. + return models.SetUserStatusAsAdmin(s, in.ID, newStatus) }) - if err != nil { - return nil, err - } - - // Refresh locally since GetUserByID refuses disabled accounts. - target.Status = newStatus - return adminUserResponse(target) //nolint:contextcheck // see adminUserResponse. } func adminUsersDelete(_ context.Context, in *struct { @@ -197,62 +170,34 @@ func adminUsersDelete(_ context.Context, in *struct { return nil, translateDomainError(models.ErrInvalidData{Message: "invalid mode, expected 'now' or 'scheduled'"}) } - _, err := adminMutateUser(in.ID, func(s *xorm.Session, target *user.User) error { - if err := user.GuardLastAdmin(s, target); err != nil { - return err - } - if mode == "now" { - return models.DeleteUser(s, target) - } - return user.RequestDeletion(s, target) - }) - if err != nil { - return nil, err - } - return &emptyBody{}, nil -} - -// adminMutateUser opens a session, loads the user by ID, runs mutate against it, -// then commits — owning the transaction so each handler only supplies its -// distinct guard-and-write step. mutate must not commit or rollback. Errors -// (load, mutate, commit) are translated to RFC 9457 responses. -func adminMutateUser(id int64, mutate func(s *xorm.Session, target *user.User) error) (*user.User, error) { s := db.NewSession() defer s.Close() - - target, err := adminLoadUser(s, id) - if err != nil { - return nil, translateDomainError(err) - } - if err := mutate(s, target); err != nil { + 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 target, nil + return &emptyBody{}, nil } -// adminLoadUser fetches a user by ID, returning ErrUserDoesNotExist for an -// invalid ID or a missing row (matching v1's 404). -func adminLoadUser(s *xorm.Session, id int64) (*user.User, error) { - if id < 1 { - return nil, user.ErrUserDoesNotExist{UserID: id} - } - target := &user.User{ID: id} - has, err := s.Get(target) +// 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 { - return nil, err + _ = s.Rollback() + return nil, translateDomainError(err) } - if !has { - return nil, user.ErrUserDoesNotExist{UserID: id} + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) } - return target, nil -} -// adminUserResponse builds the admin user view from an already-mutated user. -func adminUserResponse(target *user.User) (*adminUserBody, error) { 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) From adc8070ff9fa6dcd757683bbdd4a2b5f48431a7b Mon Sep 17 00:00:00 2001 From: Milad Nazari Date: Wed, 3 Jun 2026 13:33:03 +0330 Subject: [PATCH 24/67] feat(i18n): add persian to list of selectable languages --- frontend/src/i18n/index.ts | 3 ++- frontend/src/i18n/useDayjsLanguageSync.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 703a9940f..d7dae0060 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -30,6 +30,7 @@ export const SUPPORTED_LOCALES = { 'ja-JP': '日本語', 'hu-HU': 'Magyar', 'ar-SA': 'اَلْعَرَبِيَّةُ', + 'fa-IR': 'فارسی', 'sl-SI': 'Slovenščina', 'pt-BR': 'Português Brasileiro', 'hr-HR': 'Hrvatski', @@ -52,7 +53,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en' export type ISOLanguage = string -const RTL_LANGUAGES = ['ar-SA', 'he-IL'] as const +const RTL_LANGUAGES = ['ar-SA', 'he-IL', 'fa-IR'] as const export function isRTLLanguage(locale: SupportedLocale): boolean { return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number]) diff --git a/frontend/src/i18n/useDayjsLanguageSync.ts b/frontend/src/i18n/useDayjsLanguageSync.ts index 21e3fd7d6..792a284cd 100644 --- a/frontend/src/i18n/useDayjsLanguageSync.ts +++ b/frontend/src/i18n/useDayjsLanguageSync.ts @@ -22,6 +22,7 @@ export const DAYJS_LOCALE_MAPPING = { 'ja-jp': 'ja', 'hu-hu': 'hu', 'ar-sa': 'ar-sa', + 'fa-ir': 'fa', 'sl-si': 'sl', 'pt-br': 'pt', 'hr-hr': 'hr', @@ -55,6 +56,7 @@ export const DAYJS_LANGUAGE_IMPORTS = { 'ja-jp': () => import('dayjs/locale/ja'), 'hu-hu': () => import('dayjs/locale/hu'), 'ar-sa': () => import('dayjs/locale/ar-sa'), + 'fa-ir': () => import('dayjs/locale/fa'), 'sl-si': () => import('dayjs/locale/sl'), 'pt-br': () => import('dayjs/locale/pt-br'), 'hr-hr': () => import('dayjs/locale/hr'), From 1cf10b563af410548b71864da85d9717a07aab66 Mon Sep 17 00:00:00 2001 From: Milad Nazari Date: Wed, 3 Jun 2026 13:33:41 +0330 Subject: [PATCH 25/67] fix(frontend): fix buttons alignments in rtl direction --- frontend/src/components/tasks/partials/RelatedTasks.vue | 2 +- frontend/src/styles/theme/helpers.scss | 6 +++++- frontend/src/views/labels/ListLabels.vue | 2 +- frontend/src/views/teams/ListTeams.vue | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tasks/partials/RelatedTasks.vue b/frontend/src/components/tasks/partials/RelatedTasks.vue index 8e81a2083..0bc8c296e 100644 --- a/frontend/src/components/tasks/partials/RelatedTasks.vue +++ b/frontend/src/components/tasks/partials/RelatedTasks.vue @@ -4,7 +4,7 @@ v-if="editEnabled && Object.keys(relatedTasks).length > 0" id="showRelatedTasksFormButton" v-tooltip="$t('task.relation.add')" - class="is-pulled-right add-task-relation-button d-print-none" + class="is-pulled-end add-task-relation-button d-print-none" :class="{'is-active': showNewRelationForm}" variant="secondary" icon="plus" diff --git a/frontend/src/styles/theme/helpers.scss b/frontend/src/styles/theme/helpers.scss index b2238a00b..5e8685da2 100644 --- a/frontend/src/styles/theme/helpers.scss +++ b/frontend/src/styles/theme/helpers.scss @@ -4,6 +4,10 @@ } } -.is-pulled-right { +.is-pulled-end { float: right !important; } + +[dir="rtl"] .is-pulled-end { + float: left !important; +} diff --git a/frontend/src/views/labels/ListLabels.vue b/frontend/src/views/labels/ListLabels.vue index 30ae58645..b86dedd1d 100644 --- a/frontend/src/views/labels/ListLabels.vue +++ b/frontend/src/views/labels/ListLabels.vue @@ -5,7 +5,7 @@ > {{ $t('label.create.header') }} diff --git a/frontend/src/views/teams/ListTeams.vue b/frontend/src/views/teams/ListTeams.vue index 5b5d8077d..00c31703a 100644 --- a/frontend/src/views/teams/ListTeams.vue +++ b/frontend/src/views/teams/ListTeams.vue @@ -5,7 +5,7 @@ > {{ $t('team.create.title') }} From ea0c9fbe94092cdd8a84ad616c81fce10357aa6a Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Thu, 11 Jun 2026 20:24:56 +0000 Subject: [PATCH 26/67] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 264 +++++++++++++++++++-------------------- pkg/swagger/swagger.json | 264 +++++++++++++++++++-------------------- pkg/swagger/swagger.yaml | 210 +++++++++++++++---------------- 3 files changed, 369 insertions(+), 369 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 7921a5fe3..e0ef37ad7 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": { @@ -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": [ @@ -10001,6 +9936,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": [ @@ -10809,6 +10758,57 @@ 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 + } + } + }, "todoist.Migration": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index fc04db4bf..26d826b75 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": { @@ -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": [ @@ -9993,6 +9928,20 @@ } } }, + "models.ShareCounts": { + "type": "object", + "properties": { + "link_shares": { + "type": "integer" + }, + "team_shares": { + "type": "integer" + }, + "user_shares": { + "type": "integer" + } + } + }, "models.SharingType": { "type": "integer", "enum": [ @@ -10801,6 +10750,57 @@ } } }, + "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 + } + } + }, "todoist.Migration": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 0482c4e30..0e248ba95 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 @@ -835,6 +785,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 @@ -1474,6 +1433,47 @@ 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 todoist.Migration: properties: code: @@ -2001,7 +2001,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.Overview' + $ref: '#/definitions/models.Overview' "404": description: Not Found schema: @@ -2109,7 +2109,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' type: array "404": description: Not Found @@ -2131,14 +2131,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: @@ -2206,7 +2206,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' "400": description: Bad Request schema: @@ -2243,7 +2243,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' "400": description: Bad Request schema: From 6f3dab53cbcc09dc130ffca21b6c7576056e089d Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 20:40:12 +0200 Subject: [PATCH 27/67] feat(api/v2): add project background endpoints Port to /api/v2: - DELETE /projects/{project}/background (remove background, returns the updated project) - GET /backgrounds/unsplash/search (q, page; gated on the unsplash provider) - PUT /projects/{project}/backgrounds/unsplash (set, gated on the unsplash provider) Custom routes load the project and enforce CanUpdate explicitly. Backgrounds are gated on the static backgrounds config via a registrar early-return. Tag background.Image fields with doc: for the v2 schema, and add a scoped contextcheck exclusion since the unsplash provider's shared interface bottoms out in context.Background(). --- .golangci.yml | 3 + pkg/modules/background/background.go | 10 +- pkg/routes/api/v2/backgrounds.go | 190 +++++++++++++++++++++ pkg/webtests/huma_backgrounds_misc_test.go | 112 ++++++++++++ 4 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 pkg/routes/api/v2/backgrounds.go create mode 100644 pkg/webtests/huma_backgrounds_misc_test.go diff --git a/.golangci.yml b/.golangci.yml index 552e13cb7..19ee2f531 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -149,6 +149,9 @@ linters: - revive path: pkg/routes/api/shared/* text: 'var-naming: avoid meaningless package names' + - linters: + - contextcheck + path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context - linters: - revive text: 'var-naming: avoid package names that conflict with Go standard library package names' 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/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go new file mode 100644 index 000000000..c56d1acce --- /dev/null +++ b/pkg/routes/api/v2/backgrounds.go @@ -0,0 +1,190 @@ +// 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/modules/background" + "code.vikunja.io/api/pkg/modules/background/unsplash" + + "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) + + 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) + } +} + +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 +} + +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/webtests/huma_backgrounds_misc_test.go b/pkg/webtests/huma_backgrounds_misc_test.go new file mode 100644 index 000000000..8efdc3c2b --- /dev/null +++ b/pkg/webtests/huma_backgrounds_misc_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()) + }) +} From 56b1ba47ec7e260a3cc3fb468e501dab5bfea3b6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 20:41:18 +0200 Subject: [PATCH 28/67] feat(api/v2): add public instance info endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/v2/info (public — no auth). Extract the /info response type and its assembly out of the v1 handler into pkg/routes/api/shared.BuildInfo() so both API versions return byte-identical info; refactor v1's handler onto it. Add the v2 path to unauthenticatedAPIPaths. --- pkg/routes/api/shared/info.go | 164 +++++++++++++++++++++ pkg/routes/api/v1/info.go | 139 +---------------- pkg/routes/api/v2/info.go | 51 +++++++ pkg/routes/routes.go | 1 + pkg/webtests/huma_backgrounds_misc_test.go | 17 +++ 5 files changed, 236 insertions(+), 136 deletions(-) create mode 100644 pkg/routes/api/shared/info.go create mode 100644 pkg/routes/api/v2/info.go diff --git a/pkg/routes/api/shared/info.go b/pkg/routes/api/shared/info.go new file mode 100644 index 000000000..423aae2c7 --- /dev/null +++ b/pkg/routes/api/shared/info.go @@ -0,0 +1,164 @@ +// 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."` +} + +// 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(), + 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/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/v2/info.go b/pkg/routes/api/v2/info.go new file mode 100644 index 000000000..483abf027 --- /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 +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 159994724..949180b64 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -343,6 +343,7 @@ 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, } // collectRoutesForAPITokens collects all routes for API token permission checking. diff --git a/pkg/webtests/huma_backgrounds_misc_test.go b/pkg/webtests/huma_backgrounds_misc_test.go index 8efdc3c2b..57a87413d 100644 --- a/pkg/webtests/huma_backgrounds_misc_test.go +++ b/pkg/webtests/huma_backgrounds_misc_test.go @@ -17,6 +17,7 @@ package webtests import ( + "encoding/json" "net/http" "testing" @@ -29,6 +30,22 @@ import ( "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") +} + // 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. From 3312716afd65d4ac8ceb6cabea7e6ed003467052 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 20:41:57 +0200 Subject: [PATCH 29/67] feat(api/v2): add available webhook events endpoint Add GET /api/v2/webhooks/events, listing the events a webhook target can subscribe to. Gated on webhooks.enabled via a registrar early-return, mirroring v1. --- pkg/routes/api/v2/webhook_events.go | 54 ++++++++++++++++++++++ pkg/webtests/huma_backgrounds_misc_test.go | 20 ++++++++ 2 files changed, 74 insertions(+) create mode 100644 pkg/routes/api/v2/webhook_events.go 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/webtests/huma_backgrounds_misc_test.go b/pkg/webtests/huma_backgrounds_misc_test.go index 57a87413d..5fa412270 100644 --- a/pkg/webtests/huma_backgrounds_misc_test.go +++ b/pkg/webtests/huma_backgrounds_misc_test.go @@ -46,6 +46,26 @@ func TestHumaInfo(t *testing.T) { assert.Contains(t, body, "available_migrators") } +// 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()) + }) +} + // 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. From 5dcc501d54f98f8a93dcb9d18192c398ee303eaa Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 20:42:33 +0200 Subject: [PATCH 30/67] feat(api/v2): add user search endpoints Port to /api/v2: - GET /users (global user search by username/name/email; emails are blanked) - GET /projects/{project}/users/search (users with access to a project, for share autocomplete; requires project read access) Both are custom routes: the project search loads the project and enforces CanRead explicitly. --- pkg/routes/api/v2/user_search.go | 130 +++++++++++++++++++++ pkg/webtests/huma_backgrounds_misc_test.go | 63 ++++++++++ 2 files changed, 193 insertions(+) create mode 100644 pkg/routes/api/v2/user_search.go diff --git a/pkg/routes/api/v2/user_search.go b/pkg/routes/api/v2/user_search.go new file mode 100644 index 000000000..2f7ea66b5 --- /dev/null +++ b/pkg/routes/api/v2/user_search.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 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.ListUsers(s, in.Q, currentUser, nil) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + for i := range users { + users[i].Email = "" + } + + 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() + + project := &models.Project{ID: in.ProjectID} + canRead, _, err := project.CanRead(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !canRead { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + + currentUser, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + users, err := models.ListUsersFromProject(s, project, currentUser, in.Q) + 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 +} diff --git a/pkg/webtests/huma_backgrounds_misc_test.go b/pkg/webtests/huma_backgrounds_misc_test.go index 5fa412270..e276b2fdb 100644 --- a/pkg/webtests/huma_backgrounds_misc_test.go +++ b/pkg/webtests/huma_backgrounds_misc_test.go @@ -66,6 +66,53 @@ func TestHumaWebhookEvents(t *testing.T) { }) } +// 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()) + }) +} + // 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. @@ -147,3 +194,19 @@ func TestHumaUnsplashBackground(t *testing.T) { assert.Equal(t, http.StatusForbidden, 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 +} From 5807f2e7b432c4e2d152278aa411471d30e2ca48 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:08:41 +0200 Subject: [PATCH 31/67] refactor(user): share user-search logic between v1 and v2 Extract the duplicated user-search business logic into two helpers both API versions call, and refactor v1's handlers onto them: - user.SearchUsers wraps ListUsers + email obfuscation (global search) - models.SearchUsersForProject wraps the project read check + ListUsersFromProject Each handler keeps its own forbidden mapping (v1 echo.ErrForbidden vs v2 huma) so v1 stays byte-identical on the wire. --- pkg/models/user_project.go | 20 ++++++++++++++++++++ pkg/routes/api/v1/user_list.go | 21 +++++---------------- pkg/routes/api/v2/user_search.go | 26 ++++++++------------------ pkg/user/users_project.go | 14 ++++++++++++++ 4 files changed, 47 insertions(+), 34 deletions(-) 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/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/v2/user_search.go b/pkg/routes/api/v2/user_search.go index 2f7ea66b5..5848ba5c0 100644 --- a/pkg/routes/api/v2/user_search.go +++ b/pkg/routes/api/v2/user_search.go @@ -72,7 +72,7 @@ func usersSearch(ctx context.Context, in *struct { return nil, translateDomainError(err) } - users, err := user.ListUsers(s, in.Q, currentUser, nil) + users, err := user.SearchUsers(s, in.Q, currentUser) if err != nil { _ = s.Rollback() return nil, translateDomainError(err) @@ -81,10 +81,6 @@ func usersSearch(ctx context.Context, in *struct { return nil, translateDomainError(err) } - for i := range users { - users[i].Email = "" - } - return &userListBody{Body: NewPaginated(users, int64(len(users)), 1, len(users))}, nil } @@ -100,8 +96,14 @@ func projectUsersSearch(ctx context.Context, in *struct { 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} - canRead, _, err := project.CanRead(s, a) + users, canRead, err := models.SearchUsersForProject(s, project, a, currentUser, in.Q) if err != nil { _ = s.Rollback() return nil, translateDomainError(err) @@ -110,18 +112,6 @@ func projectUsersSearch(ctx context.Context, in *struct { _ = s.Rollback() return nil, huma.Error403Forbidden("forbidden") } - - currentUser, err := models.GetUserOrLinkShareUser(s, a) - if err != nil { - _ = s.Rollback() - return nil, translateDomainError(err) - } - - users, err := models.ListUsersFromProject(s, project, currentUser, in.Q) - if err != nil { - _ = s.Rollback() - return nil, translateDomainError(err) - } if err := s.Commit(); err != nil { return nil, translateDomainError(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 { From e5055d720c87d1162ad99696cd8ab47eb39f7efb Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:10:18 +0200 Subject: [PATCH 32/67] test(api/v2): split the B1 webtests into per-route files Replace huma_backgrounds_misc_test.go with huma_background_test.go, huma_info_test.go, huma_webhook_event_test.go and huma_user_search_test.go so each route area's tests live in their own file. --- ...s_misc_test.go => huma_background_test.go} | 100 ------------------ pkg/webtests/huma_info_test.go | 42 ++++++++ pkg/webtests/huma_user_search_test.go | 89 ++++++++++++++++ pkg/webtests/huma_webhook_event_test.go | 48 +++++++++ 4 files changed, 179 insertions(+), 100 deletions(-) rename pkg/webtests/{huma_backgrounds_misc_test.go => huma_background_test.go} (55%) create mode 100644 pkg/webtests/huma_info_test.go create mode 100644 pkg/webtests/huma_user_search_test.go create mode 100644 pkg/webtests/huma_webhook_event_test.go diff --git a/pkg/webtests/huma_backgrounds_misc_test.go b/pkg/webtests/huma_background_test.go similarity index 55% rename from pkg/webtests/huma_backgrounds_misc_test.go rename to pkg/webtests/huma_background_test.go index e276b2fdb..8efdc3c2b 100644 --- a/pkg/webtests/huma_backgrounds_misc_test.go +++ b/pkg/webtests/huma_background_test.go @@ -17,7 +17,6 @@ package webtests import ( - "encoding/json" "net/http" "testing" @@ -30,89 +29,6 @@ import ( "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") -} - -// 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()) - }) -} - -// 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()) - }) -} - // 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. @@ -194,19 +110,3 @@ func TestHumaUnsplashBackground(t *testing.T) { assert.Equal(t, http.StatusForbidden, 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_info_test.go b/pkg/webtests/huma_info_test.go new file mode 100644 index 000000000..5ba7f859c --- /dev/null +++ b/pkg/webtests/huma_info_test.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 webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "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") +} 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_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()) + }) +} From 89ee1ef5071718eb2b8d3290d21f78353385a20d Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Thu, 11 Jun 2026 20:50:04 +0000 Subject: [PATCH 33/67] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 272 +++++++++++++++++++-------------------- pkg/swagger/swagger.json | 272 +++++++++++++++++++-------------------- pkg/swagger/swagger.yaml | 178 ++++++++++++------------- 3 files changed, 361 insertions(+), 361 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index e0ef37ad7..8f6f0e673 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -836,7 +836,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.vikunjaInfos" + "$ref": "#/definitions/shared.VikunjaInfos" } } } @@ -10809,6 +10809,141 @@ const docTemplate = `{ } } }, + "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" + }, + "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": { @@ -11121,141 +11256,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 26d826b75..4cab9fb5e 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -828,7 +828,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.vikunjaInfos" + "$ref": "#/definitions/shared.VikunjaInfos" } } } @@ -10801,6 +10801,141 @@ } } }, + "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" + }, + "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": { @@ -11113,141 +11248,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 0e248ba95..af2b209dd 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1474,6 +1474,94 @@ definitions: 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 + 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: @@ -1711,94 +1799,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: @@ -2521,7 +2521,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.vikunjaInfos' + $ref: '#/definitions/shared.VikunjaInfos' summary: Info tags: - service From f819b685d8c96019526ebc7ece8feac5cc862e17 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Fri, 12 Jun 2026 00:35:31 +0000 Subject: [PATCH 34/67] chore(i18n): update translations via Crowdin --- frontend/src/i18n/lang/uk-UA.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/lang/uk-UA.json b/frontend/src/i18n/lang/uk-UA.json index aff96ecfa..d92642a0d 100644 --- a/frontend/src/i18n/lang/uk-UA.json +++ b/frontend/src/i18n/lang/uk-UA.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "YYYY/MM/DD" }, "timeFormat": "Формат часу", + "timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу", "timeFormatOptions": { "12h": "12-годинний (AM/PM)", "24h": "24-годинний (HH:mm)" @@ -781,7 +782,10 @@ "closeDialog": "Закрити діалог", "closeQuickActions": "Закрити швидкі дії", "skipToContent": "Перейти до основного вмісту", - "sortBy": "Сортувати за" + "sortBy": "Сортувати за", + "dateRange": "Діапазон дат", + "notSet": "Не встановлено", + "user": "Користувач" }, "input": { "projectColor": "Колір проєкту", @@ -991,6 +995,7 @@ "repeatAfter": "Повторювати", "percentDone": "Встановити прогрес", "attachments": "Вкласти", + "timeTracking": "Відстежити час", "relatedTasks": "Пов'язати", "moveProject": "Перемістити", "duplicate": "Дублювати", @@ -1146,6 +1151,7 @@ "repeat": { "everyDay": "Щодня", "everyWeek": "Щотижня", + "every30d": "Кожні 30 днів", "mode": "Спосіб", "monthly": "Щомісяця", "fromCurrentDate": "З дня закінчення", @@ -1459,6 +1465,24 @@ "frontendVersion": "Версія інтерфейсу: {version}", "apiVersion": "API версія: {version}" }, + "timeTracking": { + "title": "Відстеження часу", + "stop": "Зупинити таймер", + "logTime": "Записати час", + "editEntry": "Редагувати запис", + "form": { + "task": "Завдання", + "taskSearch": "Знайти завдання…", + "commentPlaceholder": "Над чим ви працювали?", + "save": "Зберегти запис", + "startTimer": "Запустити таймер", + "update": "Оновити запис", + "smartFill": "Заповнити з останнього запису" + }, + "list": { + "emptyTask": "Для цього завдання ще немає записів обліку часу." + } + }, "time": { "units": { "seconds": "секунда|секунд(и)", From 8ff46967864f75157a0740b4251021eac5f74483 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 22:31:30 +0200 Subject: [PATCH 35/67] fix(frontend): restore quick actions menu styling and height limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick actions menu (cmd+k) rendered without any background and grew beyond the viewport: - Its card visuals came from the global Bulma .card styles, which were dropped when Card.vue got its own scoped copy — QuickActions is the only place using a bare class="card" div, so it lost background, border and shadow. Give it its own card styles. - Its height limit came from Bulma's .modal-content max-height, lost when the Bulma modal import was dropped in the native-dialog refactor. The :deep(.modal-content) position override in QuickActions never matched (.modal-content is an ancestor of the scoped selector, not a descendant). Replace both with a proper `top` modal variant that anchors the content 3rem below the top edge and caps its height, resolving the FIXME asking for exactly that option. - The dark scrim never showed: Chromium intermittently stops painting a styled ::backdrop (after subtree re-renders, or while display is transitioned) even though getComputedStyle reports the color. Move the scrim onto the viewport-filling dialog element itself — same as the old div-based .modal-mask — and drop the display/allow-discrete transitions, which the JS-timed close fade never needed. --- frontend/src/components/misc/Modal.vue | 55 ++++++++++++++----- .../components/quick-actions/QuickActions.vue | 14 +++-- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/misc/Modal.vue b/frontend/src/components/misc/Modal.vue index 5a3fedc98..b654e0268 100644 --- a/frontend/src/components/misc/Modal.vue +++ b/frontend/src/components/misc/Modal.vue @@ -68,7 +68,7 @@ const props = withDefaults(defineProps<{ enabled?: boolean, overflow?: boolean, wide?: boolean, - variant?: 'default' | 'hint-modal' | 'scrolling', + variant?: 'default' | 'hint-modal' | 'scrolling' | 'top', }>(), { enabled: true, overflow: false, @@ -211,7 +211,13 @@ $modal-width: 1024px; // Reset UA dialog styles padding: 0; border: none; - background: transparent; + // The scrim lives on the dialog element, not on ::backdrop: Chromium + // intermittently stops painting a styled ::backdrop (e.g. after the + // dialog's subtree re-renders, or while display is transitioned) even + // though getComputedStyle still reports the color. The dialog fills the + // viewport anyway, and its opacity transition fades the scrim with it — + // same as the old div-based .modal-mask. + background: rgba(0, 0, 0, .8); color: #ffffff; // Fill viewport position: fixed; @@ -221,10 +227,12 @@ $modal-width: 1024px; max-inline-size: 100%; max-block-size: 100%; - // Transitions + // Transitions. No display/allow-discrete transition needed: the close + // fade runs while the dialog is still [open] (data-closing + timer in + // closeDialog), and transitioning display triggers the Chromium paint + // bug above. opacity: 0; - transition: opacity 150ms ease, - display 150ms ease allow-discrete; + transition: opacity 150ms ease; &[open]:not([data-closing]) { opacity: 1; @@ -236,16 +244,11 @@ $modal-width: 1024px; &::backdrop { background-color: rgba(0, 0, 0, 0); - transition: background-color 150ms ease, - display 150ms ease allow-discrete; } - &[open]:not([data-closing])::backdrop { - background-color: rgba(0, 0, 0, .8); - - @starting-style { - background-color: rgba(0, 0, 0, 0); - } + // in quick-add mode the Electron window itself is the overlay — no scrim + &:has(.is-quick-add-mode) { + background: transparent; } } @@ -261,7 +264,8 @@ $modal-width: 1024px; } .default .modal-content, -.hint-modal .modal-content { +.hint-modal .modal-content, +.top .modal-content { text-align: center; position: absolute; // fine to use top/left since we're only using this to position it centered @@ -289,11 +293,31 @@ $modal-width: 1024px; } } +// anchored below the top edge instead of centered, used for QuickActions +.top .modal-content { + inset-block-start: 3rem; + transform: translate(-50%, 0); + max-block-size: calc(100dvh - 6rem); + overflow: auto; + + [dir="rtl"] & { + transform: translate(50%, 0); + } + + // the fullscreen mobile layout flows and scrolls in .modal-container + @media screen and (max-width: $tablet) { + transform: none; + max-block-size: none; + overflow: visible; + } +} + // Default width for centered modals. Scoped with :not(.is-wide) so the // `wide` prop can still expand the modal (the .is-wide rule below would // otherwise be outranked by .default .modal-content's specificity). .default .modal-content:not(.is-wide), -.hint-modal .modal-content:not(.is-wide) { +.hint-modal .modal-content:not(.is-wide), +.top .modal-content:not(.is-wide) { inline-size: calc(100% - 2rem); max-inline-size: 640px; @@ -403,6 +427,7 @@ $modal-width: 1024px; block-size: auto; max-inline-size: none; max-block-size: none; + background: transparent; &::backdrop { display: none; diff --git a/frontend/src/components/quick-actions/QuickActions.vue b/frontend/src/components/quick-actions/QuickActions.vue index 13605fe4f..3656d5e89 100644 --- a/frontend/src/components/quick-actions/QuickActions.vue +++ b/frontend/src/components/quick-actions/QuickActions.vue @@ -2,6 +2,7 @@
.quick-actions { + // global Bulma .card styles are gone (ported into Card.vue, scoped), + // so this bare .card div needs its own card visuals + background-color: var(--white); + border-radius: $radius; + border: 1px solid var(--card-border-color); + box-shadow: var(--shadow-sm); + color: var(--text); overflow: hidden; justify-content: flex-start !important; - // FIXME: changed position should be an option of the modal - :deep(.modal-content) { - inset-block-start: 3rem; - transform: translate(-50%, 0); - } - &.is-quick-add-mode { padding: 0; margin: 0; From eac1fa272634525cb61203bd29f480a908d56922 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:42:34 +0200 Subject: [PATCH 36/67] refactor(auth): extract shared auth/token business logic for v2 reuse Pull the HTTP-independent core out of the v1 auth handlers so both /api/v1 and the upcoming /api/v2 routes share one implementation: - oauth2server: ExchangeToken and Authorize take plain inputs and return typed responses; HandleToken/HandleAuthorize keep binding + headers. - pkg/routes/api/shared: AuthenticateLinkShare, RegisterUser, ResetPassword (+ session clear), RequestPasswordResetToken and ConfirmEmail, plus the shared UserRegister and LinkShareToken types. v1 handlers now delegate to these; their wire output is unchanged. --- pkg/modules/auth/oauth2server/authorize.go | 60 +++++--- pkg/modules/auth/oauth2server/token.go | 74 +++++---- pkg/routes/api/shared/auth.go | 171 +++++++++++++++++++++ pkg/routes/api/v1/link_sharing_auth.go | 45 +----- pkg/routes/api/v1/user_confirm_email.go | 15 +- pkg/routes/api/v1/user_password_reset.go | 32 +--- pkg/routes/api/v1/user_register.go | 37 +---- 7 files changed, 267 insertions(+), 167 deletions(-) create mode 100644 pkg/routes/api/shared/auth.go 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..9d8d33a9a 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -36,35 +36,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(&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(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return handleAuthorizationCodeGrant(c, &req) + return exchangeAuthorizationCode(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(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -72,73 +88,69 @@ 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) 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{ + 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/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go new file mode 100644 index 000000000..d11a1cdbb --- /dev/null +++ b/pkg/routes/api/shared/auth.go @@ -0,0 +1,171 @@ +// 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/db" + "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/user" +) + +// 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(in *UserRegister) (*user.User, error) { + s := db.NewSession() + defer s.Close() + + 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 + } + + // 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 +} + +// 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/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/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_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..1c70df765 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(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) } From 37a174b99e34826f24b8f8919e9e40e8fbaaa072 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:44:03 +0200 Subject: [PATCH 37/67] feat(api/v2): add public auth routes (register, password, confirm, link-share) Port the unauthenticated local-account flows and link-share auth to /api/v2, delegating to the shared business logic: - POST /register (404 when registration is disabled) - POST /user/password/token, POST /user/password/reset - POST /user/confirm - POST /shares/{share}/auth Local-account routes register only when local auth is enabled and the link-share route only when link sharing is enabled, mirroring v1. Each operation opts out of global auth and its path is added to unauthenticatedAPIPaths. --- pkg/routes/api/v2/auth_public.go | 175 +++++++++++++++++++++++++++++++ pkg/routes/routes.go | 7 ++ 2 files changed, 182 insertions(+) create mode 100644 pkg/routes/api/v2/auth_public.go diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go new file mode 100644 index 000000000..13c33523b --- /dev/null +++ b/pkg/routes/api/v2/auth_public.go @@ -0,0 +1,175 @@ +// 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"} + + Register(api, huma.Operation{ + OperationID: "auth-register", + Summary: "Register", + Description: "Creates a new local user account. Returns 404 when registration is disabled on this instance.", + 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(_ context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { + if !config.ServiceEnableRegistration.GetBool() { + return nil, huma.Error404NotFound("registration is disabled") + } + + newUser, err := shared.RegisterUser(&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."` + Body struct { + Password string `json:"password" doc:"The password for password-protected link shares. Ignored for shares without a password."` + } +}) (*linkShareTokenBody, error) { + token, err := shared.AuthenticateLinkShare(in.Share, in.Body.Password) + if err != nil { + return nil, translateDomainError(err) + } + return &linkShareTokenBody{Body: token}, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 949180b64..4c070fd49 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -344,6 +344,13 @@ var unauthenticatedAPIPaths = map[string]bool{ "/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, } // collectRoutesForAPITokens collects all routes for API token permission checking. From dc4c3a6a174ea7e8107e7b7f37c2a0053691020d Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:45:35 +0200 Subject: [PATCH 38/67] feat(api/v2): add OAuth 2.0 token and authorize endpoints Port oauth/token and oauth/authorize to /api/v2, delegating to the shared oauth2server.ExchangeToken / Authorize cores. The token endpoint accepts spec-compliant application/x-www-form-urlencoded bodies (RFC 6749) in addition to JSON; a form-urlencoded format is registered on the v2 API that binds into the same json-tagged request struct. The response carries Cache-Control: no-store. The token endpoint is public; authorize inherits the global JWT auth. --- pkg/routes/api/v2/huma.go | 41 ++++++++++++++ pkg/routes/api/v2/oauth.go | 112 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 pkg/routes/api/v2/oauth.go diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go index 7ad6f18f6..7a7dc3514 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() diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go new file mode 100644 index 000000000..9b13c7654 --- /dev/null +++ b/pkg/routes/api/v2/oauth.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 apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/modules/auth/oauth2server" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" +) + +// 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(&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 can record them on the session it +// creates, mirroring v1. Both fall back to "" when the context is unavailable. +func requestClientInfo(ctx context.Context) (deviceInfo, ipAddress string) { + ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) + if !ok || ec == nil { + return "", "" + } + return (*ec).Request().UserAgent(), (*ec).RealIP() +} From 56a516045bb2efe0388a56fd10a87b6ebd4a7b7a Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:48:08 +0200 Subject: [PATCH 39/67] feat(api/v2): add token-check, token-routes and link-share renew endpoints Port the token introspection helpers and link-share token renewal to /api/v2: - GET/POST /token/test both return a plain 200 "ok"; v1's POST 418 teapot easter egg becomes an ordinary success. - GET /routes lists the scoped-token routes for both API versions (models.GetAPITokenRoutes already merges v1 + v2). - POST /user/token renews a link-share JWT; user tokens are rejected (they must use the refresh-token flow), mirroring v1. The renew response inlines the token field rather than returning auth.Token directly, since Huma names schemas by bare type and a top-level auth.Token body would collide with user.Token. --- pkg/routes/api/v2/token_meta.go | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 pkg/routes/api/v2/token_meta.go 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 +} From d8ad9d64f55f5347b61914e1f0c8600b4d56f8d7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:51:40 +0200 Subject: [PATCH 40/67] test(api/v2): cover ported auth/token endpoints Add webtests mirroring the v1 coverage for the v2 auth surface: register (incl. registration-disabled 404), password reset request + reset, email confirm, link-share auth (password matrix), the OAuth token flow in both JSON and form-urlencoded encodings, oauth/authorize, the token-test/check endpoints (200, not 418), /routes and link-share token renewal (incl. user-token rejection). Also make the link-share auth body optional so a passwordless share authenticates with no request body, matching v1. --- pkg/routes/api/v2/auth_public.go | 11 +- pkg/webtests/huma_auth_test.go | 293 +++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 pkg/webtests/huma_auth_test.go diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go index 13c33523b..689c2c7b7 100644 --- a/pkg/routes/api/v2/auth_public.go +++ b/pkg/routes/api/v2/auth_public.go @@ -163,11 +163,18 @@ func authConfirmEmail(_ context.Context, in *struct{ Body user.EmailConfirm }) ( func authLinkShare(_ context.Context, in *struct { Share string `path:"share" doc:"The public hash of the link share."` - Body struct { + // 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) { - token, err := shared.AuthenticateLinkShare(in.Share, in.Body.Password) + 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) } 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 +} From 2bbe77c1411f82b43f36dd86b81ae623394652b2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 09:41:19 +0200 Subject: [PATCH 41/67] fix(api/v2): gate /register at registration time, not per request Per review: when registration is disabled, skip registering the /register route entirely instead of registering it and returning 404 on every request. A request to a disabled instance still 404s (unknown route). ServiceEnableRegistration is static config, so the gate belongs in the registrar. --- pkg/routes/api/v2/auth_public.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go index 689c2c7b7..5791b7c3f 100644 --- a/pkg/routes/api/v2/auth_public.go +++ b/pkg/routes/api/v2/auth_public.go @@ -78,15 +78,20 @@ func RegisterPublicAuthRoutes(api huma.API) { func registerLocalAuthRoutes(api huma.API) { authTags := []string{"auth"} - Register(api, huma.Operation{ - OperationID: "auth-register", - Summary: "Register", - Description: "Creates a new local user account. Returns 404 when registration is disabled on this instance.", - Method: http.MethodPost, - Path: "/register", - Tags: authTags, - Security: publicSecurity, - }, authRegister) + // 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", @@ -123,10 +128,6 @@ func registerLocalAuthRoutes(api huma.API) { } func authRegister(_ context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { - if !config.ServiceEnableRegistration.GetBool() { - return nil, huma.Error404NotFound("registration is disabled") - } - newUser, err := shared.RegisterUser(&in.Body) if err != nil { return nil, translateDomainError(err) From 48f7dafce38b695006b45bf22dabf0ee377c1211 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:00:41 +0200 Subject: [PATCH 42/67] feat(events): carry request metadata onto dispatched event messages Adds a RequestMeta context bridge so events dispatched during an HTTP request can be attributed to it: a middleware stashes IP/UA/request-id on the request context, the generic Do* handlers associate that context with the transaction key, and DispatchPending/DispatchWithContext copy the metadata onto the watermill message at publish time. Existing dispatch call sites are unchanged. --- pkg/events/events.go | 37 ++++++++++++++++++++++++- pkg/events/request_meta.go | 55 ++++++++++++++++++++++++++++++++++++++ pkg/web/handler/core.go | 15 +++++++---- 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 pkg/events/request_meta.go diff --git a/pkg/events/events.go b/pkg/events/events.go index 30c26ea99..882de2bbb 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -201,6 +201,13 @@ func InitEventsForTesting(ctx context.Context) (<-chan struct{}, error) { // Dispatch dispatches an event func Dispatch(event Event) error { + return DispatchWithContext(context.Background(), event) +} + +// DispatchWithContext dispatches an event and copies request metadata from the +// context (see WithRequestMeta) onto the message metadata, so listeners can +// attribute the event to the originating HTTP request. +func DispatchWithContext(ctx context.Context, event Event) error { if isUnderTest { dispatchedTestEvents = append(dispatchedTestEvents, event) return nil @@ -216,17 +223,41 @@ func Dispatch(event Event) error { } msg := message.NewMessage(watermill.NewUUID(), content) + if meta := RequestMetaFromContext(ctx); meta != nil { + if meta.IP != "" { + msg.Metadata.Set(MetadataKeyIP, meta.IP) + } + if meta.UserAgent != "" { + msg.Metadata.Set(MetadataKeyUserAgent, meta.UserAgent) + } + if meta.RequestID != "" { + msg.Metadata.Set(MetadataKeyRequestID, meta.RequestID) + } + } return pubsub.Publish(event.Name(), msg) } // pendingEventQueue holds the pending events and a mutex for thread-safe access type pendingEventQueue struct { mu sync.Mutex + ctx context.Context events []Event } var pendingEvents sync.Map // map[any]*pendingEventQueue +// SetContextForKey associates a request context with a transaction key so that +// events queued via DispatchOnCommit for the same key are dispatched with the +// request metadata from that context. The entry is removed by DispatchPending +// or CleanupPending — callers must guarantee one of them runs for the key. +func SetContextForKey(key any, ctx context.Context) { + val, _ := pendingEvents.LoadOrStore(key, &pendingEventQueue{}) + queue := val.(*pendingEventQueue) + queue.mu.Lock() + queue.ctx = ctx + queue.mu.Unlock() +} + // DispatchOnCommit stores an event to be dispatched later, after a transaction commits. // The key should be the *xorm.Session pointer associated with the transaction. // Call DispatchPending(key) after s.Commit() to actually dispatch the events. @@ -250,8 +281,12 @@ func DispatchPending(key any) { queue := val.(*pendingEventQueue) // No need to lock here since we've already removed it from the map // and this key won't receive new events + ctx := queue.ctx + if ctx == nil { + ctx = context.Background() + } for _, event := range queue.events { - if err := Dispatch(event); err != nil { + if err := DispatchWithContext(ctx, event); err != nil { log.Errorf("Failed to dispatch event %s: %v", event.Name(), err) } } diff --git a/pkg/events/request_meta.go b/pkg/events/request_meta.go new file mode 100644 index 000000000..796c7b7e9 --- /dev/null +++ b/pkg/events/request_meta.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 events + +import "context" + +// RequestMeta carries information about the originating HTTP request. It is +// stashed on the request context by a middleware and copied onto message +// metadata at publish time, so listeners (e.g. audit) can attribute an event +// to a request without every dispatch site changing its signature. +type RequestMeta struct { + IP string + UserAgent string + RequestID string +} + +// Message metadata keys holding request information. +const ( + MetadataKeyIP = "request_ip" + MetadataKeyUserAgent = "request_user_agent" + MetadataKeyRequestID = "request_id" +) + +type requestMetaKeyType struct{} + +var requestMetaKey requestMetaKeyType + +// WithRequestMeta returns a context carrying the given request metadata. +func WithRequestMeta(ctx context.Context, meta *RequestMeta) context.Context { + return context.WithValue(ctx, requestMetaKey, meta) +} + +// RequestMetaFromContext returns the request metadata stored on the context, +// or nil if there is none. +func RequestMetaFromContext(ctx context.Context) *RequestMeta { + if ctx == nil { + return nil + } + meta, _ := ctx.Value(requestMetaKey).(*RequestMeta) + return meta +} diff --git a/pkg/web/handler/core.go b/pkg/web/handler/core.go index 01474b874..fa037794b 100644 --- a/pkg/web/handler/core.go +++ b/pkg/web/handler/core.go @@ -28,8 +28,9 @@ 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() + events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -68,8 +69,9 @@ 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() + events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -108,8 +110,9 @@ 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() + events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -135,8 +138,9 @@ func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, // 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() + events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -174,8 +178,9 @@ func DoUpdate(_ context.Context, obj CObject, a web.Auth) error { // 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() + events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) From 95084087a5fe356b438f50544782dcb6e70060a6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:01:28 +0200 Subject: [PATCH 43/67] feat(config): add audit logging config keys --- config-raw.json | 31 +++++++++++++++++++++++++++++++ pkg/config/config.go | 10 ++++++++++ 2 files changed, 41 insertions(+) diff --git a/config-raw.json b/config-raw.json index dd395b768..641285994 100644 --- a/config-raw.json +++ b/config-raw.json @@ -997,6 +997,37 @@ } ] }, + { + "key": "audit", + "comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.", + "children": [ + { + "key": "enabled", + "default_value": "false", + "comment": "Whether to enable audit logging." + }, + { + "key": "logfile", + "default_value": "", + "comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path." + }, + { + "key": "rotation", + "children": [ + { + "key": "maxsizemb", + "default_value": "100", + "comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation." + }, + { + "key": "maxage", + "default_value": "30", + "comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever." + } + ] + } + ] + }, { "key": "outgoingrequests", "children": [ diff --git a/pkg/config/config.go b/pkg/config/config.go index 1941f7f0b..2443cb627 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -220,6 +220,11 @@ const ( WebhooksProxyPassword Key = `webhooks.proxypassword` WebhooksAllowNonRoutableIPs Key = `webhooks.allownonroutableips` + AuditEnabled Key = `audit.enabled` + AuditLogfile Key = `audit.logfile` + AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb` + AuditRotationMaxAge Key = `audit.rotation.maxage` + OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips` OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl` OutgoingRequestsProxyPassword Key = `outgoingrequests.proxypassword` @@ -483,6 +488,11 @@ func InitDefaultConfig() { WebhooksEnabled.setDefault(true) WebhooksTimeoutSeconds.setDefault(30) WebhooksAllowNonRoutableIPs.setDefault(false) + // Audit + AuditEnabled.setDefault(false) + AuditLogfile.setDefault("") // empty means /audit.log, resolved at init + AuditRotationMaxSizeMB.setDefault(100) + AuditRotationMaxAge.setDefault(30) // Outgoing Requests OutgoingRequestsAllowNonRoutableIPs.setDefault(false) OutgoingRequestsTimeoutSeconds.setDefault(30) From f308fd830aad3f323507d9523eda5364791deb47 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:03:49 +0200 Subject: [PATCH 44/67] feat(audit): add audit logging package Entry schema with constructor-enforced actor/target types, a generic RegisterEventForAudit helper that maps opted-in events to entries on the existing watermill bus (license-gated per event since licenses are runtime-mutable), and a JSONL writer with size-based rotation, age-based cleanup of rotated files and batched fsync. --- pkg/audit/entry.go | 127 +++++++++++++++++++++++++++ pkg/audit/listener.go | 88 +++++++++++++++++++ pkg/audit/writer.go | 199 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 pkg/audit/entry.go create mode 100644 pkg/audit/listener.go create mode 100644 pkg/audit/writer.go diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go new file mode 100644 index 000000000..e2ed91876 --- /dev/null +++ b/pkg/audit/entry.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 audit + +import "time" + +// Entry is one audit log record. It only references actors and targets by +// opaque ID — no names, emails or content — so GDPR erasure is satisfied by +// deleting the referenced row. +type Entry struct { + EventID string `json:"event_id"` // UUIDv7 + Timestamp time.Time `json:"timestamp"` + Actor Actor `json:"actor"` + Source Source `json:"source"` + Action string `json:"action"` + Target Target `json:"target"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` + RequestID string `json:"request_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type actorType string +type targetType string + +// Actor is the principal which performed the audited action. +type Actor struct { + Type actorType `json:"type"` + ID int64 `json:"id,omitempty"` +} + +// Source describes where the action originated from. +type Source struct { + Type string `json:"type"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +// Target is the resource the audited action was performed on. +type Target struct { + Type targetType `json:"type"` + ID int64 `json:"id,omitempty"` +} + +// Outcome values for an Entry. +const ( + OutcomeSuccess = "success" + OutcomeFailure = "failure" +) + +// Source types for an Entry. +const ( + SourceHTTP = "http" + SourceSystem = "system" +) + +// The action catalog. Every audited action is listed here. +const ( + ActionLoginSucceeded = "auth.login.succeeded" + ActionLoginFailed = "auth.login.failed" + ActionLogout = "auth.logout" + ActionAPITokenIssued = "auth.api_token.issued" + ActionAPITokenRevoked = "auth.api_token.revoked" + ActionAPITokenUsed = "auth.api_token.used" + + ActionUserCreated = "user.created" + + ActionTaskCreated = "task.created" + ActionTaskUpdated = "task.updated" + ActionTaskDeleted = "task.deleted" + ActionTaskAssigneeAdded = "task.assignee.added" + ActionTaskAssigneeRemoved = "task.assignee.removed" + ActionTaskCommentCreated = "task.comment.created" + ActionTaskCommentUpdated = "task.comment.updated" + ActionTaskCommentDeleted = "task.comment.deleted" + ActionTaskAttachmentCreated = "task.attachment.created" + ActionTaskAttachmentDeleted = "task.attachment.deleted" + ActionTaskRelationCreated = "task.relation.created" + ActionTaskRelationDeleted = "task.relation.deleted" + + ActionProjectCreated = "project.created" + ActionProjectUpdated = "project.updated" + ActionProjectDeleted = "project.deleted" + ActionProjectSharedWithUser = "project.shared.user" + ActionProjectSharedWithTeam = "project.shared.team" + + ActionTeamCreated = "team.created" + ActionTeamDeleted = "team.deleted" + ActionTeamMemberAdded = "team.member.added" + ActionTeamMemberRemoved = "team.member.removed" +) + +// The type strings are unexported; these constructors are the only way to +// build an Actor or Target, so a mismatched type/ID pair can't be expressed. + +func UserActor(id int64) Actor { return Actor{Type: "user", ID: id} } +func LinkShareActor(id int64) Actor { return Actor{Type: "link_share", ID: id} } +func SystemActor() Actor { return Actor{Type: "system"} } + +// ActorFromDoerID maps a doer ID to an actor. Link shares are disguised as +// users with negative IDs throughout the event payloads. +func ActorFromDoerID(id int64) Actor { + if id < 0 { + return LinkShareActor(-id) + } + return UserActor(id) +} + +func TaskTarget(id int64) Target { return Target{Type: "task", ID: id} } +func ProjectTarget(id int64) Target { return Target{Type: "project", ID: id} } +func UserTarget(id int64) Target { return Target{Type: "user", ID: id} } +func TeamTarget(id int64) Target { return Target{Type: "team", ID: id} } +func APITokenTarget(id int64) Target { return Target{Type: "api_token", ID: id} } diff --git a/pkg/audit/listener.go b/pkg/audit/listener.go new file mode 100644 index 000000000..c0454512a --- /dev/null +++ b/pkg/audit/listener.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 audit + +import ( + "encoding/json" + + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + + "github.com/ThreeDotsLabs/watermill/message" +) + +type auditListener struct { + handle func(msg *message.Message) error +} + +func (l *auditListener) Handle(msg *message.Message) error { + return l.handle(msg) +} + +func (l *auditListener) Name() string { + return "audit" +} + +// RegisterEventForAudit opts an event into audit logging. The event→Entry +// mapping is passed at registration, so opting in and defining the mapping +// are one unit and can't drift apart. Returning a nil Entry skips the event. +func RegisterEventForAudit[T any, PT interface { + *T + events.Event +}](toEntry func(PT) *Entry) { + name := PT(new(T)).Name() + RegisterEventNameForAudit(name, func(payload []byte) (*Entry, error) { + e := PT(new(T)) // fresh instance per message — handlers run concurrently + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return toEntry(e), nil + }) +} + +// RegisterEventNameForAudit is the untyped variant for events which cannot be +// unmarshaled into their Go struct directly (e.g. interface-typed Doer +// fields); the mapping decodes the raw payload itself. +func RegisterEventNameForAudit(name string, toEntry func(payload []byte) (*Entry, error)) { + events.RegisterListener(name, &auditListener{handle: func(msg *message.Message) error { + if !license.IsFeatureEnabled(license.FeatureAuditLogs) { + return nil // license is runtime-mutable — checked per event, not at registration + } + entry, err := toEntry(msg.Payload) + if err != nil { + return err + } + if entry == nil { + return nil + } + enrichFromMetadata(entry, msg.Metadata) + return WriteAuditEvent(entry) + }}) +} + +func enrichFromMetadata(entry *Entry, meta message.Metadata) { + entry.Source.IP = meta.Get(events.MetadataKeyIP) + entry.Source.UserAgent = meta.Get(events.MetadataKeyUserAgent) + entry.RequestID = meta.Get(events.MetadataKeyRequestID) + if entry.Source.Type == "" { + if entry.Source.IP != "" || entry.Source.UserAgent != "" { + entry.Source.Type = SourceHTTP + } else { + entry.Source.Type = SourceSystem + } + } +} diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go new file mode 100644 index 000000000..548c380fe --- /dev/null +++ b/pkg/audit/writer.go @@ -0,0 +1,199 @@ +// 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 audit + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + + "github.com/google/uuid" +) + +var ( + mu sync.Mutex + initialized bool + logFile *os.File + logfilePath string + currentSize int64 + maxSizeBytes int64 + maxAge time.Duration + lastSync time.Time +) + +// Init opens the audit log file. +// Safe to call again to re-read the config (used by tests). +func Init() error { + mu.Lock() + defer mu.Unlock() + + closeLocked() + + logfilePath = config.AuditLogfile.GetString() + if logfilePath == "" { + logfilePath = filepath.Join(config.LogPath.GetString(), "audit.log") + } + maxSizeBytes = config.AuditRotationMaxSizeMB.GetInt64() * 1024 * 1024 + maxAge = time.Duration(config.AuditRotationMaxAge.GetInt64()) * 24 * time.Hour + + if err := os.MkdirAll(filepath.Dir(logfilePath), 0750); err != nil { + return fmt.Errorf("could not create audit log directory: %w", err) + } + if err := openLogFileLocked(); err != nil { + return err + } + + initialized = true + return nil +} + +// Close closes the audit log file. Used by tests. +func Close() { + mu.Lock() + defer mu.Unlock() + closeLocked() +} + +func closeLocked() { + if logFile != nil { + _ = logFile.Sync() + _ = logFile.Close() + logFile = nil + } + initialized = false +} + +func openLogFileLocked() error { + f, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("could not open audit log file %s: %w", logfilePath, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return fmt.Errorf("could not stat audit log file %s: %w", logfilePath, err) + } + logFile = f + currentSize = info.Size() + return nil +} + +// WriteAuditEvent writes one entry to the local audit log. A failed write is +// returned so the event router retries it. +func WriteAuditEvent(entry *Entry) error { + if entry.EventID == "" { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("could not generate audit event id: %w", err) + } + entry.EventID = id.String() + } + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now().UTC() + } + if entry.Outcome == "" { + entry.Outcome = OutcomeSuccess + } + + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("could not marshal audit entry: %w", err) + } + + mu.Lock() + if !initialized { + mu.Unlock() + return fmt.Errorf("audit log not initialized") + } + + if err := rotateIfNeededLocked(int64(len(line)) + 1); err != nil { + mu.Unlock() + return err + } + + written, err := logFile.Write(append(line, '\n')) + currentSize += int64(written) + if err == nil && time.Since(lastSync) > time.Second { + err = logFile.Sync() + lastSync = time.Now() + } + mu.Unlock() + + if err != nil { + return fmt.Errorf("could not write audit entry: %w", err) + } + + return nil +} + +func rotateIfNeededLocked(addition int64) error { + if maxSizeBytes <= 0 || currentSize+addition <= maxSizeBytes { + return nil + } + + _ = logFile.Sync() + _ = logFile.Close() + logFile = nil + + rotatedPath := rotatedFileName(logfilePath, time.Now().UTC()) + if err := os.Rename(logfilePath, rotatedPath); err != nil { + // Reopen the original so logging continues even if rotation failed. + _ = openLogFileLocked() + return fmt.Errorf("could not rotate audit log: %w", err) + } + + cleanupRotatedFiles() + + return openLogFileLocked() +} + +func rotatedFileName(path string, now time.Time) string { + ext := filepath.Ext(path) + return strings.TrimSuffix(path, ext) + "-" + now.Format("20060102T150405.000") + ext +} + +func cleanupRotatedFiles() { + if maxAge <= 0 { + return + } + + ext := filepath.Ext(logfilePath) + pattern := strings.TrimSuffix(logfilePath, ext) + "-*" + ext + matches, err := filepath.Glob(pattern) + if err != nil { + log.Errorf("Could not list rotated audit log files: %s", err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.ModTime().After(cutoff) { + continue + } + if err := os.Remove(match); err != nil { + log.Errorf("Could not remove old audit log file %s: %s", match, err) + } + } +} From eea2ecbc7294cb2bd6108b78ea62bc7c8e958f0a Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:05:07 +0200 Subject: [PATCH 45/67] feat(audit): wire request-meta middleware and writer initialization --- pkg/initialize/init.go | 7 +++++ pkg/routes/middleware/request_meta.go | 45 +++++++++++++++++++++++++++ pkg/routes/routes.go | 4 +++ 3 files changed, 56 insertions(+) create mode 100644 pkg/routes/middleware/request_meta.go diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index dca17cb60..7210feb1e 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -19,6 +19,7 @@ package initialize import ( "time" + "code.vikunja.io/api/pkg/audit" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/db" @@ -98,6 +99,12 @@ func FullInitWithoutAsync() { // See the package comment in pkg/license/license.go before removing. license.Init() + if config.AuditEnabled.GetBool() { + if err := audit.Init(); err != nil { + log.Fatalf("Could not initialize audit logging: %s", err) + } + } + // Start the mail daemon mail.StartMailDaemon() diff --git a/pkg/routes/middleware/request_meta.go b/pkg/routes/middleware/request_meta.go new file mode 100644 index 000000000..747a37826 --- /dev/null +++ b/pkg/routes/middleware/request_meta.go @@ -0,0 +1,45 @@ +// 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 X-Request-ID on the request context +// so events dispatched while handling the request carry them as message +// metadata (consumed by the audit listeners). +func RequestMeta() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + requestID := req.Header.Get(echo.HeaderXRequestID) + if requestID == "" { + requestID = c.Response().Header().Get(echo.HeaderXRequestID) + } + ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{ + IP: c.RealIP(), + UserAgent: req.UserAgent(), + RequestID: requestID, + }) + c.SetRequest(req.WithContext(ctx)) + return next(c) + } + } +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 4c070fd49..f8fc1609f 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -199,6 +199,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 From 5f4a21a4c57458d59a0f5c29770132e00aed872a Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:08:35 +0200 Subject: [PATCH 46/67] feat(events): add auth boundary events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint where local, LDAP and OIDC logins converge), LoginFailedEvent from handleFailedPassword on every failed password check, LogoutEvent from the logout handler, and APIToken issued/revoked/used events from the token model and auth middleware. The token events carry IDs only since the freshly created token struct holds the raw token string and the poison queue logs message payloads. None of these events have a listener yet — the audit registration adds them. Dispatching to a topic without subscribers is a no-op. --- pkg/models/api_tokens.go | 26 +++++++++++++++++++++--- pkg/models/events.go | 41 ++++++++++++++++++++++++++++++++++++++ pkg/modules/auth/auth.go | 6 ++++++ pkg/routes/api/v1/login.go | 12 +++++++++++ pkg/routes/api_tokens.go | 13 ++++++++++++ pkg/user/events.go | 31 ++++++++++++++++++++++++++++ pkg/user/user.go | 5 +++++ 7 files changed, 131 insertions(+), 3 deletions(-) diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index 410c4ac96..7739184fb 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -24,6 +24,7 @@ import ( "time" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/web" @@ -121,7 +122,17 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { } _, err = s.Insert(t) - return err + if err != nil { + return err + } + + events.DispatchOnCommit(s, &APITokenIssuedEvent{ + TokenID: t.ID, + DoerID: a.GetID(), + OwnerID: t.OwnerID, + }) + + return nil } func HashToken(token, salt string) string { @@ -192,10 +203,19 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int, // @Failure 404 {object} web.HTTPError "The token does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /tokens/{tokenID} [delete] -func (t *APIToken) Delete(s *xorm.Session, _ web.Auth) (err error) { +func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) { // Ownership is verified in CanDelete; delete by ID only. _, err = s.Where("id = ?", t.ID).Delete(&APIToken{}) - return err + if err != nil { + return err + } + + events.DispatchOnCommit(s, &APITokenRevokedEvent{ + TokenID: t.ID, + DoerID: a.GetID(), + }) + + return nil } // HasCaldavAccess checks whether the token has the caldav access permission. diff --git a/pkg/models/events.go b/pkg/models/events.go index fca768388..1996f54b8 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -395,3 +395,44 @@ type TimeEntryDeletedEvent struct { func (e *TimeEntryDeletedEvent) Name() string { return "time-entry.deleted" } + +//////////////////// +// API Token Events + +// API token events carry IDs only: the freshly created token struct holds the +// raw token string, which must never end up in a message payload (the poison +// queue logs payloads on handler failure). + +// APITokenIssuedEvent represents an API token being created +type APITokenIssuedEvent struct { + TokenID int64 `json:"token_id"` + DoerID int64 `json:"doer_id"` + OwnerID int64 `json:"owner_id"` +} + +// Name defines the name for APITokenIssuedEvent +func (e *APITokenIssuedEvent) Name() string { + return "api-token.issued" +} + +// APITokenRevokedEvent represents an API token being deleted +type APITokenRevokedEvent struct { + TokenID int64 `json:"token_id"` + DoerID int64 `json:"doer_id"` +} + +// Name defines the name for APITokenRevokedEvent +func (e *APITokenRevokedEvent) Name() string { + return "api-token.revoked" +} + +// APITokenUsedEvent represents an API token authenticating a request +type APITokenUsedEvent struct { + TokenID int64 `json:"token_id"` + OwnerID int64 `json:"owner_id"` +} + +// Name defines the name for APITokenUsedEvent +func (e *APITokenUsedEvent) Name() string { + return "api-token.used" +} diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 61a5f9b13..97429aa13 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" @@ -123,6 +125,10 @@ func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error { return err } + if err := events.DispatchWithContext(c.Request().Context(), &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. diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index eb92945d1..6c7eb686a 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -21,6 +21,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/auth" "code.vikunja.io/api/pkg/modules/auth/ldap" @@ -231,10 +233,14 @@ 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) + if id, ok := claims["id"].(float64); ok { + userID = int64(id) + } } } } @@ -257,5 +263,11 @@ func Logout(c *echo.Context) (err error) { 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."}) } 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/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/user.go b/pkg/user/user.go index 1aec85853..ab2912982 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -27,6 +27,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" @@ -411,6 +412,10 @@ func (u *User) IsLocalUser() bool { } func handleFailedPassword(user *User) { + if err := events.Dispatch(&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 { From 869bec38b5a689a96e74f5801637cfb3be5c2c5c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:10:13 +0200 Subject: [PATCH 47/67] feat(audit): register the audited event surface One config-gated block in RegisterListeners maps every opted-in event to its audit entry. Events with interface-typed doers are decoded via a small doer ref that distinguishes link shares by their hash field. --- pkg/models/listeners.go | 305 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 83ec34c9b..e29bb2369 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -22,6 +22,7 @@ import ( "strconv" "time" + "code.vikunja.io/api/pkg/audit" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" @@ -82,6 +83,310 @@ func RegisterListeners() { // Internal delivery listener — one message per webhook with its own retry lifecycle events.RegisterListener((&WebhookDeliveryEvent{}).Name(), &WebhookDeliveryListener{}) } + if config.AuditEnabled.GetBool() { + registerEventsForAuditLogging() + } +} + +// auditDoerRef decodes the doer of events whose Doer field is an interface +// and thus can't be unmarshaled into the event struct directly. +type auditDoerRef struct { + ID int64 `json:"id"` + Hash string `json:"hash"` // only set when the doer is a link share +} + +func auditActorFromDoerRef(d *auditDoerRef) audit.Actor { + if d == nil { + return audit.SystemActor() + } + if d.Hash != "" { + return audit.LinkShareActor(d.ID) + } + return audit.ActorFromDoerID(d.ID) +} + +func auditActorFromUser(u *user.User) audit.Actor { + if u == nil { + return audit.SystemActor() + } + return audit.ActorFromDoerID(u.ID) +} + +// registerEventsForAuditLogging opts events into audit logging. This block is +// the catalog of the entire audited surface — an event without a registration +// here is not audited. +func registerEventsForAuditLogging() { + // Auth boundary + audit.RegisterEventForAudit(func(e *user.LoginSucceededEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLoginSucceeded, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + } + }) + audit.RegisterEventForAudit(func(e *user.LoginFailedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLoginFailed, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + Outcome: audit.OutcomeFailure, + Reason: "wrong password", + } + }) + audit.RegisterEventForAudit(func(e *user.LogoutEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLogout, + Actor: audit.UserActor(e.UserID), + Target: audit.UserTarget(e.UserID), + } + }) + audit.RegisterEventForAudit(func(e *APITokenIssuedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenIssued, + Actor: audit.UserActor(e.DoerID), + Target: audit.APITokenTarget(e.TokenID), + Metadata: map[string]any{"owner_id": e.OwnerID}, + } + }) + audit.RegisterEventForAudit(func(e *APITokenRevokedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenRevoked, + Actor: audit.UserActor(e.DoerID), + Target: audit.APITokenTarget(e.TokenID), + } + }) + audit.RegisterEventForAudit(func(e *APITokenUsedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenUsed, + Actor: audit.UserActor(e.OwnerID), + Target: audit.APITokenTarget(e.TokenID), + } + }) + + // Users + audit.RegisterEventForAudit(func(e *user.CreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionUserCreated, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + } + }) + + // Tasks + audit.RegisterEventForAudit(func(e *TaskCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskAssigneeCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAssigneeAdded, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"assignee_id": e.Assignee.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAssigneeDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAssigneeRemoved, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"assignee_id": e.Assignee.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAttachmentCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAttachmentCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"attachment_id": e.Attachment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAttachmentDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAttachmentDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"attachment_id": e.Attachment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskRelationCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskRelationCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{ + "other_task_id": e.Relation.OtherTaskID, + "relation_kind": e.Relation.RelationKind, + }, + } + }) + audit.RegisterEventForAudit(func(e *TaskRelationDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskRelationDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{ + "other_task_id": e.Relation.OtherTaskID, + "relation_kind": e.Relation.RelationKind, + }, + } + }) + + // Projects + audit.RegisterEventForAudit(func(e *ProjectCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + } + }) + audit.RegisterEventNameForAudit((&ProjectUpdatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectUpdated, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + }, nil + }) + audit.RegisterEventNameForAudit((&ProjectDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectDeleted, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + }, nil + }) + audit.RegisterEventNameForAudit((&ProjectSharedWithUserEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + User *user.User `json:"user"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectSharedWithUser, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"user_id": e.User.ID}, + }, nil + }) + audit.RegisterEventNameForAudit((&ProjectSharedWithTeamEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + Team *Team `json:"team"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectSharedWithTeam, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"team_id": e.Team.ID}, + }, nil + }) + + // Teams + audit.RegisterEventNameForAudit((&TeamCreatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Team *Team `json:"team"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionTeamCreated, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + }, nil + }) + audit.RegisterEventNameForAudit((&TeamDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Team *Team `json:"team"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionTeamDeleted, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + }, nil + }) + audit.RegisterEventForAudit(func(e *TeamMemberAddedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamMemberAdded, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + Metadata: map[string]any{"member_id": e.Member.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TeamMemberRemovedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamMemberRemoved, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + Metadata: map[string]any{"member_id": e.Member.ID}, + } + }) } ////// From dbdf4a04cb24741a3def312beb7b942e03c47a4a Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:16:21 +0200 Subject: [PATCH 48/67] test(audit): cover listener pipeline, license gating and rotation --- pkg/audit/audit_test.go | 254 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 pkg/audit/audit_test.go diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go new file mode 100644 index 000000000..ef6ddc219 --- /dev/null +++ b/pkg/audit/audit_test.go @@ -0,0 +1,254 @@ +// 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 audit_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "code.vikunja.io/api/pkg/audit" + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + log.InitLogger() + config.InitDefaultConfig() + keyvalue.InitStorage() // license.SetForTests persists state through keyvalue + os.Exit(m.Run()) +} + +// One event type per test so each topic has exactly the listeners the test registered. +type pipelineEvent struct { + TaskID int64 `json:"task_id"` + DoerID int64 `json:"doer_id"` +} + +func (e *pipelineEvent) Name() string { return "test.audit.pipeline" } + +type licenseGateEvent struct { + Marker string `json:"marker"` +} + +func (e *licenseGateEvent) Name() string { return "test.audit.licensegate" } + +type rotationEvent struct { + Filler string `json:"filler"` +} + +func (e *rotationEvent) Name() string { return "test.audit.rotation" } + +// otherListener is a second, non-audit listener on the same topic. +type otherListener struct { + called chan struct{} +} + +func (l *otherListener) Handle(_ *message.Message) error { + select { + case l.called <- struct{}{}: + default: + } + return nil +} + +func (l *otherListener) Name() string { return "other" } + +var ( + registerTestEventsOnce sync.Once + other = &otherListener{called: make(chan struct{}, 16)} +) + +// The listener registry is global and watermill rejects duplicate handler +// names, so register once per process (relevant for -count > 1). +func registerTestEvents() { + registerTestEventsOnce.Do(func() { + audit.RegisterEventForAudit(func(e *pipelineEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.UserActor(e.DoerID), + Target: audit.TaskTarget(e.TaskID), + } + }) + events.RegisterListener((&pipelineEvent{}).Name(), other) + + audit.RegisterEventForAudit(func(e *licenseGateEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.SystemActor(), + Target: audit.TaskTarget(1), + Metadata: map[string]any{"marker": e.Marker}, + } + }) + + audit.RegisterEventForAudit(func(e *rotationEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.SystemActor(), + Target: audit.TaskTarget(1), + Metadata: map[string]any{"filler": e.Filler}, + } + }) + }) +} + +func setupAuditFile(t *testing.T) string { + t.Helper() + logfile := filepath.Join(t.TempDir(), "audit.log") + config.AuditLogfile.Set(logfile) + require.NoError(t, audit.Init()) + t.Cleanup(audit.Close) + return logfile +} + +func startEventRouter(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ready, err := events.InitEventsForTesting(ctx) + require.NoError(t, err) + <-ready +} + +func waitForLines(t *testing.T, logfile string, count int) []string { + t.Helper() + var lines []string + require.Eventuallyf(t, func() bool { + content, err := os.ReadFile(logfile) + if err != nil { + return false + } + lines = strings.Split(strings.TrimSpace(string(content)), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + return len(lines) >= count + }, 5*time.Second, 10*time.Millisecond, "expected %d audit log lines", count) + return lines +} + +func TestAuditPipeline(t *testing.T) { + logfile := setupAuditFile(t) + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + + registerTestEvents() + startEventRouter(t) + + ctx := events.WithRequestMeta(context.Background(), &events.RequestMeta{ + IP: "192.0.2.42", + UserAgent: "test-agent/1.0", + RequestID: "req-123", + }) + require.NoError(t, events.DispatchWithContext(ctx, &pipelineEvent{TaskID: 99, DoerID: 7})) + + lines := waitForLines(t, logfile, 1) + select { + case <-other.called: + case <-time.After(5 * time.Second): + t.Fatal("other listener on the same topic was not called") + } + // A topic with multiple listeners must produce exactly one audit entry. + events.WaitForPendingHandlers() + lines = waitForLines(t, logfile, 1) + require.Len(t, lines, 1) + + var entry audit.Entry + require.NoError(t, json.Unmarshal([]byte(lines[0]), &entry)) + assert.NotEmpty(t, entry.EventID) + assert.False(t, entry.Timestamp.IsZero()) + assert.Equal(t, "task.created", entry.Action) + assert.Equal(t, audit.UserActor(7), entry.Actor) + assert.Equal(t, audit.TaskTarget(99), entry.Target) + assert.Equal(t, audit.OutcomeSuccess, entry.Outcome) + assert.Equal(t, "192.0.2.42", entry.Source.IP) + assert.Equal(t, "test-agent/1.0", entry.Source.UserAgent) + assert.Equal(t, audit.SourceHTTP, entry.Source.Type) + assert.Equal(t, "req-123", entry.RequestID) +} + +func TestAuditLicenseGating(t *testing.T) { + logfile := setupAuditFile(t) + + registerTestEvents() + startEventRouter(t) + + // Without the licensed feature nothing must be written. The license check + // happens per event at handle time, so give the async handler a settle + // window before flipping the license back on. + license.ResetForTests() + require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "unlicensed"})) + require.Never(t, func() bool { + content, err := os.ReadFile(logfile) + return err == nil && len(content) > 0 + }, 500*time.Millisecond, 10*time.Millisecond, "unlicensed event must not be written") + events.WaitForPendingHandlers() + + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "licensed"})) + + lines := waitForLines(t, logfile, 1) + require.Len(t, lines, 1) + assert.Contains(t, lines[0], `"marker":"licensed"`) + assert.NotContains(t, lines[0], "unlicensed") + assert.Contains(t, lines[0], `"type":"system"`) +} + +func TestAuditRotation(t *testing.T) { + logfile := setupAuditFile(t) + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + + registerTestEvents() + startEventRouter(t) + + // Default max size is 100MB and config values are MB-granular, so two + // entries of ~600KB cross the limit with maxsizemb set to 1. + config.AuditRotationMaxSizeMB.Set("1") + t.Cleanup(func() { config.AuditRotationMaxSizeMB.Set("100") }) + require.NoError(t, audit.Init()) + + filler := strings.Repeat("x", 600*1024) + require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) + waitForLines(t, logfile, 1) + require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) + waitForLines(t, logfile, 1) + + require.Eventually(t, func() bool { + rotated, err := filepath.Glob(strings.TrimSuffix(logfile, ".log") + "-*.log") + return err == nil && len(rotated) == 1 + }, 5*time.Second, 10*time.Millisecond, "expected one rotated audit log file") +} + +func TestWriteAuditEventNotInitialized(t *testing.T) { + audit.Close() + err := audit.WriteAuditEvent(&audit.Entry{Action: "task.created"}) + require.Error(t, err) +} From fc831719cd3e4ab1c63305f8442c1e7c5b882d3f Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:18:34 +0200 Subject: [PATCH 49/67] docs(audit): add package documentation --- pkg/audit/doc.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 pkg/audit/doc.go diff --git a/pkg/audit/doc.go b/pkg/audit/doc.go new file mode 100644 index 000000000..f6d04d64d --- /dev/null +++ b/pkg/audit/doc.go @@ -0,0 +1,44 @@ +// 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 audit persists an audit trail of authentication, authorization and +// data lifecycle events as JSONL. +// +// Events opt in via RegisterEventForAudit, which subscribes one audit +// listener per event on the existing watermill bus; the event→Entry mapping +// is a closure passed at registration. The catalog of audited events lives in +// registerEventsForAuditLogging in pkg/models/listeners.go. +// +// Entries reference actors and targets by opaque ID only — deleting a user +// row orphans their audit references, which satisfies GDPR erasure without +// log redaction. +// +// Audit logging is gated twice: registration on the audit.enabled config key, +// and each write on the licensed audit_logs feature. The license is checked +// per event because it can change at runtime; enabled-but-unlicensed means +// listeners run and write nothing. +// +// Request attribution (IP, user agent, request id) flows from an Echo +// middleware through the request context onto message metadata — see +// pkg/events.RequestMeta. Events dispatched outside a request get +// source type "system" instead. +// +// A failed file write is returned to the router for retry. Tamper evidence +// comes from filesystem permissions (the file is created 0600) plus shipping +// the file to an external system, not from hash chains or signatures. +// Rotation is size-based with age-based cleanup of rotated files; retention +// is the operator's concern. +package audit From 9da51f50960218b264f1e2fefa6e0a8b3a5df888 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:23:29 +0200 Subject: [PATCH 50/67] refactor(events): pass context to DispatchPending directly Every DispatchPending caller either has the request context in scope or is genuinely request-less, so passing it as a parameter replaces the stored-context mechanism on the pending queue and satisfies contextcheck. Also fixes lint findings in the audit package. --- pkg/audit/audit_test.go | 18 ++++++++--------- pkg/audit/entry.go | 6 +++--- pkg/events/events.go | 20 ++----------------- pkg/events/events_test.go | 9 +++++---- pkg/models/task_comments_test.go | 3 ++- pkg/models/tasks_test.go | 5 +++-- pkg/models/time_tracking_test.go | 13 ++++++------ .../migration/create_from_structure.go | 3 ++- pkg/routes/api/v1/user_export.go | 2 +- pkg/routes/api/v2/time_entries.go | 2 +- pkg/routes/caldav/listStorageProvider.go | 7 ++++--- pkg/web/handler/core.go | 15 +++++--------- 12 files changed, 44 insertions(+), 59 deletions(-) diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go index ef6ddc219..897ebe93c 100644 --- a/pkg/audit/audit_test.go +++ b/pkg/audit/audit_test.go @@ -136,10 +136,10 @@ func startEventRouter(t *testing.T) { <-ready } -func waitForLines(t *testing.T, logfile string, count int) []string { +func waitForLines(t *testing.T, logfile string) []string { t.Helper() var lines []string - require.Eventuallyf(t, func() bool { + require.Eventually(t, func() bool { content, err := os.ReadFile(logfile) if err != nil { return false @@ -148,8 +148,8 @@ func waitForLines(t *testing.T, logfile string, count int) []string { if len(lines) == 1 && lines[0] == "" { lines = nil } - return len(lines) >= count - }, 5*time.Second, 10*time.Millisecond, "expected %d audit log lines", count) + return len(lines) >= 1 + }, 5*time.Second, 10*time.Millisecond, "expected at least one audit log line") return lines } @@ -168,7 +168,7 @@ func TestAuditPipeline(t *testing.T) { }) require.NoError(t, events.DispatchWithContext(ctx, &pipelineEvent{TaskID: 99, DoerID: 7})) - lines := waitForLines(t, logfile, 1) + waitForLines(t, logfile) select { case <-other.called: case <-time.After(5 * time.Second): @@ -176,7 +176,7 @@ func TestAuditPipeline(t *testing.T) { } // A topic with multiple listeners must produce exactly one audit entry. events.WaitForPendingHandlers() - lines = waitForLines(t, logfile, 1) + lines := waitForLines(t, logfile) require.Len(t, lines, 1) var entry audit.Entry @@ -214,7 +214,7 @@ func TestAuditLicenseGating(t *testing.T) { t.Cleanup(license.ResetForTests) require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "licensed"})) - lines := waitForLines(t, logfile, 1) + lines := waitForLines(t, logfile) require.Len(t, lines, 1) assert.Contains(t, lines[0], `"marker":"licensed"`) assert.NotContains(t, lines[0], "unlicensed") @@ -237,9 +237,9 @@ func TestAuditRotation(t *testing.T) { filler := strings.Repeat("x", 600*1024) require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) - waitForLines(t, logfile, 1) + waitForLines(t, logfile) require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) - waitForLines(t, logfile, 1) + waitForLines(t, logfile) require.Eventually(t, func() bool { rotated, err := filepath.Glob(strings.TrimSuffix(logfile, ".log") + "-*.log") diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go index e2ed91876..079629727 100644 --- a/pkg/audit/entry.go +++ b/pkg/audit/entry.go @@ -73,9 +73,9 @@ const ( ActionLoginSucceeded = "auth.login.succeeded" ActionLoginFailed = "auth.login.failed" ActionLogout = "auth.logout" - ActionAPITokenIssued = "auth.api_token.issued" - ActionAPITokenRevoked = "auth.api_token.revoked" - ActionAPITokenUsed = "auth.api_token.used" + ActionAPITokenIssued = "auth.api_token.issued" // #nosec G101 -- action identifier, not a credential + ActionAPITokenRevoked = "auth.api_token.revoked" // #nosec G101 + ActionAPITokenUsed = "auth.api_token.used" // #nosec G101 ActionUserCreated = "user.created" diff --git a/pkg/events/events.go b/pkg/events/events.go index 882de2bbb..5973b132d 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -240,24 +240,11 @@ func DispatchWithContext(ctx context.Context, event Event) error { // pendingEventQueue holds the pending events and a mutex for thread-safe access type pendingEventQueue struct { mu sync.Mutex - ctx context.Context events []Event } var pendingEvents sync.Map // map[any]*pendingEventQueue -// SetContextForKey associates a request context with a transaction key so that -// events queued via DispatchOnCommit for the same key are dispatched with the -// request metadata from that context. The entry is removed by DispatchPending -// or CleanupPending — callers must guarantee one of them runs for the key. -func SetContextForKey(key any, ctx context.Context) { - val, _ := pendingEvents.LoadOrStore(key, &pendingEventQueue{}) - queue := val.(*pendingEventQueue) - queue.mu.Lock() - queue.ctx = ctx - queue.mu.Unlock() -} - // DispatchOnCommit stores an event to be dispatched later, after a transaction commits. // The key should be the *xorm.Session pointer associated with the transaction. // Call DispatchPending(key) after s.Commit() to actually dispatch the events. @@ -272,8 +259,9 @@ func DispatchOnCommit(key any, event Event) { // DispatchPending dispatches all events accumulated for the given key and removes them. // Call this after s.Commit(). Safe to call even if no events were registered. +// Request metadata on the context (see WithRequestMeta) is copied onto each message. // If any event fails to dispatch, the error is logged but remaining events are still dispatched. -func DispatchPending(key any) { +func DispatchPending(ctx context.Context, key any) { val, ok := pendingEvents.LoadAndDelete(key) if !ok { return @@ -281,10 +269,6 @@ func DispatchPending(key any) { queue := val.(*pendingEventQueue) // No need to lock here since we've already removed it from the map // and this key won't receive new events - ctx := queue.ctx - if ctx == nil { - ctx = context.Background() - } for _, event := range queue.events { if err := DispatchWithContext(ctx, event); err != nil { log.Errorf("Failed to dispatch event %s: %v", event.Name(), err) diff --git a/pkg/events/events_test.go b/pkg/events/events_test.go index f78396a50..186d12f4a 100644 --- a/pkg/events/events_test.go +++ b/pkg/events/events_test.go @@ -17,6 +17,7 @@ package events import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -40,7 +41,7 @@ func TestDispatchOnCommit(t *testing.T) { assert.Equal(t, 0, CountDispatchedEvents("test.event")) // Simulate post-commit dispatch - DispatchPending(key) + DispatchPending(context.Background(), key) // Now it should be dispatched assert.Equal(t, 1, CountDispatchedEvents("test.event")) @@ -57,7 +58,7 @@ func TestDispatchOnCommitMultipleEvents(t *testing.T) { assert.Equal(t, 0, CountDispatchedEvents("test.event")) - DispatchPending(key) + DispatchPending(context.Background(), key) assert.Equal(t, 3, CountDispatchedEvents("test.event")) } @@ -74,7 +75,7 @@ func TestCleanupPending(t *testing.T) { CleanupPending(key) // Dispatching after cleanup should be a no-op - DispatchPending(key) + DispatchPending(context.Background(), key) assert.Equal(t, 0, CountDispatchedEvents("test.event")) } @@ -85,7 +86,7 @@ func TestDispatchPendingNoEvents(t *testing.T) { key := new(int) // Should be a no-op - DispatchPending(key) + DispatchPending(context.Background(), key) // Verify no events were dispatched assert.Equal(t, 0, CountDispatchedEvents("test.event")) diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go index 61f8f6dc4..988dc4f27 100644 --- a/pkg/models/task_comments_test.go +++ b/pkg/models/task_comments_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "fmt" "testing" @@ -45,7 +46,7 @@ func TestTaskComment_Create(t *testing.T) { assert.Equal(t, int64(1), tc.Author.ID) err = s.Commit() require.NoError(t, err) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TaskCommentCreatedEvent{}) db.AssertExists(t, "task_comments", map[string]interface{}{ diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index caa897740..7219b5ab8 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "testing" "time" @@ -70,7 +71,7 @@ func TestTask_Create(t *testing.T) { "bucket_id": 1, }, false) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TaskCreatedEvent{}) }) t.Run("with reminders", func(t *testing.T) { @@ -280,7 +281,7 @@ func TestTask_Update(t *testing.T) { err = s.Commit() require.NoError(t, err) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Verify exactly ONE task.updated event was dispatched count := events.CountDispatchedEvents("task.updated") assert.Equal(t, 1, count, "Expected exactly 1 task.updated event, got %d", count) diff --git a/pkg/models/time_tracking_test.go b/pkg/models/time_tracking_test.go index 91dc90ee6..6e5391d51 100644 --- a/pkg/models/time_tracking_test.go +++ b/pkg/models/time_tracking_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "encoding/json" "testing" "time" @@ -596,7 +597,7 @@ func TestTimeEntry_Events(t *testing.T) { te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} require.NoError(t, te.Create(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryCreatedEvent{}) }) @@ -612,7 +613,7 @@ func TestTimeEntry_Events(t *testing.T) { require.True(t, can) require.NoError(t, te.Update(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) @@ -624,7 +625,7 @@ func TestTimeEntry_Events(t *testing.T) { require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryDeletedEvent{}) }) @@ -637,7 +638,7 @@ func TestTimeEntry_Events(t *testing.T) { // 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(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryCreatedEvent{}) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) @@ -651,7 +652,7 @@ func TestTimeEntry_Events(t *testing.T) { te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} require.NoError(t, te.Create(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + 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") }) @@ -665,7 +666,7 @@ func TestTimeEntry_Events(t *testing.T) { _, err := StopRunningTimer(s, u) require.NoError(t, err) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) } 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/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 3c07c9ebc..6efc311c0 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -97,7 +97,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."}) } diff --git a/pkg/routes/api/v2/time_entries.go b/pkg/routes/api/v2/time_entries.go index 3500677f7..a58ee8b92 100644 --- a/pkg/routes/api/v2/time_entries.go +++ b/pkg/routes/api/v2/time_entries.go @@ -155,7 +155,7 @@ func timeEntriesTimerStop(ctx context.Context, _ *struct{}) (*singleBody[models. events.CleanupPending(s) return nil, translateDomainError(err) } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return &singleBody[models.TimeEntry]{Body: entry}, 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/web/handler/core.go b/pkg/web/handler/core.go index fa037794b..25c91c069 100644 --- a/pkg/web/handler/core.go +++ b/pkg/web/handler/core.go @@ -30,7 +30,6 @@ import ( // Caller is responsible for body/path binding and validation before calling. func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -61,7 +60,7 @@ func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } @@ -71,7 +70,6 @@ func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { // header in the Echo wrapper; Huma wrapper may ignore it. func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -102,7 +100,7 @@ func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, return 0, err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return maxPermission, nil } @@ -112,7 +110,6 @@ func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, // nil-slice normalization remain the caller's responsibility. 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() - events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -131,7 +128,7 @@ func DoReadAll(ctx 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 } @@ -140,7 +137,6 @@ func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page // and validation before calling. func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -171,7 +167,7 @@ func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } @@ -180,7 +176,6 @@ func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { // calling. func DoDelete(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -211,6 +206,6 @@ func DoDelete(ctx context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } From b86710903b89548d0b7f8a739360a56706ea2784 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:32:14 +0200 Subject: [PATCH 51/67] fix: dispatch pending events after user creation commits The register handler, local/LDAP login and the OIDC callback all queue the user.created event via DispatchOnCommit but never called DispatchPending, so the event was silently dropped and its queue entry leaked. Flush after commit and discard on rollback. --- pkg/modules/auth/openid/openid.go | 9 +++++++++ pkg/routes/api/shared/auth.go | 10 +++++++++- pkg/routes/api/v1/login.go | 5 +++++ pkg/routes/api/v1/user_register.go | 2 +- pkg/routes/api/v2/auth_public.go | 4 ++-- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go index 381570f42..b1fa3961a 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" @@ -187,6 +188,9 @@ func HandleCallback(c *echo.Context) error { 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) @@ -212,6 +216,9 @@ 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(c.Request().Context(), s) } if user.IsErrInvalidTOTPPasscode(err) { user.HandleFailedTOTPAuth(u) @@ -233,6 +240,8 @@ func HandleCallback(c *echo.Context) error { return err } + events.DispatchPending(c.Request().Context(), s) + // Create token return auth.NewUserAuthTokenResponse(u, c, false) } diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go index d11a1cdbb..925a533d8 100644 --- a/pkg/routes/api/shared/auth.go +++ b/pkg/routes/api/shared/auth.go @@ -17,8 +17,11 @@ 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" @@ -39,9 +42,12 @@ type UserRegister struct { // 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(in *UserRegister) (*user.User, error) { +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, @@ -59,6 +65,8 @@ func RegisterUser(in *UserRegister) (*user.User, error) { 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() { diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 6c7eb686a..7385ed1f6 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -53,6 +53,9 @@ func Login(c *echo.Context) (err 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) var user *user2.User if config.AuthLdapEnabled.GetBool() { @@ -127,6 +130,8 @@ func Login(c *echo.Context) (err error) { return err } + events.DispatchPending(c.Request().Context(), s) + // Create token return auth.NewUserAuthTokenResponse(user, c, u.LongToken) } diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 1c70df765..e9a90dc2f 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -63,7 +63,7 @@ func RegisterUser(c *echo.Context) error { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } - newUser, err := shared.RegisterUser(userIn) + newUser, err := shared.RegisterUser(c.Request().Context(), userIn) if err != nil { return err } diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go index 5791b7c3f..c41fb162d 100644 --- a/pkg/routes/api/v2/auth_public.go +++ b/pkg/routes/api/v2/auth_public.go @@ -127,8 +127,8 @@ func registerLocalAuthRoutes(api huma.API) { }, authConfirmEmail) } -func authRegister(_ context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { - newUser, err := shared.RegisterUser(&in.Body) +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) } From 2e0e8e9582f284fd125f87fc3be4618c69b4ca9a Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:30:39 +0200 Subject: [PATCH 52/67] refactor(audit): move package docs into entry.go --- pkg/audit/doc.go | 44 -------------------------------------------- pkg/audit/entry.go | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 44 deletions(-) delete mode 100644 pkg/audit/doc.go diff --git a/pkg/audit/doc.go b/pkg/audit/doc.go deleted file mode 100644 index f6d04d64d..000000000 --- a/pkg/audit/doc.go +++ /dev/null @@ -1,44 +0,0 @@ -// 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 audit persists an audit trail of authentication, authorization and -// data lifecycle events as JSONL. -// -// Events opt in via RegisterEventForAudit, which subscribes one audit -// listener per event on the existing watermill bus; the event→Entry mapping -// is a closure passed at registration. The catalog of audited events lives in -// registerEventsForAuditLogging in pkg/models/listeners.go. -// -// Entries reference actors and targets by opaque ID only — deleting a user -// row orphans their audit references, which satisfies GDPR erasure without -// log redaction. -// -// Audit logging is gated twice: registration on the audit.enabled config key, -// and each write on the licensed audit_logs feature. The license is checked -// per event because it can change at runtime; enabled-but-unlicensed means -// listeners run and write nothing. -// -// Request attribution (IP, user agent, request id) flows from an Echo -// middleware through the request context onto message metadata — see -// pkg/events.RequestMeta. Events dispatched outside a request get -// source type "system" instead. -// -// A failed file write is returned to the router for retry. Tamper evidence -// comes from filesystem permissions (the file is created 0600) plus shipping -// the file to an external system, not from hash chains or signatures. -// Rotation is size-based with age-based cleanup of rotated files; retention -// is the operator's concern. -package audit diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go index 079629727..bb7f98493 100644 --- a/pkg/audit/entry.go +++ b/pkg/audit/entry.go @@ -14,6 +14,33 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +// Package audit persists an audit trail of authentication, authorization and +// data lifecycle events as JSONL. +// +// Events opt in via RegisterEventForAudit, which subscribes one audit +// listener per event on the existing watermill bus; the event→Entry mapping +// is a closure passed at registration. The catalog of audited events lives in +// registerEventsForAuditLogging in pkg/models/listeners.go. +// +// Entries reference actors and targets by opaque ID only — deleting a user +// row orphans their audit references, which satisfies GDPR erasure without +// log redaction. +// +// Audit logging is gated twice: registration on the audit.enabled config key, +// and each write on the licensed audit_logs feature. The license is checked +// per event because it can change at runtime; enabled-but-unlicensed means +// listeners run and write nothing. +// +// Request attribution (IP, user agent, request id) flows from an Echo +// middleware through the request context onto message metadata — see +// pkg/events.RequestMeta. Events dispatched outside a request get +// source type "system" instead. +// +// A failed file write is returned to the router for retry. Tamper evidence +// comes from filesystem permissions (the file is created 0600) plus shipping +// the file to an external system, not from hash chains or signatures. +// Rotation is size-based with age-based cleanup of rotated files; retention +// is the operator's concern. package audit import "time" From 10717556253c5d1ff1a383c7d80f2006fbba651e Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:32:25 +0200 Subject: [PATCH 53/67] fix(routes): generate request IDs at the start of the middleware chain Echo's RequestID middleware reuses the X-Request-Id header from a proxy or generates one, so logging and audit all see the same ID. RequestMeta previously read the request header before any later middleware could have set one, leaving the audit request_id mostly empty. --- pkg/routes/middleware/request_meta.go | 13 +++++-------- pkg/routes/routes.go | 5 +++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/routes/middleware/request_meta.go b/pkg/routes/middleware/request_meta.go index 747a37826..865cd5cde 100644 --- a/pkg/routes/middleware/request_meta.go +++ b/pkg/routes/middleware/request_meta.go @@ -22,21 +22,18 @@ import ( "github.com/labstack/echo/v5" ) -// RequestMeta stashes IP, User-Agent and X-Request-ID on the request context -// so events dispatched while handling the request carry them as message -// metadata (consumed by the audit listeners). +// 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() - requestID := req.Header.Get(echo.HeaderXRequestID) - if requestID == "" { - requestID = c.Response().Header().Get(echo.HeaderXRequestID) - } ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{ IP: c.RealIP(), UserAgent: req.UserAgent(), - RequestID: requestID, + 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 f8fc1609f..9f6af5af3 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()) From 5d7812a093f717d28b7f09c50438d5f06b814f20 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:33:08 +0200 Subject: [PATCH 54/67] fix(audit): handle reopen failure after a failed rotation If both the rename and the reopen fail, logFile stayed nil while initialized was still true, panicking on the next write. Propagate the reopen error and retry the open on the next write so it self-heals. --- pkg/audit/writer.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go index 548c380fe..feccdb6f3 100644 --- a/pkg/audit/writer.go +++ b/pkg/audit/writer.go @@ -18,6 +18,7 @@ package audit import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -132,6 +133,15 @@ func WriteAuditEvent(entry *Entry) error { return err } + // A failed rotation can leave us without an open file — retry the open + // here so writes self-heal via the router's retries instead of panicking. + if logFile == nil { + if err := openLogFileLocked(); err != nil { + mu.Unlock() + return err + } + } + written, err := logFile.Write(append(line, '\n')) currentSize += int64(written) if err == nil && time.Since(lastSync) > time.Second { @@ -159,7 +169,9 @@ func rotateIfNeededLocked(addition int64) error { rotatedPath := rotatedFileName(logfilePath, time.Now().UTC()) if err := os.Rename(logfilePath, rotatedPath); err != nil { // Reopen the original so logging continues even if rotation failed. - _ = openLogFileLocked() + if openErr := openLogFileLocked(); openErr != nil { + return errors.Join(fmt.Errorf("could not rotate audit log: %w", err), openErr) + } return fmt.Errorf("could not rotate audit log: %w", err) } From 3291556821f778f711d05267cb2a8fe52bc2c578 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:33:43 +0200 Subject: [PATCH 55/67] fix(audit): only attribute the logout event to user tokens Link share JWTs carry no sid claim so they returned before the event fired, but the id claim was read without checking the token type. Make the guard explicit so a link share id can never appear as a user id. --- pkg/routes/api/v1/login.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 7385ed1f6..8bcde0dcf 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -243,8 +243,12 @@ func Logout(c *echo.Context) (err error) { if jwtinf, ok := raw.(*jwt.Token); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { sid, _ = claims["sid"].(string) - if id, ok := claims["id"].(float64); ok { - userID = int64(id) + // 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) + } } } } From f33cde82e2a637a98df577442fe22c00738725e3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:34:45 +0200 Subject: [PATCH 56/67] feat(audit): attribute failed logins to the originating request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the request context through CheckUserCredentials so the LoginFailedEvent carries IP, user agent and request id — without it, failed logins were the one auth event useless for brute-force tracing. All four callers have the request at hand. --- pkg/models/user_settings.go | 6 ++++-- pkg/routes/api/v1/login.go | 2 +- pkg/routes/api/v1/user_update_email.go | 2 +- pkg/routes/api/v1/user_update_password.go | 2 +- pkg/routes/api/v2/user_settings.go | 4 ++-- pkg/routes/caldav/auth.go | 2 +- pkg/user/update_email.go | 6 ++++-- pkg/user/user.go | 12 +++++++----- pkg/user/user_test.go | 19 ++++++++++--------- 9 files changed, 31 insertions(+), 24 deletions(-) diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go index cba87cdb5..0d905cd1a 100644 --- a/pkg/models/user_settings.go +++ b/pkg/models/user_settings.go @@ -17,6 +17,8 @@ package models import ( + "context" + "code.vikunja.io/api/pkg/modules/avatar" "code.vikunja.io/api/pkg/user" @@ -66,12 +68,12 @@ func NewUserGeneralSettings(u *user.User) *UserGeneralSettings { // 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(s *xorm.Session, u *user.User, oldPassword, newPassword string) error { +func ChangeUserPassword(ctx context.Context, s *xorm.Session, u *user.User, oldPassword, newPassword string) error { if oldPassword == "" { return user.ErrEmptyOldPassword{} } - if _, err := user.CheckUserCredentials(s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { + if _, err := user.CheckUserCredentials(ctx, s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { return err } diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 8bcde0dcf..ae92e1d72 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -77,7 +77,7 @@ func Login(c *echo.Context) (err error) { } // This allows us to still have local users while ldap is enabled - user, err = user2.CheckUserCredentials(s, &u) + user, err = user2.CheckUserCredentials(c.Request().Context(), s, &u) if err != nil { _ = s.Rollback() return err diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go index e73ba6f89..7e03b250a 100644 --- a/pkg/routes/api/v1/user_update_email.go +++ b/pkg/routes/api/v1/user_update_email.go @@ -62,7 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) { s := db.NewSession() defer s.Close() - if err := user.ChangeUserEmail(s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); 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 52941a48a..87b372aff 100644 --- a/pkg/routes/api/v1/user_update_password.go +++ b/pkg/routes/api/v1/user_update_password.go @@ -66,7 +66,7 @@ func UserChangePassword(c *echo.Context) error { s := db.NewSession() defer s.Close() - if err := models.ChangeUserPassword(s, doer, newPW.OldPassword, newPW.NewPassword); 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/user_settings.go b/pkg/routes/api/v2/user_settings.go index a1f5bbee4..35a366644 100644 --- a/pkg/routes/api/v2/user_settings.go +++ b/pkg/routes/api/v2/user_settings.go @@ -176,7 +176,7 @@ func userChangePassword(ctx context.Context, in *struct { s := db.NewSession() defer s.Close() - if err := models.ChangeUserPassword(s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { + if err := models.ChangeUserPassword(ctx, s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { _ = s.Rollback() return nil, translateDomainError(err) } @@ -206,7 +206,7 @@ func userUpdateEmail(ctx context.Context, in *struct { s := db.NewSession() defer s.Close() - if err := user.ChangeUserEmail(s, doer, in.Body.Password, in.Body.NewEmail); err != nil { + if err := user.ChangeUserEmail(ctx, s, doer, in.Body.Password, in.Body.NewEmail); err != nil { _ = s.Rollback() return nil, translateDomainError(err) } 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/user/update_email.go b/pkg/user/update_email.go index b721ba518..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" @@ -34,8 +36,8 @@ type EmailUpdate struct { // 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(s *xorm.Session, u *User, password, newEmail string) error { - verified, err := CheckUserCredentials(s, &Login{Username: u.Username, Password: password}) +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 } diff --git a/pkg/user/user.go b/pkg/user/user.go index ab2912982..09fef2565 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -17,6 +17,7 @@ package user import ( + "context" "encoding/json" "errors" "fmt" @@ -363,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{} @@ -391,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 } @@ -411,8 +413,8 @@ func (u *User) IsLocalUser() bool { return u.Issuer == IssuerLocal } -func handleFailedPassword(user *User) { - if err := events.Dispatch(&LoginFailedEvent{User: user}); err != nil { +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) } 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)) }) From b3bcab1f729145bb6fbd98a215dbcf8d20b36475 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:39:08 +0200 Subject: [PATCH 57/67] refactor(events): use a concrete doer on project and team events ProjectUpdated/Deleted, ProjectSharedWith* and TeamCreated/Deleted carried an interface-typed Doer that could not be unmarshaled, forcing the audit registrations to decode anonymous mirror structs. Hydrate the doer via GetUserOrLinkShareUser at the dispatch sites like the task events already do, register the events directly and drop the untyped audit registration path. Webhook payloads for these events now serialize link share doers as their pseudo-user (negative id) instead of the raw link share object, consistent with task events. --- pkg/audit/listener.go | 18 ++----- pkg/models/events.go | 25 +++++----- pkg/models/listeners.go | 97 +++++++------------------------------ pkg/models/project.go | 12 ++++- pkg/models/project_team.go | 6 ++- pkg/models/project_users.go | 6 ++- pkg/models/teams.go | 8 ++- 7 files changed, 59 insertions(+), 113 deletions(-) diff --git a/pkg/audit/listener.go b/pkg/audit/listener.go index c0454512a..599a9b385 100644 --- a/pkg/audit/listener.go +++ b/pkg/audit/listener.go @@ -45,27 +45,15 @@ func RegisterEventForAudit[T any, PT interface { events.Event }](toEntry func(PT) *Entry) { name := PT(new(T)).Name() - RegisterEventNameForAudit(name, func(payload []byte) (*Entry, error) { - e := PT(new(T)) // fresh instance per message — handlers run concurrently - if err := json.Unmarshal(payload, e); err != nil { - return nil, err - } - return toEntry(e), nil - }) -} - -// RegisterEventNameForAudit is the untyped variant for events which cannot be -// unmarshaled into their Go struct directly (e.g. interface-typed Doer -// fields); the mapping decodes the raw payload itself. -func RegisterEventNameForAudit(name string, toEntry func(payload []byte) (*Entry, error)) { events.RegisterListener(name, &auditListener{handle: func(msg *message.Message) error { if !license.IsFeatureEnabled(license.FeatureAuditLogs) { return nil // license is runtime-mutable — checked per event, not at registration } - entry, err := toEntry(msg.Payload) - if err != nil { + e := PT(new(T)) // fresh instance per message — handlers run concurrently + if err := json.Unmarshal(msg.Payload, e); err != nil { return err } + entry := toEntry(e) if entry == nil { return nil } diff --git a/pkg/models/events.go b/pkg/models/events.go index 1996f54b8..b938345f4 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -18,7 +18,6 @@ package models import ( "code.vikunja.io/api/pkg/user" - "code.vikunja.io/api/pkg/web" ) ///////////////// @@ -230,8 +229,8 @@ func (l *ProjectCreatedEvent) Name() string { // ProjectUpdatedEvent represents an event where a project has been updated type ProjectUpdatedEvent struct { - Project *Project `json:"project"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectUpdatedEvent @@ -241,8 +240,8 @@ func (p *ProjectUpdatedEvent) Name() string { // ProjectDeletedEvent represents an event where a project has been deleted type ProjectDeletedEvent struct { - Project *Project `json:"project"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectDeletedEvent @@ -258,7 +257,7 @@ func (p *ProjectDeletedEvent) Name() string { type ProjectSharedWithUserEvent struct { Project *Project `json:"project"` User *user.User `json:"user"` - Doer web.Auth `json:"doer"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectSharedWithUserEvent @@ -268,9 +267,9 @@ func (p *ProjectSharedWithUserEvent) Name() string { // ProjectSharedWithTeamEvent represents an event where a project has been shared with a team type ProjectSharedWithTeamEvent struct { - Project *Project `json:"project"` - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectSharedWithTeamEvent @@ -308,8 +307,8 @@ func (t *TeamMemberRemovedEvent) Name() string { // TeamCreatedEvent represents a TeamCreatedEvent event type TeamCreatedEvent struct { - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for TeamCreatedEvent @@ -319,8 +318,8 @@ func (t *TeamCreatedEvent) Name() string { // TeamDeletedEvent represents a TeamDeletedEvent event type TeamDeletedEvent struct { - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for TeamDeletedEvent diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index e29bb2369..e50631ae1 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -88,23 +88,6 @@ func RegisterListeners() { } } -// auditDoerRef decodes the doer of events whose Doer field is an interface -// and thus can't be unmarshaled into the event struct directly. -type auditDoerRef struct { - ID int64 `json:"id"` - Hash string `json:"hash"` // only set when the doer is a link share -} - -func auditActorFromDoerRef(d *auditDoerRef) audit.Actor { - if d == nil { - return audit.SystemActor() - } - if d.Hash != "" { - return audit.LinkShareActor(d.ID) - } - return audit.ActorFromDoerID(d.ID) -} - func auditActorFromUser(u *user.User) audit.Actor { if u == nil { return audit.SystemActor() @@ -281,95 +264,51 @@ func registerEventsForAuditLogging() { Target: audit.ProjectTarget(e.Project.ID), } }) - audit.RegisterEventNameForAudit((&ProjectUpdatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err - } + audit.RegisterEventForAudit(func(e *ProjectUpdatedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectUpdated, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), - }, nil - }) - audit.RegisterEventNameForAudit((&ProjectDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *ProjectDeletedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectDeleted, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), - }, nil - }) - audit.RegisterEventNameForAudit((&ProjectSharedWithUserEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - User *user.User `json:"user"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithUserEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectSharedWithUser, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), Metadata: map[string]any{"user_id": e.User.ID}, - }, nil - }) - audit.RegisterEventNameForAudit((&ProjectSharedWithTeamEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - Team *Team `json:"team"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithTeamEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectSharedWithTeam, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), Metadata: map[string]any{"team_id": e.Team.ID}, - }, nil + } }) // Teams - audit.RegisterEventNameForAudit((&TeamCreatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Team *Team `json:"team"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err - } + audit.RegisterEventForAudit(func(e *TeamCreatedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionTeamCreated, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.TeamTarget(e.Team.ID), - }, nil - }) - audit.RegisterEventNameForAudit((&TeamDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Team *Team `json:"team"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *TeamDeletedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionTeamDeleted, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.TeamTarget(e.Team.ID), - }, nil + } }) audit.RegisterEventForAudit(func(e *TeamMemberAddedEvent) *audit.Entry { return &audit.Entry{ diff --git a/pkg/models/project.go b/pkg/models/project.go index 23fc9f6ca..019afe792 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1217,9 +1217,13 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje return err } + doer, err := GetUserOrLinkShareUser(s, auth) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectUpdatedEvent{ Project: project, - Doer: auth, + Doer: doer, }) l, err := GetProjectSimpleByID(s, project.ID) @@ -1448,9 +1452,13 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { return } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectDeletedEvent{ Project: fullProject, - Doer: a, + Doer: doer, }) childProjects := []*Project{} diff --git a/pkg/models/project_team.go b/pkg/models/project_team.go index 0c9fb6908..4f3ed9c42 100644 --- a/pkg/models/project_team.go +++ b/pkg/models/project_team.go @@ -109,10 +109,14 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) { return err } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{ Project: l, Team: team, - Doer: a, + Doer: doer, }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/project_users.go b/pkg/models/project_users.go index 58ef71c38..1470dd1bb 100644 --- a/pkg/models/project_users.go +++ b/pkg/models/project_users.go @@ -115,10 +115,14 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) { return err } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{ Project: l, User: u, - Doer: a, + Doer: doer, }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/teams.go b/pkg/models/teams.go index 98c87161c..e1ac8887c 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 } @@ -360,9 +360,13 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { return } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &TeamDeletedEvent{ Team: t, - Doer: a, + Doer: doer, }) return nil } From f0eff5294936c3617f459ecb9b7c77c0be49a696 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 09:36:07 +0200 Subject: [PATCH 58/67] fix(events): build event doers without re-fetching the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetUserOrLinkShareUser re-fetches the account and fails its status check, which broke deleting a disabled user's projects (the deletion runs with the disabled account as doer). Convert the authenticated principal directly instead — it also matches what the events serialized before the doer became concrete, and drops a query per event. --- pkg/models/project.go | 12 ++---------- pkg/models/project_team.go | 6 +----- pkg/models/project_users.go | 6 +----- pkg/models/teams.go | 6 +----- pkg/models/users.go | 14 ++++++++++++++ 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/pkg/models/project.go b/pkg/models/project.go index 019afe792..a799d1815 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1217,13 +1217,9 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje return err } - doer, err := GetUserOrLinkShareUser(s, auth) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectUpdatedEvent{ Project: project, - Doer: doer, + Doer: doerFromAuth(auth), }) l, err := GetProjectSimpleByID(s, project.ID) @@ -1452,13 +1448,9 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { return } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectDeletedEvent{ Project: fullProject, - Doer: doer, + Doer: doerFromAuth(a), }) childProjects := []*Project{} diff --git a/pkg/models/project_team.go b/pkg/models/project_team.go index 4f3ed9c42..e3571906c 100644 --- a/pkg/models/project_team.go +++ b/pkg/models/project_team.go @@ -109,14 +109,10 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) { return err } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{ Project: l, Team: team, - Doer: doer, + Doer: doerFromAuth(a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/project_users.go b/pkg/models/project_users.go index 1470dd1bb..41254ac1d 100644 --- a/pkg/models/project_users.go +++ b/pkg/models/project_users.go @@ -115,14 +115,10 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) { return err } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{ Project: l, User: u, - Doer: doer, + Doer: doerFromAuth(a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/teams.go b/pkg/models/teams.go index e1ac8887c..6f73dc3ae 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -360,13 +360,9 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { return } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &TeamDeletedEvent{ Team: t, - Doer: doer, + Doer: doerFromAuth(a), }) return nil } diff --git a/pkg/models/users.go b/pkg/models/users.go index da2b7af97..51b6ede2a 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -22,6 +22,20 @@ import ( "xorm.io/xorm" ) +// doerFromAuth converts the authenticated principal into a user for event +// payloads without re-fetching it. A re-fetch would fail its status check in +// flows acting on behalf of disabled accounts (e.g. user deletion), and the +// event only needs the principal as it authenticated. +func doerFromAuth(a web.Auth) *user.User { + 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 { From 0eb39fae9a4f11cff08fff72f6fc752689c43792 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 09:44:43 +0200 Subject: [PATCH 59/67] fix(events): handle nil auth when building event doers ProjectUser.Create and friends are called with a nil auth in tests; the old interface-typed Doer just serialized as null, so a nil doer keeps that behavior (and maps to the system actor in the audit entry). --- pkg/models/users.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/models/users.go b/pkg/models/users.go index 51b6ede2a..84a7101da 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -27,6 +27,9 @@ import ( // flows acting on behalf of disabled accounts (e.g. user deletion), and the // event only needs the principal as it authenticated. func doerFromAuth(a web.Auth) *user.User { + if a == nil { + return nil + } if u, is := a.(*user.User); is { return u } From acdc2a07f26b8fff74e1ef2bba71a20ff5e6f2cc Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:43:38 +0200 Subject: [PATCH 60/67] feat(audit): emit the login event for the OAuth code exchange The new v2 OAuth token endpoint mints a fresh session without going through NewUserAuthTokenResponse, so those logins were missing from the audit trail. The refresh grant stays unaudited like the v1 refresh. --- pkg/modules/auth/oauth2server/token.go | 18 ++++++++++++++---- pkg/routes/api/v2/oauth.go | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 9d8d33a9a..11f85772e 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" @@ -56,7 +60,7 @@ func HandleToken(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } - resp, err := ExchangeToken(&req, c.Request().UserAgent(), c.RealIP()) + resp, err := ExchangeToken(c.Request().Context(), &req, c.Request().UserAgent(), c.RealIP()) if err != nil { return err } @@ -69,10 +73,10 @@ func HandleToken(c *echo.Context) error { // 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(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { +func ExchangeToken(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return exchangeAuthorizationCode(req, deviceInfo, ipAddress) + return exchangeAuthorizationCode(ctx, req, deviceInfo, ipAddress) case "refresh_token": return exchangeRefreshToken(req) default: @@ -80,7 +84,7 @@ func ExchangeToken(req *TokenRequest, deviceInfo, ipAddress string) (*TokenRespo } } -func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { +func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -133,6 +137,12 @@ func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) return nil, err } + // 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", diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go index 9b13c7654..45d1efe57 100644 --- a/pkg/routes/api/v2/oauth.go +++ b/pkg/routes/api/v2/oauth.go @@ -76,7 +76,7 @@ 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(&in.Body, deviceInfo, ipAddress) + resp, err := oauth2server.ExchangeToken(ctx, &in.Body, deviceInfo, ipAddress) if err != nil { return nil, translateDomainError(err) } From 5e00fcbbb827bec84dd07539335e6622acdc982e Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:43:38 +0200 Subject: [PATCH 61/67] chore(lint): suppress contextcheck on OIDC provider init call sites Adding a context parameter to the shared package put its call chains in contextcheck's scope; the flagged background context in the provider setup is deliberate since provider lifetime exceeds any request. --- pkg/routes/api/v2/admin_users.go | 2 +- pkg/routes/api/v2/info.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/routes/api/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go index 1588b1643..2724e433c 100644 --- a/pkg/routes/api/v2/admin_users.go +++ b/pkg/routes/api/v2/admin_users.go @@ -127,7 +127,7 @@ func adminUsersCreate(_ context.Context, in *struct{ Body models.CreateUserBody if err != nil { return nil, translateDomainError(err) } - return &adminUserBody{Body: shared.NewAdminUser(newUser, providers)}, nil + 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 { diff --git a/pkg/routes/api/v2/info.go b/pkg/routes/api/v2/info.go index 483abf027..3b4256363 100644 --- a/pkg/routes/api/v2/info.go +++ b/pkg/routes/api/v2/info.go @@ -47,5 +47,5 @@ func RegisterInfoRoutes(api huma.API) { func init() { AddRouteRegistrar(RegisterInfoRoutes) } func info(_ context.Context, _ *struct{}) (*infoBody, error) { - return &infoBody{Body: shared.BuildInfo()}, nil + return &infoBody{Body: shared.BuildInfo()}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request } From 8381f7543f2051c5a1b0d612bdec94958d5bafb3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:00:42 +0200 Subject: [PATCH 62/67] refactor(background): share upload validation between v1 and v2 handlers Extract the MIME validation, file storage and project reload from the v1 UploadBackground handler into ValidateAndSaveBackgroundUpload so the upcoming v2 handler can reuse it instead of duplicating the logic. The v1 handler keeps its exact wire behaviour; the inline "not an image" check now returns a typed ErrFileIsNoImage that the handler maps to the same message. --- pkg/modules/background/handler/background.go | 68 +++++++++++--------- pkg/modules/background/handler/errors.go | 19 ++++++ 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index a89784ee9..afe7901e5 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -204,44 +204,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 +226,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) 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) +} From 3af5eb8208591123b4501abe153b1cca51cd67e2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:00:49 +0200 Subject: [PATCH 63/67] feat(api/v2): add project background upload on /api/v2 Port PUT /projects/{project}/backgrounds/upload to the Huma-backed v2 API. The multipart handler reuses handler.ValidateAndSaveBackgroundUpload (shared with v1), checks project write access explicitly, and is gated on the upload provider config flag. Adds webtests covering the happy path, auth/permission failures, non-image rejection, the disabled-provider case and the multipart spec shape. --- pkg/routes/api/v2/backgrounds.go | 75 ++++++++++ pkg/webtests/huma_background_upload_test.go | 151 ++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 pkg/webtests/huma_background_upload_test.go diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go index c56d1acce..f01fcb4e3 100644 --- a/pkg/routes/api/v2/backgrounds.go +++ b/pkg/routes/api/v2/backgrounds.go @@ -24,6 +24,7 @@ import ( "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" "github.com/danielgtaylor/huma/v2" @@ -54,6 +55,22 @@ func RegisterBackgroundRoutes(api huma.API) { Tags: tags, }, backgroundRemove) + 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", @@ -152,6 +169,64 @@ func backgroundUnsplashSet(ctx context.Context, in *struct { 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 +} + func backgroundRemove(ctx context.Context, in *struct { ProjectID int64 `path:"project"` }) (*singleBody[models.Project], error) { 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()) +} From a881246e802d41401d9c869e243a9b9b1c972cfd Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:28 +0200 Subject: [PATCH 64/67] refactor(migration): extract file/CSV migrate orchestration into shared funcs Pull the StartMigration -> Migrate -> FinishMigration orchestration out of the v1 echo handlers into handler.RunFileMigration and csv.RunMigration so the v2 API can reuse the exact same business logic. v1 is refactored onto them and stays byte-identical on the wire. Also tag the CSV detect/preview/config DTOs with doc:/enum: so they carry descriptions in the v2 OpenAPI schema (ignored by v1 swaggo/xorm). --- pkg/modules/migration/csv/csv.go | 48 ++++++++++++------- pkg/modules/migration/csv/handler.go | 14 +----- pkg/modules/migration/handler/handler_file.go | 31 +++++++----- 3 files changed, 51 insertions(+), 42 deletions(-) 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/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 } From a21822fcec721f71596320cd3b2293ce036a3f0a Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:42 +0200 Subject: [PATCH 65/67] feat(api/v2): add file migrators (vikunja-file, ticktick, wekan) on /api/v2 Port the file-based migrators' status + migrate endpoints to the Huma API. A single registerFileMigrator helper wires all three (mirroring the OAuth migrator registrar); the migrate endpoint takes a multipart upload under the "import" field and reuses handler.RunFileMigration. POST migrate returns 200 since it runs an import rather than creating a REST resource. --- pkg/routes/api/v2/migration_file.go | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 pkg/routes/api/v2/migration_file.go 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 +} From 77416d32e428ff054f4d623ec7eac3d4cc5eaf87 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:42 +0200 Subject: [PATCH 66/67] feat(api/v2): add the generic CSV importer on /api/v2 Port the CSV importer's status/detect/preview/migrate endpoints to the Huma API. detect/preview/migrate take a multipart upload; preview and migrate also carry the import config as a JSON form value (modeled as a typed multipart form field), unmarshaled in one shared place and reused via csv.RunMigration. --- pkg/routes/api/v2/migration_csv.go | 200 +++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 pkg/routes/api/v2/migration_csv.go 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 +} From a8a53c9581a0c86ba24d9833a0fb6b593eac2d89 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:52 +0200 Subject: [PATCH 67/67] test(api/v2): cover the v2 file and CSV migrator endpoints Webtests for the file migrators (status, migrate, auth, missing-file) and the CSV importer (status, detect, preview, migrate happy path, missing/malformed config, empty file, auth). Each rejected upload is asserted to map to a 4xx domain error rather than a 500. --- pkg/webtests/huma_migration_csv_test.go | 125 ++++++++++++++++++++++ pkg/webtests/huma_migration_file_test.go | 128 +++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 pkg/webtests/huma_migration_csv_test.go create mode 100644 pkg/webtests/huma_migration_file_test.go 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()) +}