diff --git a/.golangci.yml b/.golangci.yml index 6f1a759f2..19ee2f531 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -145,6 +145,13 @@ 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: + - 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/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/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 e790717ee..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==} @@ -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==} @@ -8744,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 @@ -8763,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 @@ -8782,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 @@ -8804,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': {} @@ -9432,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: @@ -9669,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: @@ -9681,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': {} @@ -9698,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 @@ -9722,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)': @@ -9953,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': @@ -9968,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: @@ -10293,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 @@ -10412,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) @@ -10423,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: @@ -10475,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: @@ -11504,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 @@ -11565,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 @@ -11595,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' @@ -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: @@ -14037,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 @@ -14068,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 @@ -14078,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) @@ -14119,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 @@ -14131,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) @@ -14140,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 @@ -14149,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 @@ -14169,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: 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; 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/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/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": "секунда|секунд(и)", 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'), 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') }} diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go new file mode 100644 index 000000000..897ebe93c --- /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) []string { + t.Helper() + var lines []string + require.Eventually(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) >= 1 + }, 5*time.Second, 10*time.Millisecond, "expected at least one audit log line") + 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})) + + waitForLines(t, logfile) + 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) + 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) + 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) + require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) + waitForLines(t, logfile) + + 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) +} diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go new file mode 100644 index 000000000..bb7f98493 --- /dev/null +++ b/pkg/audit/entry.go @@ -0,0 +1,154 @@ +// 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 + +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" // #nosec G101 -- action identifier, not a credential + ActionAPITokenRevoked = "auth.api_token.revoked" // #nosec G101 + ActionAPITokenUsed = "auth.api_token.used" // #nosec G101 + + 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..599a9b385 --- /dev/null +++ b/pkg/audit/listener.go @@ -0,0 +1,76 @@ +// 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() + 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 + } + 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 + } + 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..feccdb6f3 --- /dev/null +++ b/pkg/audit/writer.go @@ -0,0 +1,211 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package audit + +import ( + "encoding/json" + "errors" + "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 + } + + // 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 { + 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. + 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) + } + + 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) + } + } +} 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) 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/events/events.go b/pkg/events/events.go index 30c26ea99..5973b132d 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,6 +223,17 @@ 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) } @@ -241,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 @@ -251,7 +270,7 @@ func DispatchPending(key any) { // No need to lock here since we've already removed it from the map // and this key won't receive new events 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/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/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/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/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_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/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/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..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 @@ -395,3 +394,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/models/listeners.go b/pkg/models/listeners.go index 83ec34c9b..e50631ae1 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,249 @@ func RegisterListeners() { // Internal delivery listener — one message per webhook with its own retry lifecycle events.RegisterListener((&WebhookDeliveryEvent{}).Name(), &WebhookDeliveryListener{}) } + if config.AuditEnabled.GetBool() { + registerEventsForAuditLogging() + } +} + +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.RegisterEventForAudit(func(e *ProjectUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + } + }) + audit.RegisterEventForAudit(func(e *ProjectDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithUserEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectSharedWithUser, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"user_id": e.User.ID}, + } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithTeamEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectSharedWithTeam, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"team_id": e.Team.ID}, + } + }) + + // Teams + audit.RegisterEventForAudit(func(e *TeamCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + } + }) + audit.RegisterEventForAudit(func(e *TeamDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + } + }) + 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}, + } + }) } ////// diff --git a/pkg/models/project.go b/pkg/models/project.go index 23fc9f6ca..a799d1815 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1219,7 +1219,7 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje events.DispatchOnCommit(s, &ProjectUpdatedEvent{ Project: project, - Doer: auth, + Doer: doerFromAuth(auth), }) l, err := GetProjectSimpleByID(s, project.ID) @@ -1450,7 +1450,7 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectDeletedEvent{ Project: fullProject, - Doer: a, + Doer: doerFromAuth(a), }) childProjects := []*Project{} diff --git a/pkg/models/project_team.go b/pkg/models/project_team.go index 0c9fb6908..e3571906c 100644 --- a/pkg/models/project_team.go +++ b/pkg/models/project_team.go @@ -112,7 +112,7 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{ Project: l, Team: team, - Doer: a, + Doer: doerFromAuth(a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/project_users.go b/pkg/models/project_users.go index 58ef71c38..41254ac1d 100644 --- a/pkg/models/project_users.go +++ b/pkg/models/project_users.go @@ -118,7 +118,7 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{ Project: l, User: u, - Doer: a, + Doer: doerFromAuth(a), }) err = updateProjectLastUpdated(s, l) 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) } 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/teams.go b/pkg/models/teams.go index 98c87161c..6f73dc3ae 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -222,7 +222,7 @@ func (t *Team) CreateNewTeam(s *xorm.Session, a web.Auth, firstUserShouldBeAdmin events.DispatchOnCommit(s, &TeamCreatedEvent{ Team: t, - Doer: a, + Doer: doer, }) return nil } @@ -362,7 +362,7 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &TeamDeletedEvent{ Team: t, - Doer: a, + Doer: doerFromAuth(a), }) return nil } 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/models/user_project.go b/pkg/models/user_project.go index 34ea85abe..7fe98c772 100644 --- a/pkg/models/user_project.go +++ b/pkg/models/user_project.go @@ -18,10 +18,30 @@ package models import ( "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" "xorm.io/builder" "xorm.io/xorm" ) +// SearchUsersForProject performs the per-project user search shared by both API +// versions: it checks the caller can read the project, then lists the users +// with access to it. canRead is false (with no error) when the caller lacks +// read access, so each handler can map that to its own forbidden response. +func SearchUsersForProject(s *xorm.Session, project *Project, a web.Auth, currentUser *user.User, search string) (users []*user.User, canRead bool, err error) { + canRead, _, err = project.CanRead(s, a) + if err != nil { + return nil, false, err + } + if !canRead { + return nil, false, nil + } + users, err = ListUsersFromProject(s, project, currentUser, search) + if err != nil { + return nil, true, err + } + return users, true, nil +} + // ProjectUIDs hold all kinds of user IDs from accounts who have access to a project type ProjectUIDs struct { ProjectOwnerID int64 `xorm:"projectOwner"` diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go new file mode 100644 index 000000000..0d905cd1a --- /dev/null +++ b/pkg/models/user_settings.go @@ -0,0 +1,130 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "context" + + "code.vikunja.io/api/pkg/modules/avatar" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// UserGeneralSettings is the single user-settings wire struct shared by v1 and +// v2 — both the update request body and the nested settings on GET /user. A +// dedicated struct (not user.User) is required: user.User's settings fields are +// json:"-" so they don't leak when it is embedded in other responses +// (assignees, created_by, members …). +type UserGeneralSettings struct { + Name string `json:"name" doc:"The full name of the user."` + EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"If enabled, sends email reminders of tasks to the user."` + DiscoverableByName bool `json:"discoverable_by_name" doc:"If true, this user can be found by their name or parts of it when searching."` + DiscoverableByEmail bool `json:"discoverable_by_email" doc:"If true, the user can be found when searching for their exact email."` + OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled" doc:"If enabled, the user gets an email for their overdue tasks each morning."` + OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required" doc:"The time the daily overdue-tasks summary is sent, as HH:MM."` + DefaultProjectID int64 `json:"default_project_id" doc:"Project a task is filed under when created without an explicit project."` + WeekStart int `json:"week_start" valid:"range(0|6)" minimum:"0" maximum:"6" doc:"The day the week starts on: 0=sunday, 1=monday, … 6=saturday."` + Language string `json:"language" doc:"The user's language."` + Timezone string `json:"timezone" doc:"The user's time zone, used to send task reminders in their local time."` + FrontendSettings any `json:"frontend_settings" doc:"Arbitrary settings used only by the frontend. Any JSON value; stored and returned verbatim."` + // Server/OpenID-provided; populated on read, ignored on write. + ExtraSettingsLinks map[string]any `json:"extra_settings_links" readOnly:"true" doc:"Additional settings links provided by the OpenID provider. Server-controlled."` +} + +// NewUserGeneralSettings projects a user's stored settings into the shared wire +// struct for GET /user. Used by both the v1 and v2 user-show handlers. +func NewUserGeneralSettings(u *user.User) *UserGeneralSettings { + return &UserGeneralSettings{ + Name: u.Name, + EmailRemindersEnabled: u.EmailRemindersEnabled, + DiscoverableByName: u.DiscoverableByName, + DiscoverableByEmail: u.DiscoverableByEmail, + OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, + OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, + DefaultProjectID: u.DefaultProjectID, + WeekStart: u.WeekStart, + Language: u.Language, + Timezone: u.Timezone, + FrontendSettings: u.FrontendSettings, + ExtraSettingsLinks: u.ExtraSettingsLinks, + } +} + +// ChangeUserPassword verifies the old password, sets the new one, and +// invalidates all of the user's sessions. Lives here (not in pkg/user) because +// it needs DeleteAllUserSessions, which pkg/user cannot import. +func ChangeUserPassword(ctx context.Context, s *xorm.Session, u *user.User, oldPassword, newPassword string) error { + if oldPassword == "" { + return user.ErrEmptyOldPassword{} + } + + if _, err := user.CheckUserCredentials(ctx, s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { + return err + } + + if err := user.UpdateUserPassword(s, u, newPassword); err != nil { + return err + } + + return DeleteAllUserSessions(s, u.ID) +} + +// UpdateUserGeneralSettings copies the general settings onto the user, persists +// them, and flushes the avatar cache when an initials avatar's name changed. +// Lives here (not in pkg/user) because the avatar flush needs pkg/modules/avatar, +// which pkg/user cannot import. +func UpdateUserGeneralSettings(s *xorm.Session, u *user.User, settings *UserGeneralSettings) error { + invalidateAvatar := u.AvatarProvider == "initials" && u.Name != settings.Name + + u.Name = settings.Name + u.EmailRemindersEnabled = settings.EmailRemindersEnabled + u.DiscoverableByEmail = settings.DiscoverableByEmail + u.DiscoverableByName = settings.DiscoverableByName + u.OverdueTasksRemindersEnabled = settings.OverdueTasksRemindersEnabled + u.DefaultProjectID = settings.DefaultProjectID + u.WeekStart = settings.WeekStart + u.Language = settings.Language + u.Timezone = settings.Timezone + u.OverdueTasksRemindersTime = settings.OverdueTasksRemindersTime + u.FrontendSettings = settings.FrontendSettings + + if _, err := user.UpdateUser(s, u, true); err != nil { + return err + } + + if invalidateAvatar { + avatar.FlushAllCaches(u) + } + return nil +} + +// UpdateUserAvatarProvider sets the user's avatar provider, persists it, and +// flushes the avatar cache when the provider changes (or is set to initials). +func UpdateUserAvatarProvider(s *xorm.Session, u *user.User, provider string) error { + oldProvider := u.AvatarProvider + u.AvatarProvider = provider + + if _, err := user.UpdateUser(s, u, false); err != nil { + return err + } + + if u.AvatarProvider == "initials" || oldProvider != u.AvatarProvider { + avatar.FlushAllCaches(u) + } + return nil +} diff --git a/pkg/models/users.go b/pkg/models/users.go index da2b7af97..84a7101da 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -22,6 +22,23 @@ 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 a == nil { + return nil + } + if u, is := a.(*user.User); is { + return u + } + if share, is := a.(*LinkSharing); is { + return share.toUser() + } + return &user.User{ID: a.GetID()} +} + // GetUserOrLinkShareUser returns either a user or a link share disguised as a user. func GetUserOrLinkShareUser(s *xorm.Session, a web.Auth) (uu *user.User, err error) { if u, is := a.(*user.User); is { diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index fd7ee8e81..b4038bf6c 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -40,6 +40,7 @@ import ( "code.vikunja.io/api/pkg/version" "code.vikunja.io/api/pkg/web" + "xorm.io/builder" "xorm.io/xorm" ) @@ -216,24 +217,36 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 500 {object} models.Message "Internal server error" // @Router /projects/{id}/webhooks [get] func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { - p := &Project{ID: w.ProjectID} - can, _, err := p.CanRead(s, a) - if err != nil { - return nil, 0, 0, err - } - if !can { - return nil, 0, 0, ErrGenericForbidden{} + // w.UserID set selects the user-level list: a user may only see their own + // webhooks. The project list (w.UserID == 0) delegates to the project's read + // permission instead. + var listCond builder.Cond + if w.UserID > 0 { + if _, isShareAuth := a.(*LinkSharing); isShareAuth || w.UserID != a.GetID() { + return nil, 0, 0, ErrGenericForbidden{} + } + listCond = builder.Eq{"user_id": w.UserID} + } else { + p := &Project{ID: w.ProjectID} + can, _, cerr := p.CanRead(s, a) + if cerr != nil { + return nil, 0, 0, cerr + } + if !can { + return nil, 0, 0, ErrGenericForbidden{} + } + listCond = builder.Eq{"project_id": w.ProjectID} } ws := []*Webhook{} - err = s.Where("project_id = ?", w.ProjectID). + err = s.Where(listCond). Limit(getLimitFromPageIndex(page, perPage)). Find(&ws) if err != nil { return } - total, err := s.Where("project_id = ?", w.ProjectID). + total, err := s.Where(listCond). Count(&Webhook{}) if err != nil { return diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 61a5f9b13..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/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..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" @@ -36,35 +40,51 @@ type TokenResponse struct { RefreshToken string `json:"refresh_token"` } -// tokenRequest holds the JSON body of a POST /oauth/token request. -type tokenRequest struct { - GrantType string `json:"grant_type"` - Code string `json:"code"` - ClientID string `json:"client_id"` - RedirectURI string `json:"redirect_uri"` - CodeVerifier string `json:"code_verifier"` - RefreshToken string `json:"refresh_token"` +// TokenRequest holds the parameters of a POST /oauth/token request. v1 binds it +// from JSON; v2 accepts spec-compliant application/x-www-form-urlencoded as well +// (form tags mirror the json names). +type TokenRequest struct { + GrantType string `json:"grant_type" form:"grant_type"` + Code string `json:"code" form:"code"` + ClientID string `json:"client_id" form:"client_id"` + RedirectURI string `json:"redirect_uri" form:"redirect_uri"` + CodeVerifier string `json:"code_verifier" form:"code_verifier"` + RefreshToken string `json:"refresh_token" form:"refresh_token"` } // HandleToken handles POST /oauth/token. // Supports grant_type=authorization_code and grant_type=refresh_token. func HandleToken(c *echo.Context) error { - var req tokenRequest + var req TokenRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } + resp, err := ExchangeToken(c.Request().Context(), &req, c.Request().UserAgent(), c.RealIP()) + if err != nil { + return err + } + + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, resp) +} + +// ExchangeToken runs the grant-type dispatch and token issuance for the OAuth +// token endpoint, independent of the HTTP layer. Callers own request binding and +// the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded +// on the session created for the authorization_code grant. +func ExchangeToken(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return handleAuthorizationCodeGrant(c, &req) + return exchangeAuthorizationCode(ctx, req, deviceInfo, ipAddress) case "refresh_token": - return handleRefreshTokenGrant(c, &req) + return exchangeRefreshToken(req) default: - return &models.ErrOAuthInvalidGrantType{} + return nil, &models.ErrOAuthInvalidGrantType{} } } -func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { +func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -72,73 +92,75 @@ func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { oauthCode, err := models.GetAndDeleteOAuthCode(s, req.Code) if err != nil { _ = s.Rollback() - return err + return nil, err } // Validate client_id matches if oauthCode.ClientID != req.ClientID { _ = s.Rollback() - return &models.ErrOAuthClientNotFound{} + return nil, &models.ErrOAuthClientNotFound{} } // Validate redirect_uri matches if oauthCode.RedirectURI != req.RedirectURI { _ = s.Rollback() - return &models.ErrOAuthInvalidRedirectURI{} + return nil, &models.ErrOAuthInvalidRedirectURI{} } // Verify PKCE if !VerifyPKCE(req.CodeVerifier, oauthCode.CodeChallenge, oauthCode.CodeChallengeMethod) { _ = s.Rollback() - return &models.ErrOAuthPKCEVerifyFailed{} + return nil, &models.ErrOAuthPKCEVerifyFailed{} } // Create a session (reuses existing session infrastructure) - deviceInfo := c.Request().UserAgent() - ipAddress := c.RealIP() session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false) if err != nil { _ = s.Rollback() - return err + return nil, err } u, err := user.GetUserByID(s, oauthCode.UserID) if err != nil { _ = s.Rollback() - return err + return nil, err } // Generate JWT accessToken, err := auth.NewUserJWTAuthtoken(u, session.ID) if err != nil { _ = s.Rollback() - return err + return nil, err } if err := s.Commit(); err != nil { - return err + return nil, err } - c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, TokenResponse{ + // The code exchange mints a fresh session, so it is a login for the + // audit trail, same as NewUserAuthTokenResponse. + if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil { + log.Errorf("Could not dispatch login succeeded event: %s", err) + } + + return &TokenResponse{ AccessToken: accessToken, TokenType: "bearer", ExpiresIn: config.ServiceJWTTTLShort.GetInt64(), RefreshToken: session.RefreshToken, - }) + }, nil } -func handleRefreshTokenGrant(c *echo.Context, req *tokenRequest) error { +func exchangeRefreshToken(req *TokenRequest) (*TokenResponse, error) { result, err := auth.RefreshSession(req.RefreshToken) if err != nil { - return err + return nil, err } - c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, TokenResponse{ + return &TokenResponse{ AccessToken: result.AccessToken, TokenType: "bearer", ExpiresIn: result.ExpiresIn, RefreshToken: result.NewRefreshToken, - }) + }, nil } diff --git a/pkg/modules/auth/openid/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/modules/background/background.go b/pkg/modules/background/background.go index 161485bfe..7e48d2d16 100644 --- a/pkg/modules/background/background.go +++ b/pkg/modules/background/background.go @@ -24,12 +24,12 @@ import ( // Image represents an image which can be used as a project background type Image struct { - ID string `json:"id"` - URL string `json:"url"` - Thumb string `json:"thumb,omitempty"` - BlurHash string `json:"blur_hash"` + ID string `json:"id" doc:"The provider-specific id of the image; pass this back to set it as a background."` + URL string `json:"url" doc:"The full-size URL of the image."` + Thumb string `json:"thumb,omitempty" doc:"A thumbnail URL of the image, if the provider supplies one."` + BlurHash string `json:"blur_hash" doc:"A BlurHash placeholder for the image."` // This can be used to supply extra information from an image provider to clients - Info interface{} `json:"info,omitempty"` + Info interface{} `json:"info,omitempty" doc:"Provider-specific extra information about the image (e.g. the Unsplash author for attribution)."` } const MaxBackgroundImageHeight = 3840 diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index a89784ee9..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) +} diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 0e9c9b942..d59dd6946 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -18,6 +18,7 @@ package migration import ( "bytes" + "context" "xorm.io/xorm" @@ -50,7 +51,7 @@ func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.Us return err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) return nil } diff --git a/pkg/modules/migration/csv/csv.go b/pkg/modules/migration/csv/csv.go index a0bee4f08..6d9ded38d 100644 --- a/pkg/modules/migration/csv/csv.go +++ b/pkg/modules/migration/csv/csv.go @@ -107,28 +107,28 @@ var AllTaskAttributes = []TaskAttribute{ // ColumnMapping represents a mapping from a CSV column to a task attribute type ColumnMapping struct { - ColumnIndex int `json:"column_index"` - ColumnName string `json:"column_name"` - Attribute TaskAttribute `json:"attribute"` + ColumnIndex int `json:"column_index" doc:"The zero-based index of the CSV column this mapping applies to."` + ColumnName string `json:"column_name" doc:"The header name of the CSV column, for display."` + Attribute TaskAttribute `json:"attribute" enum:"title,description,due_date,start_date,end_date,done,priority,labels,project,reminder,ignore" doc:"The task attribute the column maps to. Use \"ignore\" to drop the column."` } // DetectionResult contains the auto-detected CSV structure type DetectionResult struct { - Columns []string `json:"columns"` - Delimiter string `json:"delimiter"` - QuoteChar string `json:"quote_char"` - DateFormat string `json:"date_format"` - SuggestedMapping []ColumnMapping `json:"suggested_mapping"` - PreviewRows [][]string `json:"preview_rows"` + Columns []string `json:"columns" doc:"The detected column header names, in order."` + Delimiter string `json:"delimiter" doc:"The detected field delimiter (one of \",\", \";\", tab, \"|\")."` + QuoteChar string `json:"quote_char" doc:"The detected quote character."` + DateFormat string `json:"date_format" doc:"The detected Go reference date layout used to parse date columns."` + SuggestedMapping []ColumnMapping `json:"suggested_mapping" doc:"A best-guess column-to-attribute mapping; the client may edit it before previewing or migrating."` + PreviewRows [][]string `json:"preview_rows" doc:"The first few raw rows of the file, for the client to render a preview."` } // ImportConfig contains the configuration for CSV import type ImportConfig struct { - Delimiter string `json:"delimiter"` - QuoteChar string `json:"quote_char"` - DateFormat string `json:"date_format"` - SkipRows int `json:"skip_rows"` - Mapping []ColumnMapping `json:"mapping"` + Delimiter string `json:"delimiter" doc:"The field delimiter to parse with. Defaults to comma when empty."` + QuoteChar string `json:"quote_char" doc:"The quote character to parse with."` + DateFormat string `json:"date_format" doc:"The Go reference date layout used to parse date columns."` + SkipRows int `json:"skip_rows" doc:"Number of leading rows to skip (e.g. a header row) before importing."` + Mapping []ColumnMapping `json:"mapping" doc:"The column-to-attribute mappings that drive the import."` } // PreviewTask represents a task preview before import @@ -146,8 +146,8 @@ type PreviewTask struct { // PreviewResult contains preview data before import type PreviewResult struct { - Tasks []PreviewTask `json:"tasks"` - TotalRows int `json:"total_rows"` + Tasks []PreviewTask `json:"tasks" doc:"The first few tasks that would be imported with the given config."` + TotalRows int `json:"total_rows" doc:"The total number of data rows in the file."` } // stripBOM removes the UTF-8 BOM from the beginning of a reader @@ -557,6 +557,22 @@ func (m *Migrator) Migrate(_ *user.User, _ io.ReaderAt, _ int64) error { return &migration.ErrCSVConfigRequired{} } +// RunMigration records the migration's start, imports the CSV with the given +// config and records its finish. Shared by the v1 and v2 HTTP layers so the +// status bookkeeping around MigrateWithConfig lives in one place. +func RunMigration(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error { + status, err := migration.StartMigration(&Migrator{}, u) + if err != nil { + return err + } + + if err := MigrateWithConfig(u, file, size, config); err != nil { + return err + } + + return migration.FinishMigration(status) +} + // MigrateWithConfig imports CSV data into Vikunja with the provided configuration func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error { if size == 0 { diff --git a/pkg/modules/migration/csv/handler.go b/pkg/modules/migration/csv/handler.go index 389c13573..1a99c342d 100644 --- a/pkg/modules/migration/csv/handler.go +++ b/pkg/modules/migration/csv/handler.go @@ -186,19 +186,7 @@ func (c *MigratorWeb) Migrate(ctx *echo.Context) error { } defer src.Close() - m := &Migrator{} - status, err := migration.StartMigration(m, u) - if err != nil { - return err - } - - err = MigrateWithConfig(u, src, file.Size, &config) - if err != nil { - return err - } - - err = migration.FinishMigration(status) - if err != nil { + if err := RunMigration(u, src, file.Size, &config); err != nil { return err } diff --git a/pkg/modules/migration/errors.go b/pkg/modules/migration/errors.go index 3129c5da2..eef789c39 100644 --- a/pkg/modules/migration/errors.go +++ b/pkg/modules/migration/errors.go @@ -18,10 +18,33 @@ package migration import ( "net/http" + "time" "code.vikunja.io/api/pkg/web" ) +// ErrMigrationAlreadyRunning is returned when a migration is started for a user +// who already has one in progress (started but not yet finished). +type ErrMigrationAlreadyRunning struct { + StartedAt time.Time +} + +func (err *ErrMigrationAlreadyRunning) Error() string { + return "Migration already running" +} + +// ErrCodeMigrationAlreadyRunning holds the unique world-error code of this error +const ErrCodeMigrationAlreadyRunning = 14005 + +// HTTPError holds the http error description +func (err *ErrMigrationAlreadyRunning) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeMigrationAlreadyRunning, + Message: "Migration already running", + } +} + // ErrNotAZipFile represents a "ErrNotAZipFile" kind of error. type ErrNotAZipFile struct{} diff --git a/pkg/modules/migration/handler/handler.go b/pkg/modules/migration/handler/handler.go index 840dbacd6..5ef52d747 100644 --- a/pkg/modules/migration/handler/handler.go +++ b/pkg/modules/migration/handler/handler.go @@ -39,7 +39,7 @@ type MigrationWeb struct { // AuthURL is returned to the user when requesting the auth url type AuthURL struct { - URL string `json:"url"` + URL string `json:"url" readOnly:"true" doc:"The OAuth authorization url the client should redirect the user to. After authorizing, the obtained code is passed back to the migrate endpoint."` } // RegisterMigrator registers all routes for migration @@ -57,6 +57,28 @@ func (mw *MigrationWeb) AuthURL(c *echo.Context) error { return c.JSON(http.StatusOK, &AuthURL{URL: ms.AuthURL()}) } +// StartMigration kicks off a migration for the given user: it refuses with +// migration.ErrMigrationAlreadyRunning if one is already in progress, then +// dispatches the MigrationRequestedEvent that runs the migration asynchronously. +// The migrator must already carry its request payload (e.g. the OAuth code). +// Shared by the v1 and v2 HTTP layers so the orchestration lives in one place. +func StartMigration(ms migration.Migrator, u *user2.User) error { + stats, err := migration.GetMigrationStatus(ms, u) + if err != nil { + return err + } + + if !stats.StartedAt.IsZero() && stats.FinishedAt.IsZero() { + return &migration.ErrMigrationAlreadyRunning{StartedAt: stats.StartedAt} + } + + return events.Dispatch(&MigrationRequestedEvent{ + Migrator: ms, + MigratorKind: ms.Name(), + User: u, + }) +} + // Migrate calls the migration method func (mw *MigrationWeb) Migrate(c *echo.Context) error { ms := mw.MigrationStruct() @@ -85,12 +107,7 @@ func (mw *MigrationWeb) Migrate(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No or invalid model provided: "+err.Error()).Wrap(err) } - err = events.Dispatch(&MigrationRequestedEvent{ - Migrator: ms, - MigratorKind: ms.Name(), - User: user, - }) - if err != nil { + if err := StartMigration(ms, user); err != nil { return err } diff --git a/pkg/modules/migration/handler/handler_file.go b/pkg/modules/migration/handler/handler_file.go index 8fae1d775..76b7f4d13 100644 --- a/pkg/modules/migration/handler/handler_file.go +++ b/pkg/modules/migration/handler/handler_file.go @@ -17,6 +17,7 @@ package handler import ( + "io" "net/http" "code.vikunja.io/api/pkg/models" @@ -36,6 +37,22 @@ func (fw *FileMigratorWeb) RegisterRoutes(g *echo.Group) { g.PUT("/"+ms.Name()+"/migrate", fw.Migrate) } +// RunFileMigration records the migration's start, runs the file migrator and +// records its finish. Shared by the v1 and v2 HTTP layers so the orchestration +// lives in one place; the caller supplies the already-opened upload. +func RunFileMigration(ms migration.FileMigrator, u *user2.User, file io.ReaderAt, size int64) error { + m, err := migration.StartMigration(ms, u) + if err != nil { + return err + } + + if err := ms.Migrate(u, file, size); err != nil { + return err + } + + return migration.FinishMigration(m) +} + // Migrate calls the migration method func (fw *FileMigratorWeb) Migrate(c *echo.Context) error { ms := fw.MigrationStruct() @@ -56,19 +73,7 @@ func (fw *FileMigratorWeb) Migrate(c *echo.Context) error { } defer src.Close() - m, err := migration.StartMigration(ms, user) - if err != nil { - return err - } - - // Do the migration - err = ms.Migrate(user, src, file.Size) - if err != nil { - return err - } - - err = migration.FinishMigration(m) - if err != nil { + if err := RunFileMigration(ms, user, src, file.Size); err != nil { return err } diff --git a/pkg/modules/migration/migration_status.go b/pkg/modules/migration/migration_status.go index a967c950c..419ac880c 100644 --- a/pkg/modules/migration/migration_status.go +++ b/pkg/modules/migration/migration_status.go @@ -25,11 +25,11 @@ import ( // Status represents this migration status type Status struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this migration status."` UserID int64 `xorm:"bigint not null" json:"-"` - MigratorName string `xorm:"varchar(255)" json:"migrator_name"` - StartedAt time.Time `xorm:"not null" json:"started_at"` - FinishedAt time.Time `xorm:"null" json:"finished_at"` + MigratorName string `xorm:"varchar(255)" json:"migrator_name" readOnly:"true" doc:"The name of the migrator this status belongs to, e.g. \"todoist\"."` + StartedAt time.Time `xorm:"not null" json:"started_at" readOnly:"true" doc:"When the last migration started. Zero value if the user never migrated from this service."` + FinishedAt time.Time `xorm:"null" json:"finished_at" readOnly:"true" doc:"When the last migration finished. Zero value while a migration is still running or was never run."` } // TableName holds the table name for the migration status table diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 292927c80..7749e1d9c 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -20,6 +20,7 @@ import ( "bytes" "embed" templatehtml "html/template" + "net/url" "regexp" "strings" templatetext "text/template" @@ -187,16 +188,26 @@ const mailTemplateConversationalHTML = ` //go:embed logo.png var logo embed.FS -func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { +// newNotificationSanitizer builds the bluemonday policy for all HTML in notification +// emails. Only inline data-URI images (avatars) are allowed: RewriteSrc blanks any +// remote image src so a user-controlled task title, comment or description can't +// smuggle a tracking pixel into a recipient's inbox. +func newNotificationSanitizer() *bluemonday.Policy { p := bluemonday.UGCPolicy() - // Allow data URI images for inline avatars in mentions p.AllowDataURIImages() - // Allow style attribute on img and div elements for avatar and layout styling p.AllowAttrs("style").OnElements("img", "div") - // Allow specific CSS properties for avatar styling p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img") - // Allow padding styles on div elements for content spacing p.AllowStyles("padding-top", "margin-bottom").OnElements("div") + p.RewriteSrc(func(u *url.URL) { + if u.Scheme != "data" { + *u = url.URL{} + } + }) + return p +} + +func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { + p := newNotificationSanitizer() for _, line := range lines { if line.isHTML { @@ -225,11 +236,7 @@ func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err e // sanitizeLinesToHTML sanitizes lines without wrapping in

tags or adding margins. // Used for footer lines and other content that should not have paragraph styling. func sanitizeLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { - p := bluemonday.UGCPolicy() - p.AllowDataURIImages() - p.AllowAttrs("style").OnElements("img", "div") - p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img") - p.AllowStyles("padding-top", "margin-bottom").OnElements("div") + p := newNotificationSanitizer() for _, line := range lines { if line.isHTML { @@ -366,12 +373,8 @@ func RenderMail(m *Mail, lang string) (mailOpts *mail.Opts, err error) { data["CopyURLText"] = i18n.T(lang, "notifications.common.copy_url") if m.headerLine != nil { - p := bluemonday.UGCPolicy() - p.AllowDataURIImages() - p.AllowAttrs("style").OnElements("img", "div") - p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img") // #nosec G203 -- the html is sanitized - data["HeaderLineHTML"] = templatehtml.HTML(p.Sanitize(m.headerLine.Text)) + data["HeaderLineHTML"] = templatehtml.HTML(newNotificationSanitizer().Sanitize(m.headerLine.Text)) } data["IntroLinesHTML"], err = convertLinesToHTML(m.introLines) diff --git a/pkg/notifications/mail_test.go b/pkg/notifications/mail_test.go index fca5c6447..d8f5db2e4 100644 --- a/pkg/notifications/mail_test.go +++ b/pkg/notifications/mail_test.go @@ -711,3 +711,55 @@ func TestConversationalMail(t *testing.T) { assert.Contains(t, headerLine1, "(Project > Task) #1") }) } + +// Regression test for GHSA-2vr2-r3qw-rjvq: a user-controlled task title (or comment +// or description) must not be able to smuggle a remote image into a notification +// email, where it would act as a tracking pixel. Inline data-URI avatars and normal +// links must keep working. +func TestNotificationEmailStripsRemoteImages(t *testing.T) { + const remoteSrc = "https://attacker.example/track.png?u=victim" + + t.Run("remote image injected via task title in header is stripped", func(t *testing.T) { + payloadTitle := `normal title` + header := CreateConversationalHeader("", "attacker left a comment", "https://example.com/task/1", "Project", "#1", payloadTitle) + + mailOpts, err := RenderMail(NewMail(). + Conversational(). + Subject("Test"). + HeaderLine(header). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc) + assert.NotContains(t, mailOpts.HTMLMessage, "attacker.example") + // The benign text is still delivered, and the legitimate task link survives. + assert.Contains(t, mailOpts.HTMLMessage, "normal title") + assert.Contains(t, mailOpts.HTMLMessage, `href="https://example.com/task/1"`) + }) + + t.Run("remote image in body content is stripped", func(t *testing.T) { + mailOpts, err := RenderMail(NewMail(). + Conversational(). + Subject("Test"). + HTML(`

hi

`). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc) + assert.Contains(t, mailOpts.HTMLMessage, "hi") + }) + + t.Run("inline data-URI avatar is preserved", func(t *testing.T) { + const avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + header := CreateConversationalHeader(avatar, "alice left a comment", "https://example.com/task/1", "Project", "#1", "Task") + + mailOpts, err := RenderMail(NewMail(). + Conversational(). + Subject("Test"). + HeaderLine(header). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.Contains(t, mailOpts.HTMLMessage, "data:image/png;base64,") + }) +} diff --git a/pkg/routes/api/shared/admin_user.go b/pkg/routes/api/shared/admin_user.go new file mode 100644 index 000000000..4459f2698 --- /dev/null +++ b/pkg/routes/api/shared/admin_user.go @@ -0,0 +1,64 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/user" +) + +// AdminUser re-exposes fields hidden by the default user.User JSON view. +type AdminUser struct { + *user.User + IsAdmin bool `json:"is_admin" readOnly:"true" doc:"Whether the user is an instance admin."` + Status user.Status `json:"status" readOnly:"true" doc:"Account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked)."` + Issuer string `json:"issuer" readOnly:"true" doc:"Authentication issuer; empty or 'local' for local accounts."` + Subject string `json:"subject,omitempty" readOnly:"true" doc:"External subject identifier, for non-local accounts."` + AuthProvider string `json:"auth_provider,omitempty" readOnly:"true" doc:"Resolved auth provider name (e.g. 'LDAP' or an OIDC provider), empty for local accounts."` +} + +// NewAdminUser builds the admin-facing user view, resolving the auth-provider +// display name from the configured OIDC providers. +func NewAdminUser(u *user.User, providers []*openid.Provider) *AdminUser { + return &AdminUser{ + User: u, + IsAdmin: u.IsAdmin, + Status: u.Status, + Issuer: u.Issuer, + Subject: u.Subject, + AuthProvider: resolveAuthProvider(u, providers), + } +} + +func resolveAuthProvider(u *user.User, providers []*openid.Provider) string { + switch u.Issuer { + case "", user.IssuerLocal: + return "" + case user.IssuerLDAP: + return "LDAP" + } + for _, provider := range providers { + issuerURL, err := provider.Issuer() + if err != nil { + continue + } + if issuerURL == u.Issuer { + return provider.Name + } + } + return u.Issuer +} diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go new file mode 100644 index 000000000..925a533d8 --- /dev/null +++ b/pkg/routes/api/shared/auth.go @@ -0,0 +1,179 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package shared + +import ( + "context" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/metrics" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/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(ctx context.Context, in *UserRegister) (*user.User, error) { + s := db.NewSession() + defer s.Close() + // Discards events queued during a rolled-back transaction; a no-op once + // DispatchPending has run. + defer events.CleanupPending(s) + + newUser, err := models.RegisterUser(s, &user.User{ + Username: in.Username, + Password: in.Password, + Email: in.Email, + Language: in.Language, + }) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + events.DispatchPending(ctx, s) + + // Bust the cached user count so the new registration shows up in metrics + // immediately instead of after the regular cache expiry. + if config.MetricsEnabled.GetBool() { + if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { + log.Errorf("Could not invalidate user count metric: %s", err) + } + } + + return newUser, nil +} + +// ResetPassword resets a user's password from a previously issued reset token +// and invalidates all of that user's sessions, so a leaked password cannot be +// used after a reset. Shared by v1 and v2. +func ResetPassword(reset *user.PasswordReset) error { + s := db.NewSession() + defer s.Close() + + userID, err := user.ResetPassword(s, reset) + if err != nil { + _ = s.Rollback() + return err + } + + if err := models.DeleteAllUserSessions(s, userID); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// RequestPasswordResetToken issues a password-reset token for the account with +// the given email and sends it via email. Shared by v1 and v2. +func RequestPasswordResetToken(req *user.PasswordTokenRequest) error { + s := db.NewSession() + defer s.Close() + + if err := user.RequestUserPasswordResetTokenByEmail(s, req); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// ConfirmEmail confirms a newly registered user's email from the token sent to +// them. Shared by v1 and v2. +func ConfirmEmail(confirm *user.EmailConfirm) error { + s := db.NewSession() + defer s.Close() + + if err := user.ConfirmEmail(s, confirm); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// LinkShareToken is the response for the link-share auth endpoint. It embeds the +// authenticated share alongside the issued JWT and re-exposes the project id +// (which LinkSharing hides with json:"-"). The embedded share's write-only +// Password is blanked by AuthenticateLinkShare before this is returned. +type LinkShareToken struct { + auth.Token + *models.LinkSharing + ProjectID int64 `json:"project_id" readOnly:"true" doc:"The id of the project this share grants access to."` +} + +// AuthenticateLinkShare resolves a link share by its public hash, verifies the +// password for password-protected shares, and issues a JWT auth token for it. +// The returned token's embedded share has its password blanked. Shared by v1 +// and v2. +func AuthenticateLinkShare(hash, password string) (*LinkShareToken, error) { + s := db.NewSession() + defer s.Close() + + share, err := models.GetLinkShareByHash(s, hash) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if share.SharingType == models.SharingTypeWithPassword { + if err := models.VerifyLinkSharePassword(share, password); err != nil { + _ = s.Rollback() + return nil, err + } + } + + t, err := auth.NewLinkShareJWTAuthtoken(share) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + share.Password = "" + + return &LinkShareToken{ + Token: auth.Token{Token: t}, + LinkSharing: share, + ProjectID: share.ProjectID, + }, nil +} diff --git a/pkg/routes/api/shared/auth_provider.go b/pkg/routes/api/shared/auth_provider.go new file mode 100644 index 000000000..042a5567d --- /dev/null +++ b/pkg/routes/api/shared/auth_provider.go @@ -0,0 +1,54 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package shared holds helpers used by both the v1 and v2 route packages. It +// sits above the auth/user modules in the import graph, so it can combine them +// without creating a cycle. +package shared + +import ( + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/user" +) + +// GetAuthProviderName resolves the human-readable name of the source a user +// authenticated with: "local"/"ldap" for those issuers, otherwise the +// configured OpenID provider whose issuer URL matches the user's. Returns "" +// when no provider matches. +func GetAuthProviderName(u *user.User) (string, error) { + switch u.Issuer { + case user.IssuerLocal: + return "local", nil + case user.IssuerLDAP: + return "ldap", nil + } + + providers, err := openid.GetAllProviders() + if err != nil { + return "", err + } + for _, provider := range providers { + issuerURL, err := provider.Issuer() + if err != nil { + return "", err + } + if issuerURL == u.Issuer { + return provider.Name, nil + } + } + + return "", nil +} diff --git a/pkg/routes/api/shared/info.go b/pkg/routes/api/shared/info.go new file mode 100644 index 000000000..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/admin/overview.go b/pkg/routes/api/v1/admin/overview.go index 3911e31be..6c5b71858 100644 --- a/pkg/routes/api/v1/admin/overview.go +++ b/pkg/routes/api/v1/admin/overview.go @@ -20,77 +20,27 @@ import ( "net/http" "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/models" + "github.com/labstack/echo/v5" ) -type ShareCounts struct { - LinkShares int64 `json:"link_shares"` - TeamShares int64 `json:"team_shares"` - UserShares int64 `json:"user_shares"` -} - -type Overview struct { - Users int64 `json:"users"` - Projects int64 `json:"projects"` - Tasks int64 `json:"tasks"` - Teams int64 `json:"teams"` - Shares ShareCounts `json:"shares"` - License license.Info `json:"license"` -} - // GetOverview returns aggregate instance counts and metadata. // @Summary Admin overview // @Description Returns per-instance counts (users, projects, shares) plus version and license info. Instance-admin only, gated by the admin_panel feature. // @tags admin // @Produce json // @Security JWTKeyAuth -// @Success 200 {object} admin.Overview +// @Success 200 {object} models.Overview // @Failure 404 {object} web.HTTPError // @Router /admin/overview [get] func GetOverview(c *echo.Context) error { s := db.NewSession() defer s.Close() - users, err := s.Table("users").Count() + overview, err := models.BuildOverview(s) if err != nil { return err } - projects, err := s.Table("projects").Count() - if err != nil { - return err - } - tasks, err := s.Table("tasks").Count() - if err != nil { - return err - } - teams, err := s.Table("teams").Count() - if err != nil { - return err - } - linkShares, err := s.Table("link_shares").Count() - if err != nil { - return err - } - teamShares, err := s.Table("team_projects").Count() - if err != nil { - return err - } - userShares, err := s.Table("users_projects").Count() - if err != nil { - return err - } - - return c.JSON(http.StatusOK, Overview{ - Users: users, - Projects: projects, - Tasks: tasks, - Teams: teams, - Shares: ShareCounts{ - LinkShares: linkShares, - TeamShares: teamShares, - UserShares: userShares, - }, - License: license.CurrentInfo(), - }) + return c.JSON(http.StatusOK, overview) } diff --git a/pkg/routes/api/v1/admin/user_create.go b/pkg/routes/api/v1/admin/user_create.go index 5ba455579..bedddef58 100644 --- a/pkg/routes/api/v1/admin/user_create.go +++ b/pkg/routes/api/v1/admin/user_create.go @@ -20,28 +20,14 @@ import ( "errors" "net/http" - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth/openid" - "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -// CreateUserBody wraps user.APIUserPassword with admin-only fields. -type CreateUserBody struct { - // The full name of the new user. Optional. - Name string `json:"name"` - // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. - Language string `json:"language" valid:"language"` - user.APIUserPassword - // Mark the new user as an instance admin. - IsAdmin bool `json:"is_admin"` - // Activate the new user immediately without email confirmation. - SkipEmailConfirm bool `json:"skip_email_confirm"` -} - // CreateUser provisions a new account on behalf of an instance admin. // @Summary Create a user (admin) // @Description Create a new local user account. Respects the admin-only fields `is_admin` and `skip_email_confirm`. The public registration toggle is bypassed. @@ -49,12 +35,12 @@ type CreateUserBody struct { // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param body body admin.CreateUserBody true "The user to create" -// @Success 200 {object} admin.User +// @Param body body models.CreateUserBody true "The user to create" +// @Success 200 {object} shared.AdminUser // @Failure 400 {object} web.HTTPError // @Router /admin/users [post] func CreateUser(c *echo.Context) error { - body := &CreateUserBody{} + body := &models.CreateUserBody{} if err := c.Bind(body); err != nil { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } @@ -69,52 +55,15 @@ func CreateUser(c *echo.Context) error { s := db.NewSession() defer s.Close() - newUser, err := models.RegisterUser(s, &user.User{ - Username: body.Username, - Password: body.Password, - Email: body.Email, - Name: body.Name, - Language: body.Language, - }) + newUser, err := models.CreateUserAsAdmin(s, body) if err != nil { _ = s.Rollback() return err } - if body.IsAdmin { - if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil { - _ = s.Rollback() - return err - } - newUser.IsAdmin = true - } - - // Force Active when the admin asked to skip, or when no mailer exists to send the confirmation. - if body.SkipEmailConfirm || !config.MailerEnabled.GetBool() { - if err := user.SetUserStatus(s, newUser, user.StatusActive); err != nil { - _ = s.Rollback() - return err - } - newUser.Status = user.StatusActive - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - // Reload the user so the returned status reflects what was actually persisted - // (e.g. StatusEmailConfirmationRequired on mail-enabled instances). - rs := db.NewSession() - defer rs.Close() - newUser, err = user.GetUserByID(rs, newUser.ID) - if err != nil { - return err - } - providers, err := openid.GetAllProviders() if err != nil { return err } - return c.JSON(http.StatusOK, newAdminUser(newUser, providers)) + return c.JSON(http.StatusOK, shared.NewAdminUser(newUser, providers)) } diff --git a/pkg/routes/api/v1/admin/users.go b/pkg/routes/api/v1/admin/users.go index f9392b772..5117b9e46 100644 --- a/pkg/routes/api/v1/admin/users.go +++ b/pkg/routes/api/v1/admin/users.go @@ -18,52 +18,13 @@ package admin import ( "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "xorm.io/xorm" ) -// User re-exposes fields hidden by the default user.User JSON view. -type User struct { - *user.User - IsAdmin bool `json:"is_admin"` - Status user.Status `json:"status"` - Issuer string `json:"issuer"` - Subject string `json:"subject,omitempty"` - AuthProvider string `json:"auth_provider,omitempty"` -} - -func newAdminUser(u *user.User, providers []*openid.Provider) *User { - return &User{ - User: u, - IsAdmin: u.IsAdmin, - Status: u.Status, - Issuer: u.Issuer, - Subject: u.Subject, - AuthProvider: resolveAuthProvider(u, providers), - } -} - -func resolveAuthProvider(u *user.User, providers []*openid.Provider) string { - switch u.Issuer { - case "", user.IssuerLocal: - return "" - case user.IssuerLDAP: - return "LDAP" - } - for _, provider := range providers { - issuerURL, err := provider.Issuer() - if err != nil { - continue - } - if issuerURL == u.Issuer { - return provider.Name - } - } - return u.Issuer -} - // UserList backs the admin list-users route via handler.ReadAllWeb; only ReadAll is used. type UserList struct { web.CRUDable `xorm:"-" json:"-"` @@ -79,7 +40,7 @@ type UserList struct { // @Param s query string false "Search string matched against username and email." // @Param page query int false "Page number, defaults to 1." // @Param per_page query int false "Items per page, defaults to the service setting." -// @Success 200 {array} admin.User +// @Success 200 {array} shared.AdminUser // @Failure 404 {object} web.HTTPError // @Router /admin/users [get] func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPage int) (interface{}, int, int64, error) { @@ -106,9 +67,9 @@ func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPa return nil, 0, 0, err } - out := make([]*User, 0, len(users)) + out := make([]*shared.AdminUser, 0, len(users)) for _, u := range users { - out = append(out, newAdminUser(u, providers)) + out = append(out, shared.NewAdminUser(u, providers)) } return out, len(out), totalCount, nil } diff --git a/pkg/routes/api/v1/admin/users_admin.go b/pkg/routes/api/v1/admin/users_admin.go index 5f31b269f..195bb2092 100644 --- a/pkg/routes/api/v1/admin/users_admin.go +++ b/pkg/routes/api/v1/admin/users_admin.go @@ -23,7 +23,9 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" + "github.com/labstack/echo/v5" ) @@ -41,7 +43,7 @@ type IsAdminPatch struct { // @Security JWTKeyAuth // @Param id path int true "User ID" // @Param body body admin.IsAdminPatch true "New admin value" -// @Success 200 {object} admin.User +// @Success 200 {object} shared.AdminUser // @Failure 400 {object} web.HTTPError // @Failure 404 {object} web.HTTPError // @Router /admin/users/{id}/admin [patch] @@ -63,24 +65,8 @@ func PatchAdmin(c *echo.Context) error { s := db.NewSession() defer s.Close() - target := &user.User{ID: id} - has, err := s.Get(target) + target, err := models.SetUserAdminFlag(s, id, *body.IsAdmin) if err != nil { - return err - } - if !has { - return user.ErrUserDoesNotExist{UserID: id} - } - - if !*body.IsAdmin { - if err := user.GuardLastAdmin(s, target); err != nil { - _ = s.Rollback() - return err - } - } - - target.IsAdmin = *body.IsAdmin - if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil { _ = s.Rollback() return err } @@ -92,5 +78,5 @@ func PatchAdmin(c *echo.Context) error { if err != nil { return err } - return c.JSON(http.StatusOK, newAdminUser(target, providers)) + return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers)) } diff --git a/pkg/routes/api/v1/admin/users_mgmt.go b/pkg/routes/api/v1/admin/users_mgmt.go index 2e95d88b5..1e72fa173 100644 --- a/pkg/routes/api/v1/admin/users_mgmt.go +++ b/pkg/routes/api/v1/admin/users_mgmt.go @@ -23,7 +23,9 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" + "github.com/labstack/echo/v5" ) @@ -41,7 +43,7 @@ type StatusPatch struct { // @Security JWTKeyAuth // @Param id path int true "User ID" // @Param body body admin.StatusPatch true "Status" -// @Success 200 {object} admin.User +// @Success 200 {object} shared.AdminUser // @Failure 400 {object} web.HTTPError // @Failure 404 {object} web.HTTPError // @Router /admin/users/{id}/status [patch] @@ -65,24 +67,8 @@ func PatchStatus(c *echo.Context) error { s := db.NewSession() defer s.Close() - target := &user.User{ID: id} - has, err := s.Get(target) + target, err := models.SetUserStatusAsAdmin(s, id, newStatus) if err != nil { - return err - } - if !has { - return user.ErrUserDoesNotExist{UserID: id} - } - - // Any non-Active status blocks login, so moving an admin out of Active is equivalent to demotion. - if target.IsAdmin && newStatus != user.StatusActive { - if err := user.GuardLastAdmin(s, target); err != nil { - _ = s.Rollback() - return err - } - } - - if err := user.SetUserStatus(s, target, newStatus); err != nil { _ = s.Rollback() return err } @@ -90,13 +76,11 @@ func PatchStatus(c *echo.Context) error { return err } - // Refresh locally since GetUserByID refuses disabled accounts. - target.Status = newStatus providers, err := openid.GetAllProviders() if err != nil { return err } - return c.JSON(http.StatusOK, newAdminUser(target, providers)) + return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers)) } // DeleteUser removes a user either immediately or through the self-deletion flow. @@ -128,32 +112,10 @@ func DeleteUser(c *echo.Context) error { s := db.NewSession() defer s.Close() - target := &user.User{ID: id} - has, err := s.Get(target) - if err != nil { - return err - } - if !has { - return user.ErrUserDoesNotExist{UserID: id} - } - - if err := user.GuardLastAdmin(s, target); err != nil { + if err := models.DeleteUserAsAdmin(s, id, mode); err != nil { _ = s.Rollback() return err } - - if mode == "now" { - if err := models.DeleteUser(s, target); err != nil { - _ = s.Rollback() - return err - } - } else { - if err := user.RequestDeletion(s, target); err != nil { - _ = s.Rollback() - return err - } - } - if err := s.Commit(); err != nil { return err } diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 0e0a64ff2..87891ff15 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -19,151 +19,18 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/license" - "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/modules/auth/openid" - csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv" - microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" - "code.vikunja.io/api/pkg/modules/migration/ticktick" - "code.vikunja.io/api/pkg/modules/migration/todoist" - "code.vikunja.io/api/pkg/modules/migration/trello" - vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" - "code.vikunja.io/api/pkg/modules/migration/wekan" - "code.vikunja.io/api/pkg/version" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -type vikunjaInfos struct { - Version string `json:"version"` - FrontendURL string `json:"frontend_url"` - Motd string `json:"motd"` - LinkSharingEnabled bool `json:"link_sharing_enabled"` - MaxFileSize string `json:"max_file_size"` - MaxItemsPerPage int `json:"max_items_per_page"` - AvailableMigrators []string `json:"available_migrators"` - TaskAttachmentsEnabled bool `json:"task_attachments_enabled"` - EnabledBackgroundProviders []string `json:"enabled_background_providers"` - TotpEnabled bool `json:"totp_enabled"` - Legal legalInfo `json:"legal"` - CaldavEnabled bool `json:"caldav_enabled"` - AuthInfo authInfo `json:"auth"` - EmailRemindersEnabled bool `json:"email_reminders_enabled"` - UserDeletionEnabled bool `json:"user_deletion_enabled"` - TaskCommentsEnabled bool `json:"task_comments_enabled"` - DemoModeEnabled bool `json:"demo_mode_enabled"` - WebhooksEnabled bool `json:"webhooks_enabled"` - PublicTeamsEnabled bool `json:"public_teams_enabled"` - AllowIconChanges bool `json:"allow_icon_changes"` - EnabledProFeatures []license.Feature `json:"enabled_pro_features"` -} - -type authInfo struct { - Local localAuthInfo `json:"local"` - Ldap ldapAuthInfo `json:"ldap"` - OpenIDConnect openIDAuthInfo `json:"openid_connect"` -} - -type localAuthInfo struct { - Enabled bool `json:"enabled"` - RegistrationEnabled bool `json:"registration_enabled"` -} - -type ldapAuthInfo struct { - Enabled bool `json:"enabled"` -} - -type openIDAuthInfo struct { - Enabled bool `json:"enabled"` - Providers []*openid.Provider `json:"providers"` -} - -type legalInfo struct { - ImprintURL string `json:"imprint_url"` - PrivacyPolicyURL string `json:"privacy_policy_url"` -} - // Info is the handler to get infos about this vikunja instance // @Summary Info // @Description Returns the version, frontendurl, motd and various settings of Vikunja // @tags service // @Produce json -// @Success 200 {object} v1.vikunjaInfos +// @Success 200 {object} shared.VikunjaInfos // @Router /info [get] func Info(c *echo.Context) error { - info := vikunjaInfos{ - Version: version.Version, - FrontendURL: config.ServicePublicURL.GetString(), - Motd: config.ServiceMotd.GetString(), - LinkSharingEnabled: config.ServiceEnableLinkSharing.GetBool(), - MaxFileSize: config.FilesMaxSize.GetString(), - MaxItemsPerPage: config.ServiceMaxItemsPerPage.GetInt(), - TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(), - TotpEnabled: config.ServiceEnableTotp.GetBool(), - CaldavEnabled: config.ServiceEnableCaldav.GetBool(), - EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(), - UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(), - TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(), - DemoModeEnabled: config.ServiceDemoMode.GetBool(), - WebhooksEnabled: config.WebhooksEnabled.GetBool(), - PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), - AllowIconChanges: config.ServiceAllowIconChanges.GetBool(), - EnabledProFeatures: license.EnabledProFeatures(), - AvailableMigrators: []string{ - (&vikunja_file.FileMigrator{}).Name(), - (&ticktick.Migrator{}).Name(), - (&wekan.Migrator{}).Name(), - (&csvmigrator.Migrator{}).Name(), - }, - Legal: legalInfo{ - ImprintURL: config.LegalImprintURL.GetString(), - PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), - }, - AuthInfo: authInfo{ - Local: localAuthInfo{ - Enabled: config.AuthLocalEnabled.GetBool(), - RegistrationEnabled: config.AuthLocalEnabled.GetBool() && config.ServiceEnableRegistration.GetBool(), - }, - Ldap: ldapAuthInfo{ - Enabled: config.AuthLdapEnabled.GetBool(), - }, - OpenIDConnect: openIDAuthInfo{ - Enabled: config.AuthOpenIDEnabled.GetBool(), - }, - }, - } - - providers, err := openid.GetAllProviders() - if err != nil { - log.Errorf("Error while getting openid providers for /info: %s", err) - // No return here to not break /info - } - - info.AuthInfo.OpenIDConnect.Providers = providers - - // Migrators - if config.MigrationTodoistEnable.GetBool() { - m := &todoist.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - if config.MigrationTrelloEnable.GetBool() { - m := &trello.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - if config.MigrationMicrosoftTodoEnable.GetBool() { - m := µsofttodo.Migration{} - info.AvailableMigrators = append(info.AvailableMigrators, m.Name()) - } - - if config.BackgroundsEnabled.GetBool() { - if config.BackgroundsUploadEnabled.GetBool() { - info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "upload") - } - if config.BackgroundsUnsplashEnabled.GetBool() { - info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash") - } - } - - return c.JSON(http.StatusOK, info) + return c.JSON(http.StatusOK, shared.BuildInfo()) } diff --git a/pkg/routes/api/v1/link_sharing_auth.go b/pkg/routes/api/v1/link_sharing_auth.go index 9e20a94f8..f4ca79ed0 100644 --- a/pkg/routes/api/v1/link_sharing_auth.go +++ b/pkg/routes/api/v1/link_sharing_auth.go @@ -19,20 +19,11 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/routes/api/shared" - "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/auth" "github.com/labstack/echo/v5" ) -// LinkShareToken represents a link share auth token with extra infos about the actual link share -type LinkShareToken struct { - auth.Token - *models.LinkSharing - ProjectID int64 `json:"project_id"` -} - // LinkShareAuth represents everything required to authenticate a link share type LinkShareAuth struct { Hash string `param:"share" json:"-"` @@ -53,36 +44,14 @@ type LinkShareAuth struct { // @Router /shares/{share}/auth [post] func AuthenticateLinkShare(c *echo.Context) error { sh := &LinkShareAuth{} - err := c.Bind(sh) + if err := c.Bind(sh); err != nil { + return err + } + + token, err := shared.AuthenticateLinkShare(sh.Hash, sh.Password) if err != nil { return err } - s := db.NewSession() - defer s.Close() - - share, err := models.GetLinkShareByHash(s, sh.Hash) - if err != nil { - return err - } - - if share.SharingType == models.SharingTypeWithPassword { - err := models.VerifyLinkSharePassword(share, sh.Password) - if err != nil { - return err - } - } - - t, err := auth.NewLinkShareJWTAuthtoken(share) - if err != nil { - return err - } - - share.Password = "" - - return c.JSON(http.StatusOK, LinkShareToken{ - Token: auth.Token{Token: t}, - LinkSharing: share, - ProjectID: share.ProjectID, - }) + return c.JSON(http.StatusOK, token) } diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index eb92945d1..ae92e1d72 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" @@ -51,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() { @@ -72,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 @@ -125,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) } @@ -231,10 +238,18 @@ func Logout(c *echo.Context) (err error) { auth.ClearRefreshTokenCookie(c) var sid string + var userID int64 if raw := c.Get("user"); raw != nil { if jwtinf, ok := raw.(*jwt.Token); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { sid, _ = claims["sid"].(string) + // Only user tokens carry a sid, but check the type explicitly + // so a link share id can never be logged as a user id. + if typ, ok := claims["type"].(float64); ok && int(typ) == auth.AuthTypeUser { + if id, ok := claims["id"].(float64); ok { + userID = int64(id) + } + } } } } @@ -257,5 +272,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/v1/user_confirm_email.go b/pkg/routes/api/v1/user_confirm_email.go index e01865103..254d4142a 100644 --- a/pkg/routes/api/v1/user_confirm_email.go +++ b/pkg/routes/api/v1/user_confirm_email.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -44,17 +43,7 @@ func UserConfirmEmail(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No token provided.").Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.ConfirmEmail(s, &emailConfirm) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ConfirmEmail(&emailConfirm); err != nil { return err } diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 3c07c9ebc..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/v1/user_list.go b/pkg/routes/api/v1/user_list.go index db4a777a0..6bc6e392f 100644 --- a/pkg/routes/api/v1/user_list.go +++ b/pkg/routes/api/v1/user_list.go @@ -52,17 +52,12 @@ func UserList(c *echo.Context) error { return err } - users, err := user.ListUsers(s, search, currentUser, nil) + users, err := user.SearchUsers(s, search, currentUser) if err != nil { _ = s.Rollback() return err } - // Obfuscate the mailadresses - for in := range users { - users[in].Email = "" - } - return c.JSON(http.StatusOK, users) } @@ -98,15 +93,6 @@ func ListUsersForProject(c *echo.Context) error { s := db.NewSession() defer s.Close() - canRead, _, err := project.CanRead(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - if !canRead { - return echo.ErrForbidden - } - currentUser, err := user.GetCurrentUser(c) if err != nil { _ = s.Rollback() @@ -114,11 +100,14 @@ func ListUsersForProject(c *echo.Context) error { } search := c.QueryParam("s") - users, err := models.ListUsersFromProject(s, &project, currentUser, search) + users, canRead, err := models.SearchUsersForProject(s, &project, auth, currentUser, search) if err != nil { _ = s.Rollback() return err } + if !canRead { + return echo.ErrForbidden + } if err := s.Commit(); err != nil { _ = s.Rollback() diff --git a/pkg/routes/api/v1/user_password_reset.go b/pkg/routes/api/v1/user_password_reset.go index b91a28a7a..6c8090ba0 100644 --- a/pkg/routes/api/v1/user_password_reset.go +++ b/pkg/routes/api/v1/user_password_reset.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -49,22 +48,7 @@ func UserResetPassword(c *echo.Context) error { return err } - s := db.NewSession() - defer s.Close() - - userID, err := user.ResetPassword(s, &pwReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, userID); err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ResetPassword(&pwReset); err != nil { return err } @@ -93,17 +77,7 @@ func UserRequestResetPasswordToken(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()).Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.RequestUserPasswordResetTokenByEmail(s, &pwTokenReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.RequestPasswordResetToken(&pwTokenReset); err != nil { return err } diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 9db52c88a..e9a90dc2f 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -21,20 +21,15 @@ import ( "net/http" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -type UserRegister struct { - // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja. - Language string `json:"language" valid:"language"` - user.APIUserPassword -} +// UserRegister is an alias for the shared registration input, kept so the v1 +// swagger annotation and any existing imports still resolve. +type UserRegister = shared.UserRegister // RegisterUser is the register handler // @Summary Register @@ -68,32 +63,10 @@ func RegisterUser(c *echo.Context) error { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } - s := db.NewSession() - defer s.Close() - - newUser, err := models.RegisterUser(s, &user.User{ - Username: userIn.Username, - Password: userIn.Password, - Email: userIn.Email, - Language: userIn.Language, - }) + newUser, err := shared.RegisterUser(c.Request().Context(), userIn) if err != nil { - _ = s.Rollback() return err } - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - // Bust the cached user count so the new registration shows up in metrics - // immediately instead of after the regular cache expiry. - if config.MetricsEnabled.GetBool() { - if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { - log.Errorf("Could not invalidate user count metric: %s", err) - } - } - return c.JSON(http.StatusOK, newUser) } diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index 2efa9c0f0..049330411 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -26,7 +26,6 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/avatar" user2 "code.vikunja.io/api/pkg/user" ) @@ -36,35 +35,6 @@ type UserAvatarProvider struct { AvatarProvider string `json:"avatar_provider"` } -// UserSettings holds all user settings -type UserSettings struct { - // The new name of the current user. - Name string `json:"name"` - // If enabled, sends email reminders of tasks to the user. - EmailRemindersEnabled bool `json:"email_reminders_enabled"` - // If true, this user can be found by their name or parts of it when searching for it. - DiscoverableByName bool `json:"discoverable_by_name"` - // If true, the user can be found when searching for their exact email. - DiscoverableByEmail bool `json:"discoverable_by_email"` - // If enabled, the user will get an email for their overdue tasks each morning. - OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"` - // The time when the daily summary of overdue tasks will be sent via email. - OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"` - // If a task is created without a specified project this value should be used. Applies - // to tasks made directly in API and from clients. - DefaultProjectID int64 `json:"default_project_id"` - // The day when the week starts for this user. 0 = sunday, 1 = monday, etc. - WeekStart int `json:"week_start" valid:"range(0|6)"` - // The user's language - Language string `json:"language"` - // The user's time zone. Used to send task reminders in the time zone of the user. - Timezone string `json:"timezone"` - // Additional settings only used by the frontend - FrontendSettings interface{} `json:"frontend_settings"` - // Additional settings links as provided by openid - ExtraSettingsLinks map[string]any `json:"extra_settings_links"` -} - // GetUserAvatarProvider returns the currently set user avatar // @Summary Return user avatar setting // @Description Returns the current user's avatar setting. @@ -135,29 +105,16 @@ func ChangeUserAvatarProvider(c *echo.Context) error { return err } - oldProvider := user.AvatarProvider - - user.AvatarProvider = uap.AvatarProvider - - _, err = user2.UpdateUser(s, user, false) - if err != nil { + if err := models.UpdateUserAvatarProvider(s, user, uap.AvatarProvider); err != nil { _ = s.Rollback() return err } - if user.AvatarProvider == "initials" { - avatar.FlushAllCaches(user) - } - if err := s.Commit(); err != nil { _ = s.Rollback() return err } - if oldProvider != user.AvatarProvider { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."}) } @@ -167,13 +124,13 @@ func ChangeUserAvatarProvider(c *echo.Context) error { // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param avatar body UserSettings true "The updated user settings" +// @Param avatar body models.UserGeneralSettings true "The updated user settings" // @Success 200 {object} models.Message // @Failure 400 {object} web.HTTPError "Something's invalid." // @Failure 500 {object} models.Message "Internal server error." // @Router /user/settings/general [post] func UpdateGeneralUserSettings(c *echo.Context) error { - us := &UserSettings{} + us := &models.UserGeneralSettings{} err := c.Bind(us) if err != nil { var he *echo.HTTPError @@ -202,22 +159,7 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - invalidateAvatar := user.AvatarProvider == "initials" && user.Name != us.Name - - user.Name = us.Name - user.EmailRemindersEnabled = us.EmailRemindersEnabled - user.DiscoverableByEmail = us.DiscoverableByEmail - user.DiscoverableByName = us.DiscoverableByName - user.OverdueTasksRemindersEnabled = us.OverdueTasksRemindersEnabled - user.DefaultProjectID = us.DefaultProjectID - user.WeekStart = us.WeekStart - user.Language = us.Language - user.Timezone = us.Timezone - user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime - user.FrontendSettings = us.FrontendSettings - - _, err = user2.UpdateUser(s, user, true) - if err != nil { + if err := models.UpdateUserGeneralSettings(s, user, us); err != nil { _ = s.Rollback() return err } @@ -227,10 +169,6 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - if invalidateAvatar { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "The settings were updated successfully."}) } diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go index d5a391267..655b0fb5c 100644 --- a/pkg/routes/api/v1/user_show.go +++ b/pkg/routes/api/v1/user_show.go @@ -20,7 +20,7 @@ import ( "net/http" "time" - "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" @@ -34,11 +34,11 @@ import ( type UserWithSettings struct { user.User - Settings *UserSettings `json:"settings"` - DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` - IsLocalUser bool `json:"is_local_user"` - AuthProvider string `json:"auth_provider"` - IsAdmin bool `json:"is_admin"` + Settings *models.UserGeneralSettings `json:"settings"` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` + IsLocalUser bool `json:"is_local_user"` + AuthProvider string `json:"auth_provider"` + IsAdmin bool `json:"is_admin"` } // UserShow gets all information about the current user @@ -67,57 +67,17 @@ func UserShow(c *echo.Context) error { } us := &UserWithSettings{ - User: *u, - Settings: &UserSettings{ - Name: u.Name, - EmailRemindersEnabled: u.EmailRemindersEnabled, - DiscoverableByName: u.DiscoverableByName, - DiscoverableByEmail: u.DiscoverableByEmail, - OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, - DefaultProjectID: u.DefaultProjectID, - WeekStart: u.WeekStart, - Language: u.Language, - Timezone: u.Timezone, - OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, - FrontendSettings: u.FrontendSettings, - ExtraSettingsLinks: u.ExtraSettingsLinks, - }, + User: *u, + Settings: models.NewUserGeneralSettings(u), DeletionScheduledAt: u.DeletionScheduledAt, IsLocalUser: u.Issuer == user.IssuerLocal, IsAdmin: u.IsAdmin, } - us.AuthProvider, err = getAuthProviderName(u) + us.AuthProvider, err = shared.GetAuthProviderName(u) if err != nil { return err } return c.JSON(http.StatusOK, us) } - -func getAuthProviderName(u *user.User) (name string, err error) { - if u.Issuer == user.IssuerLocal { - return "local", nil - } - - if u.Issuer == user.IssuerLDAP { - return "ldap", nil - } - - providers, err := openid.GetAllProviders() - if err != nil { - return "", err - } - - for _, provider := range providers { - issuerURL, err := provider.Issuer() - if err != nil { - return "", err - } - if issuerURL == u.Issuer { - return provider.Name, nil - } - } - - return -} diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go index ea1077075..7e03b250a 100644 --- a/pkg/routes/api/v1/user_update_email.go +++ b/pkg/routes/api/v1/user_update_email.go @@ -62,17 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) { s := db.NewSession() defer s.Close() - emailUpdate.User, err = user.CheckUserCredentials(s, &user.Login{ - Username: emailUpdate.User.Username, - Password: emailUpdate.Password, - }) - if err != nil { - _ = s.Rollback() - return err - } - - err = user.UpdateEmail(s, emailUpdate) - if err != nil { + if err := user.ChangeUserEmail(c.Request().Context(), s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v1/user_update_password.go b/pkg/routes/api/v1/user_update_password.go index 0172a21ec..87b372aff 100644 --- a/pkg/routes/api/v1/user_update_password.go +++ b/pkg/routes/api/v1/user_update_password.go @@ -63,26 +63,10 @@ func UserChangePassword(c *echo.Context) error { return err } - if newPW.OldPassword == "" { - return user.ErrEmptyOldPassword{} - } - s := db.NewSession() defer s.Close() - // Check the current password - if _, err = user.CheckUserCredentials(s, &user.Login{Username: doer.Username, Password: newPW.OldPassword}); err != nil { - _ = s.Rollback() - return err - } - - // Update the password - if err = user.UpdateUserPassword(s, doer, newPW.NewPassword); err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, doer.ID); err != nil { + if err := models.ChangeUserPassword(c.Request().Context(), s, doer, newPW.OldPassword, newPW.NewPassword); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go index 2203ab01d..9d424eb6e 100644 --- a/pkg/routes/api/v2/admin_projects.go +++ b/pkg/routes/api/v2/admin_projects.go @@ -21,6 +21,7 @@ import ( "fmt" "net/http" + "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/web/handler" @@ -31,6 +32,16 @@ type adminProjectListBody struct { Body Paginated[*models.Project] } +type adminProjectBody struct { + Body *models.Project +} + +// adminOwnerPatchBody reassigns a project's owner. owner_id is the only field; +// the regular project-update endpoint refuses owner changes. +type adminOwnerPatchBody struct { + OwnerID int64 `json:"owner_id" minimum:"1" doc:"The numeric ID of the user who should become the project's owner."` +} + // Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler. func RegisterAdminProjectRoutes(api huma.API) { tags := []string{"admin"} @@ -43,6 +54,15 @@ func RegisterAdminProjectRoutes(api huma.API) { Path: "/admin/projects", Tags: tags, }, adminProjectsList) + + Register(api, huma.Operation{ + OperationID: "admin-projects-patch-owner", + Summary: "Reassign a project's owner (admin)", + Description: "Reassigns a project to a new owner — the admin-only escape hatch the regular update endpoint does not allow. The new owner must be an active account that is not scheduled for deletion. Restricted to instance admins on a licensed instance.", + Method: http.MethodPatch, + Path: "/admin/projects/{id}/owner", + Tags: tags, + }, adminProjectsPatchOwner) } func init() { AddRouteRegistrar(RegisterAdminProjectRoutes) } @@ -62,3 +82,28 @@ func adminProjectsList(ctx context.Context, in *ListParams) (*adminProjectListBo } return &adminProjectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil } + +func adminProjectsPatchOwner(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the project."` + Body adminOwnerPatchBody +}) (*adminProjectBody, error) { + if in.ID < 1 { + return nil, translateDomainError(models.ErrProjectDoesNotExist{ID: in.ID}) + } + if in.Body.OwnerID < 1 { + return nil, translateDomainError(models.ErrInvalidData{Message: "invalid body"}) + } + + s := db.NewSession() + defer s.Close() + + p, err := models.ReassignProjectOwner(s, in.ID, in.Body.OwnerID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &adminProjectBody{Body: p}, nil +} diff --git a/pkg/routes/api/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go new file mode 100644 index 000000000..2724e433c --- /dev/null +++ b/pkg/routes/api/v2/admin_users.go @@ -0,0 +1,206 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type adminOverviewBody struct { + Body *models.Overview +} + +type adminUserBody struct { + Body *shared.AdminUser +} + +// adminIsAdminPatchBody uses a pointer so an omitted is_admin leaves the flag unchanged +// instead of silently demoting. +type adminIsAdminPatchBody struct { + IsAdmin *bool `json:"is_admin" doc:"New admin flag. Omitting it leaves the current value unchanged."` +} + +// adminStatusPatchBody uses a pointer so an omitted status leaves the account unchanged +// instead of silently reactivating. +type adminStatusPatchBody struct { + Status *user.Status `json:"status" doc:"New account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked). Omitting it leaves the current value unchanged."` +} + +// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler. +func RegisterAdminUserRoutes(api huma.API) { + tags := []string{"admin"} + + Register(api, huma.Operation{ + OperationID: "admin-overview", + Summary: "Admin overview", + Description: "Returns per-instance counts (users, projects, tasks, teams, shares) plus the current license snapshot. Restricted to instance admins on a licensed instance; unlicensed or non-admin callers get a 404, making the endpoint indistinguishable from one that is not registered.", + Method: http.MethodGet, + Path: "/admin/overview", + Tags: tags, + }, adminOverview) + + Register(api, huma.Operation{ + OperationID: "admin-users-create", + Summary: "Create a user (admin)", + Description: "Creates a local user account, bypassing the public-registration toggle. Honours the admin-only is_admin and skip_email_confirm fields. Restricted to instance admins on a licensed instance.", + Method: http.MethodPost, + Path: "/admin/users", + Tags: tags, + }, adminUsersCreate) + + Register(api, huma.Operation{ + OperationID: "admin-users-patch-admin", + Summary: "Promote or demote a user (admin)", + Description: "Sets a user's instance-admin flag. The body field is a pointer: omitting is_admin leaves the flag unchanged. Demoting the last remaining admin is refused with 400.", + Method: http.MethodPatch, + Path: "/admin/users/{id}/admin", + Tags: tags, + }, adminUsersPatchAdmin) + + Register(api, huma.Operation{ + OperationID: "admin-users-patch-status", + Summary: "Set a user's status (admin)", + Description: "Changes a user's account status without requiring them to log in. The body field is a pointer: omitting status leaves it unchanged. Moving the last remaining admin out of Active is refused with 400.", + Method: http.MethodPatch, + Path: "/admin/users/{id}/status", + Tags: tags, + }, adminUsersPatchStatus) + + Register(api, huma.Operation{ + OperationID: "admin-users-delete", + Summary: "Delete a user (admin)", + Description: "Deletes a user. With mode=now the user is removed immediately. With mode=scheduled (the default) the user is scheduled for deletion through the email-confirmation self-deletion flow. Deleting the last remaining admin is refused with 400.", + Method: http.MethodDelete, + Path: "/admin/users/{id}", + Tags: tags, + }, adminUsersDelete) +} + +func init() { AddRouteRegistrar(RegisterAdminUserRoutes) } + +func adminOverview(_ context.Context, _ *struct{}) (*adminOverviewBody, error) { + s := db.NewSession() + defer s.Close() + + overview, err := models.BuildOverview(s) + if err != nil { + return nil, translateDomainError(err) + } + return &adminOverviewBody{Body: overview}, nil +} + +func adminUsersCreate(_ context.Context, in *struct{ Body models.CreateUserBody }) (*adminUserBody, error) { + s := db.NewSession() + defer s.Close() + + newUser, err := models.CreateUserAsAdmin(s, &in.Body) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers. + if err != nil { + return nil, translateDomainError(err) + } + return &adminUserBody{Body: shared.NewAdminUser(newUser, providers)}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request +} + +func adminUsersPatchAdmin(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the user."` + Body adminIsAdminPatchBody +}) (*adminUserBody, error) { + if in.Body.IsAdmin == nil { + return nil, translateDomainError(models.ErrInvalidData{Message: "is_admin is required"}) + } + return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser. + return models.SetUserAdminFlag(s, in.ID, *in.Body.IsAdmin) + }) +} + +func adminUsersPatchStatus(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the user."` + Body adminStatusPatchBody +}) (*adminUserBody, error) { + if in.Body.Status == nil { + return nil, translateDomainError(models.ErrInvalidData{Message: "status is required"}) + } + newStatus := *in.Body.Status + if newStatus < user.StatusActive || newStatus > user.StatusAccountLocked { + return nil, translateDomainError(models.ErrInvalidData{Message: "invalid status"}) + } + return adminCommitUser(func(s *xorm.Session) (*user.User, error) { //nolint:contextcheck // see adminCommitUser. + return models.SetUserStatusAsAdmin(s, in.ID, newStatus) + }) +} + +func adminUsersDelete(_ context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric ID of the user."` + Mode string `query:"mode" doc:"'now' deletes immediately; 'scheduled' (the default) triggers the email-confirmation self-deletion flow."` +}) (*emptyBody, error) { + mode := in.Mode + if mode == "" { + mode = "scheduled" + } + if mode != "now" && mode != "scheduled" { + return nil, translateDomainError(models.ErrInvalidData{Message: "invalid mode, expected 'now' or 'scheduled'"}) + } + + s := db.NewSession() + defer s.Close() + if err := models.DeleteUserAsAdmin(s, in.ID, mode); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +// adminCommitUser runs a user-returning admin action in its own transaction and +// renders the admin user view. The action does the load/guard/mutate against the +// session (shared with v1 via the models layer); this owns the commit and response. +func adminCommitUser(action func(s *xorm.Session) (*user.User, error)) (*adminUserBody, error) { + s := db.NewSession() + defer s.Close() + + target, err := action(s) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers. + if err != nil { + return nil, translateDomainError(err) + } + return &adminUserBody{Body: shared.NewAdminUser(target, providers)}, nil +} diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go new file mode 100644 index 000000000..c41fb162d --- /dev/null +++ b/pkg/routes/api/v2/auth_public.go @@ -0,0 +1,183 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// publicSecurity is the empty security requirement that opts an operation out of +// the globally-applied JWT/API-token auth. The matching Echo path must also be +// listed in unauthenticatedAPIPaths so the token middleware lets it through. +var publicSecurity = []map[string][]string{} + +// registerUserBody is the response wrapper for the registration endpoint. +type registerUserBody struct { + Body *user.User +} + +// messageBody carries a human-readable confirmation for endpoints that report +// success without returning a resource (password reset, email confirm). +type messageBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."` + } +} + +// linkShareTokenBody wraps the issued link-share auth token and its share. +type linkShareTokenBody struct { + Body *shared.LinkShareToken +} + +func init() { AddRouteRegistrar(RegisterPublicAuthRoutes) } + +// RegisterPublicAuthRoutes wires the unauthenticated local-account flows +// (registration, password reset, email confirmation) and the link-share auth +// endpoint. The local-account flows mirror v1 by only registering when local +// auth is enabled; the link-share endpoint follows ServiceEnableLinkSharing. +func RegisterPublicAuthRoutes(api huma.API) { + if config.AuthLocalEnabled.GetBool() { + registerLocalAuthRoutes(api) + } + + if config.ServiceEnableLinkSharing.GetBool() { + Register(api, huma.Operation{ + OperationID: "auth-link-share", + Summary: "Get an auth token for a link share", + Description: "Exchanges a link share's public hash (and password, for password-protected shares) for a JWT auth token scoped to the shared project.", + Method: http.MethodPost, + Path: "/shares/{share}/auth", + DefaultStatus: http.StatusOK, + Tags: []string{"sharing"}, + Security: publicSecurity, + }, authLinkShare) + } +} + +func registerLocalAuthRoutes(api huma.API) { + authTags := []string{"auth"} + + // Registration is its own static-config gate on top of local auth: when it + // is disabled the route simply isn't registered (a request then 404s as an + // unknown route), rather than registering it and rejecting per request. + if config.ServiceEnableRegistration.GetBool() { + Register(api, huma.Operation{ + OperationID: "auth-register", + Summary: "Register", + Description: "Creates a new local user account.", + Method: http.MethodPost, + Path: "/register", + Tags: authTags, + Security: publicSecurity, + }, authRegister) + } + + Register(api, huma.Operation{ + OperationID: "auth-password-token", + Summary: "Request a password reset token", + Description: "Requests a token to reset the password for the account with the given email. The token is sent to that email; the response is the same whether or not an account exists.", + Method: http.MethodPost, + Path: "/user/password/token", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authRequestPasswordToken) + + Register(api, huma.Operation{ + OperationID: "auth-password-reset", + Summary: "Reset a password", + Description: "Sets a new password using a previously issued reset token. All of the user's existing sessions are invalidated.", + Method: http.MethodPost, + Path: "/user/password/reset", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authResetPassword) + + Register(api, huma.Operation{ + OperationID: "auth-confirm-email", + Summary: "Confirm an email address", + Description: "Confirms the email address of a newly registered user using the token sent to that email.", + Method: http.MethodPost, + Path: "/user/confirm", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authConfirmEmail) +} + +func authRegister(ctx context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { + newUser, err := shared.RegisterUser(ctx, &in.Body) + if err != nil { + return nil, translateDomainError(err) + } + return ®isterUserBody{Body: newUser}, nil +} + +func authRequestPasswordToken(_ context.Context, in *struct{ Body user.PasswordTokenRequest }) (*messageBody, error) { + if err := shared.RequestPasswordResetToken(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "Token was sent." + return out, nil +} + +func authResetPassword(_ context.Context, in *struct{ Body user.PasswordReset }) (*messageBody, error) { + if err := shared.ResetPassword(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "The password was updated successfully." + return out, nil +} + +func authConfirmEmail(_ context.Context, in *struct{ Body user.EmailConfirm }) (*messageBody, error) { + if err := shared.ConfirmEmail(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "The email was confirmed successfully." + return out, nil +} + +func authLinkShare(_ context.Context, in *struct { + Share string `path:"share" doc:"The public hash of the link share."` + // Pointer so the body is optional: shares without a password are + // authenticated with no body at all. + Body *struct { + Password string `json:"password" doc:"The password for password-protected link shares. Ignored for shares without a password."` + } +}) (*linkShareTokenBody, error) { + var password string + if in.Body != nil { + password = in.Body.Password + } + + token, err := shared.AuthenticateLinkShare(in.Share, password) + if err != nil { + return nil, translateDomainError(err) + } + return &linkShareTokenBody{Body: token}, nil +} diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go new file mode 100644 index 000000000..f01fcb4e3 --- /dev/null +++ b/pkg/routes/api/v2/backgrounds.go @@ -0,0 +1,265 @@ +// 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" + backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler" + "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.BackgroundsUploadEnabled.GetBool() { + Register(api, huma.Operation{ + OperationID: "projects-background-upload", + Summary: "Upload a project background", + Description: "Uploads an image via multipart/form-data under the \"background\" field and sets it as the project's background. Requires write access to the project. The image is resized server-side and stored as JPEG; it replaces any previous background (idempotent replace, hence PUT). Returns the updated project.", + Method: http.MethodPut, + Path: "/projects/{project}/backgrounds/upload", + // Return the updated project with 200, the natural code for an idempotent PUT. + DefaultStatus: http.StatusOK, + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, backgroundUpload) + } + + if config.BackgroundsUnsplashEnabled.GetBool() { + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-search", + Summary: "Search Unsplash backgrounds", + Description: "Searches Unsplash for background images. With an empty query it returns the featured wallpaper collection. Results are paginated by Unsplash; total counts are not available.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/search", + Tags: tags, + }, backgroundUnsplashSearch) + + Register(api, huma.Operation{ + OperationID: "projects-background-unsplash-set", + Summary: "Set an Unsplash image as project background", + Description: "Sets a previously searched Unsplash image as the project's background, identified by the image id from the search results. Requires write access to the project.", + Method: http.MethodPut, + Path: "/projects/{project}/backgrounds/unsplash", + Tags: tags, + }, backgroundUnsplashSet) + } +} + +func init() { AddRouteRegistrar(RegisterBackgroundRoutes) } + +func backgroundUnsplashSearch(ctx context.Context, in *struct { + Q string `query:"q" doc:"Search query; empty returns the featured wallpaper collection."` + Page int64 `query:"page" default:"1" minimum:"1" doc:"1-based page number."` +}) (*backgroundSearchBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + page := in.Page + if page < 1 { + page = 1 + } + + s := db.NewSession() + defer s.Close() + + p := &unsplash.Provider{} + result, err := p.Search(s, in.Q, page) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + // Unsplash paginates server-side and p.Search discards the total, so the + // envelope's total is just this page's length (v1 returned a bare array). + return &backgroundSearchBody{Body: NewPaginated(result, int64(len(result)), int(page), len(result))}, nil +} + +func backgroundUnsplashSet(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + Body background.Image +}) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + project, err = models.GetProjectSimpleByID(s, in.ProjectID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + p := &unsplash.Provider{} + if err := p.Set(s, &in.Body, project, a); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := project.ReadOne(s, a); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} + +type backgroundUploadInput struct { + ProjectID int64 `path:"project" doc:"The id of the project to set the background on."` + // Allow-list mirrors the formats background uploads can actually be decoded as + // (handler.ValidateAndSaveBackgroundUpload's allowedImageMimes); octet-stream covers + // programmatic clients. Huma's MimeTypeValidator rejects the part pre-handler, so the + // byte-level image check in the shared function is the real gate. + RawBody huma.MultipartFormFiles[struct { + Background huma.FormFile `form:"background" contentType:"image/jpeg,image/png,image/gif,image/bmp,image/tiff,image/webp,application/octet-stream" required:"true" doc:"The background image to upload. Must be a decodable raster image (JPEG, PNG, GIF, BMP, TIFF or WebP); it is resized server-side and re-encoded as JPEG."` + }] +} + +// backgroundUpload owns auth, the session and the permission check because there is +// no handler.Do* for multipart uploads (see the api-v2-routes skill's "Non-CRUDable +// / custom routes" section). It shares its body with v1 via +// handler.ValidateAndSaveBackgroundUpload. +func backgroundUpload(ctx context.Context, in *backgroundUploadInput) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + project, err = models.GetProjectSimpleByID(s, in.ProjectID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + file := in.RawBody.Data().Background + defer func() { _ = file.Close() }() + + if err := backgroundHandler.ValidateAndSaveBackgroundUpload(s, a, project, file, file.Filename, uint64(file.Size)); err != nil { + _ = s.Rollback() + if backgroundHandler.IsErrFileIsNoImage(err) || backgroundHandler.IsErrFileUnsupportedImageFormat(err) { + return nil, huma.Error400BadRequest(err.Error()) + } + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} + +func backgroundRemove(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` +}) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + + if err := project.DeleteBackgroundFileIfExists(s); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := models.ClearProjectBackground(s, project.ID); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} diff --git a/pkg/routes/api/v2/caldav_tokens.go b/pkg/routes/api/v2/caldav_tokens.go new file mode 100644 index 000000000..b8cfbc19c --- /dev/null +++ b/pkg/routes/api/v2/caldav_tokens.go @@ -0,0 +1,121 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// CalDAV tokens are scoped to the authenticated user, not a CRUDable resource: +// there is no per-token Can* method, so these handlers own their own user lookup +// (user.GetFromAuth refuses link shares) and session/commit lives in the user package. + +type caldavTokenListBody struct { + Body Paginated[*user.Token] +} + +type caldavTokenBody struct { + Body *user.Token +} + +// RegisterCalDAVTokenRoutes wires the current user's CalDAV token operations onto the Huma API. +func RegisterCalDAVTokenRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-create", + Summary: "Generate a CalDAV token", + Description: "Generates a CalDAV token for the authenticated user. The clear-text token is returned only in this response and can never be retrieved again. Link shares cannot have CalDAV tokens.", + Method: http.MethodPost, + Path: "/user/settings/token/caldav", + Tags: tags, + }, caldavTokensCreate) + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-list", + Summary: "List CalDAV tokens", + Description: "Returns the authenticated user's CalDAV tokens. Only the id and creation date are returned — never the token value, which is shown once on creation.", + Method: http.MethodGet, + Path: "/user/settings/token/caldav", + Tags: tags, + }, caldavTokensList) + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-delete", + Summary: "Delete a CalDAV token", + Description: "Deletes one of the authenticated user's CalDAV tokens by id. Tokens of other users are out of scope and cannot be deleted.", + Method: http.MethodDelete, + Path: "/user/settings/token/caldav/{id}", + Tags: tags, + }, caldavTokensDelete) +} + +func init() { AddRouteRegistrar(RegisterCalDAVTokenRoutes) } + +func caldavTokensCreate(ctx context.Context, _ *struct{}) (*caldavTokenBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + token, err := user.GenerateNewCaldavToken(u) + if err != nil { + return nil, translateDomainError(err) + } + return &caldavTokenBody{Body: token}, nil +} + +func caldavTokensList(ctx context.Context, in *ListParams) (*caldavTokenListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + tokens, err := user.GetCaldavTokens(u) + if err != nil { + return nil, translateDomainError(err) + } + return &caldavTokenListBody{Body: NewPaginated(tokens, int64(len(tokens)), in.Page, in.PerPage)}, nil +} + +func caldavTokensDelete(ctx context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric id of the CalDAV token to delete."` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + if err := user.DeleteCaldavTokenByID(u, in.ID); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/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/info.go b/pkg/routes/api/v2/info.go new file mode 100644 index 000000000..3b4256363 --- /dev/null +++ b/pkg/routes/api/v2/info.go @@ -0,0 +1,51 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/routes/api/shared" + + "github.com/danielgtaylor/huma/v2" +) + +type infoBody struct { + Body shared.VikunjaInfos +} + +// RegisterInfoRoutes wires the public instance-info endpoint onto the Huma API. +func RegisterInfoRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "info", + Summary: "Instance info", + Description: "Returns version, frontend URL, motd and the enabled features of this Vikunja instance. Public — no authentication required.", + Method: http.MethodGet, + Path: "/info", + Tags: []string{"service"}, + // Public: opt out of the globally-applied auth. The path is also listed + // in unauthenticatedAPIPaths so the token middleware lets it through. + Security: []map[string][]string{}, + }, info) +} + +func init() { AddRouteRegistrar(RegisterInfoRoutes) } + +func info(_ context.Context, _ *struct{}) (*infoBody, error) { + return &infoBody{Body: shared.BuildInfo()}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request +} diff --git a/pkg/routes/api/v2/migration_csv.go b/pkg/routes/api/v2/migration_csv.go new file mode 100644 index 000000000..9f1922671 --- /dev/null +++ b/pkg/routes/api/v2/migration_csv.go @@ -0,0 +1,200 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + "code.vikunja.io/api/pkg/modules/migration/csv" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// csvDetectInput is the detect upload: just the file. +type csvDetectInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to analyze."` + }] +} + +// csvImportInput is the preview/migrate upload: the file plus a JSON config +// blob carried as a multipart form value (mirrors v1's FormValue(\"config\")). +type csvImportInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to import."` + Config string `form:"config" required:"true" doc:"The import configuration as a JSON object (see the ImportConfig schema), passed as a multipart form value. Obtain a starting config from the detect endpoint."` + }] +} + +type csvDetectBody struct { + Body *csv.DetectionResult +} + +type csvPreviewBody struct { + Body *csv.PreviewResult +} + +// RegisterMigrationCSVRoutes wires the generic CSV importer onto the Huma API. +// Like the other file migrators it has no config flag in v1, so it is always +// registered. +func RegisterMigrationCSVRoutes(api huma.API) { + tags := []string{"migration"} + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + maxBody := (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024 + + Register(api, huma.Operation{ + OperationID: "migration-csv-status", + Summary: "Get the CSV migration status", + Description: "Returns the migration status of the authenticated user for the CSV importer, i.e. whether and when they last imported a CSV.", + Method: http.MethodGet, + Path: "/migration/csv/status", + Tags: tags, + }, csvStatus) + + Register(api, huma.Operation{ + OperationID: "migration-csv-detect", + Summary: "Detect a CSV file's structure", + Description: "Analyzes an uploaded CSV file and returns its detected columns, delimiter, quote character and date format, plus a suggested column-to-attribute mapping the client can edit before previewing or migrating. Read-only: nothing is imported.", + Method: http.MethodPost, + Path: "/migration/csv/detect", + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvDetect) + + Register(api, huma.Operation{ + OperationID: "migration-csv-preview", + Summary: "Preview a CSV import", + Description: "Returns the first few tasks that would be imported from the uploaded CSV file with the given config, without importing anything. Read-only.", + Method: http.MethodPost, + Path: "/migration/csv/preview", + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvPreview) + + Register(api, huma.Operation{ + OperationID: "migration-csv-migrate", + Summary: "Import a CSV file", + Description: "Imports the tasks from the uploaded CSV file into Vikunja using the given config. The import runs synchronously and returns once it has finished.", + Method: http.MethodPost, + Path: "/migration/csv/migrate", + // POST runs an import rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvMigrate) +} + +func init() { AddRouteRegistrar(RegisterMigrationCSVRoutes) } + +func csvStatus(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(&csv.Migrator{}, u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func csvDetect(ctx context.Context, in *csvDetectInput) (*csvDetectBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + result, err := csv.DetectCSVStructure(src, src.Size) + if err != nil { + return nil, translateDomainError(err) + } + return &csvDetectBody{Body: result}, nil +} + +func csvPreview(ctx context.Context, in *csvImportInput) (*csvPreviewBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + cfg, err := parseCSVImportConfig(in.RawBody.Data().Config) + if err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + result, err := csv.PreviewImport(src, src.Size, cfg) + if err != nil { + return nil, translateDomainError(err) + } + return &csvPreviewBody{Body: result}, nil +} + +func csvMigrate(ctx context.Context, in *csvImportInput) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + cfg, err := parseCSVImportConfig(in.RawBody.Data().Config) + if err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + if err := csv.RunMigration(u, src, src.Size, cfg); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Everything was migrated successfully." + return out, nil +} + +// parseCSVImportConfig unmarshals the JSON config form value, mirroring v1's +// json.Unmarshal of FormValue("config"). required:"true" guarantees presence, +// so only a malformed body needs guarding here. +func parseCSVImportConfig(raw string) (*csv.ImportConfig, error) { + var cfg csv.ImportConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return nil, huma.Error400BadRequest("Invalid configuration: " + err.Error()) + } + return &cfg, nil +} diff --git a/pkg/routes/api/v2/migration_file.go b/pkg/routes/api/v2/migration_file.go new file mode 100644 index 000000000..d02db596e --- /dev/null +++ b/pkg/routes/api/v2/migration_file.go @@ -0,0 +1,126 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/modules/migration/ticktick" + vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" + "code.vikunja.io/api/pkg/modules/migration/wekan" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// fileMigrateInput is the multipart upload body shared by every file migrator's +// migrate endpoint. +type fileMigrateInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The export file to import. Its expected format depends on the migrator (e.g. a Vikunja export zip, a TickTick CSV, a WeKan JSON export)."` + }] +} + +// RegisterMigrationFileRoutes wires the file-based migrators (Vikunja export, +// TickTick, WeKan) onto the Huma API. Unlike the OAuth migrators these have no +// config flag in v1, so they are always registered. +func RegisterMigrationFileRoutes(api huma.API) { + registerFileMigrator(api, func() migration.FileMigrator { return &vikunja_file.FileMigrator{} }) + registerFileMigrator(api, func() migration.FileMigrator { return &ticktick.Migrator{} }) + registerFileMigrator(api, func() migration.FileMigrator { return &wekan.Migrator{} }) +} + +func init() { AddRouteRegistrar(RegisterMigrationFileRoutes) } + +// registerFileMigrator registers status + migrate for a single file migrator. +// factory produces a fresh migrator instance per request, matching v1's +// MigrationStruct func so concurrent requests never share mutable state. +func registerFileMigrator(api huma.API, factory func() migration.FileMigrator) { + name := factory().Name() + tags := []string{"migration"} + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-status", + Summary: "Get the migration status for " + name, + Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated.", + Method: http.MethodGet, + Path: "/migration/" + name + "/status", + Tags: tags, + }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + return migrationFileStatus(ctx, factory) + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-migrate", + Summary: "Migrate from " + name, + Description: "Imports the authenticated user's data from an uploaded export file into Vikunja. Send the file under the multipart \"import\" field. The import runs synchronously and returns once it has finished.", + Method: http.MethodPost, + Path: "/migration/" + name + "/migrate", + // POST runs an import rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, func(ctx context.Context, in *fileMigrateInput) (*migrationStartedBody, error) { + return migrationFileMigrate(ctx, factory, in) + }) +} + +func migrationFileStatus(ctx context.Context, factory func() migration.FileMigrator) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(factory(), u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func migrationFileMigrate(ctx context.Context, factory func() migration.FileMigrator, in *fileMigrateInput) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + if err := migrationHandler.RunFileMigration(factory(), u, src, src.Size); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Everything was migrated successfully." + return out, nil +} diff --git a/pkg/routes/api/v2/migration_oauth.go b/pkg/routes/api/v2/migration_oauth.go new file mode 100644 index 000000000..4d254632c --- /dev/null +++ b/pkg/routes/api/v2/migration_oauth.go @@ -0,0 +1,167 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" + "code.vikunja.io/api/pkg/modules/migration/todoist" + "code.vikunja.io/api/pkg/modules/migration/trello" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// migrationAuthURLBody is the response for the OAuth auth-url endpoint. +type migrationAuthURLBody struct { + Body migrationHandler.AuthURL +} + +// migrationStatusBody is the response for the migration status endpoint. +type migrationStatusBody struct { + Body *migration.Status +} + +// migrationMigrateBody carries the OAuth code obtained from the auth url back +// to the server. It is applied onto the concrete migrator (whose field carries +// json:"code") so it works across migrators regardless of their field name. +type migrationMigrateBody struct { + Code string `json:"code" doc:"The OAuth code obtained after authorizing against the auth url."` +} + +// migrationStartedBody confirms the migration was kicked off; the actual work +// runs asynchronously. +type migrationStartedBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` + } +} + +// RegisterMigrationOAuthRoutes wires the OAuth-based migrators (Todoist, Trello, +// Microsoft To-Do) onto the Huma API. Each migrator is gated behind its static +// config flag and exposes the same three operations, so registration is driven +// by one generic helper instead of three copy-pasted blocks. +func RegisterMigrationOAuthRoutes(api huma.API) { + registerOAuthMigrator(api, config.MigrationTodoistEnable.GetBool(), func() migration.Migrator { return &todoist.Migration{} }) + registerOAuthMigrator(api, config.MigrationTrelloEnable.GetBool(), func() migration.Migrator { return &trello.Migration{} }) + registerOAuthMigrator(api, config.MigrationMicrosoftTodoEnable.GetBool(), func() migration.Migrator { return µsofttodo.Migration{} }) +} + +func init() { AddRouteRegistrar(RegisterMigrationOAuthRoutes) } + +// registerOAuthMigrator registers auth/status/migrate for a single OAuth +// migrator. enabled gates the whole migrator (config early-return, no +// middleware); factory produces a fresh migrator instance per request, matching +// v1's MigrationStruct func so concurrent requests never share mutable state. +func registerOAuthMigrator(api huma.API, enabled bool, factory func() migration.Migrator) { + if !enabled { + return + } + + name := factory().Name() + tags := []string{"migration"} + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-auth", + Summary: "Get the auth url for " + name, + Description: "Returns the OAuth url the user needs to authenticate against. The code obtained there is passed back to the migrate endpoint.", + Method: http.MethodGet, + Path: "/migration/" + name + "/auth", + Tags: tags, + }, func(_ context.Context, _ *struct{}) (*migrationAuthURLBody, error) { + return &migrationAuthURLBody{Body: migrationHandler.AuthURL{URL: factory().AuthURL()}}, nil + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-status", + Summary: "Get the migration status for " + name, + Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated. Used to prevent starting a second migration while one is running.", + Method: http.MethodGet, + Path: "/migration/" + name + "/status", + Tags: tags, + }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + return migrationOAuthStatus(ctx, factory) + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-migrate", + Summary: "Migrate from " + name, + Description: "Starts a migration of the authenticated user's data from this service into Vikunja. The migration runs asynchronously; this returns once it has been queued. Refuses with 412 if a migration for this service is already running.", + Method: http.MethodPost, + Path: "/migration/" + name + "/migrate", + // POST kicks off a job rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + }, func(ctx context.Context, in *struct{ Body migrationMigrateBody }) (*migrationStartedBody, error) { + return migrationOAuthMigrate(ctx, factory, in.Body) + }) +} + +func migrationOAuthStatus(ctx context.Context, factory func() migration.Migrator) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(factory(), u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func migrationOAuthMigrate(ctx context.Context, factory func() migration.Migrator, body migrationMigrateBody) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + ms := factory() + // Apply the request payload onto the concrete migrator the same way v1's + // c.Bind does, so migrator-specific field names (e.g. Trello's Token, + // json:"code") bind transparently. + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, ms); err != nil { + return nil, huma.Error400BadRequest("invalid migration payload", err) + } + + if err := migrationHandler.StartMigration(ms, u); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Migration was started successfully." + return out, nil +} diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go new file mode 100644 index 000000000..45d1efe57 --- /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(ctx, &in.Body, deviceInfo, ipAddress) + if err != nil { + return nil, translateDomainError(err) + } + return &oauthTokenBody{CacheControl: "no-store", Body: resp}, nil +} + +func oauthAuthorize(ctx context.Context, in *struct{ Body oauth2server.AuthorizeRequest }) (*oauthAuthorizeBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + resp, err := oauth2server.Authorize(&in.Body, u.ID) + if err != nil { + return nil, translateDomainError(err) + } + return &oauthAuthorizeBody{Body: resp}, nil +} + +// requestClientInfo pulls the user agent and client IP off the underlying Echo +// request so the authorization_code grant 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() +} diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go new file mode 100644 index 000000000..1a379dbe6 --- /dev/null +++ b/pkg/routes/api/v2/task_collection.go @@ -0,0 +1,235 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +const taskListFilterDoc = "Filtering, sorting and search apply to every variant. See https://vikunja.io/docs/filters for the filter language." + +type taskListBody struct { + Body Paginated[*models.Task] +} + +// bucketsWithTasksBody is the buckets-with-tasks response. It is not paginated: +// the view's bucket configuration bounds how many tasks each bucket carries, so +// page/per_page don't apply and total is simply the number of buckets. +type bucketsWithTasksBody struct { + Body struct { + Items []*models.Bucket `json:"items"` + Total int64 `json:"total" doc:"The number of buckets returned."` + } +} + +// TaskListQueryParams is the shared filter/sort/search/expand query block for +// every task-list variant. It must stay EXPORTED: Huma promotes an anonymous +// embed's params only when the embed field is itself exported, and an embed +// field is exported iff its type name is (a lowercase type name silently drops +// all of its params from binding and the spec). +// +// The three input structs below embed it but keep their path params inline: +// Huma lists every path:"" field regardless of the route template, so a shared +// project/view field would leak onto a narrower route as a phantom path param. +// taskListViewInput is shared by both view-scoped endpoints. +type TaskListQueryParams struct { + ListParams + Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."` + FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."` + FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."` + SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."` + OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."` +} + +type taskListAllInput struct { + TaskListQueryParams +} + +type taskListProjectInput struct { + ProjectID int64 `path:"project" doc:"The numeric id of the project."` + TaskListQueryParams +} + +type taskListViewInput struct { + ProjectID int64 `path:"project" doc:"The numeric id of the project."` + ViewID int64 `path:"view" doc:"The numeric id of the project view."` + TaskListQueryParams +} + +// taskListFilters is the bound query carried into the shared collection builder. +// The three input structs convert into it so the collection logic lives once. +type taskListFilters struct { + Q string + Filter string + FilterTimezone string + FilterIncludeNulls bool + SortBy []string + OrderBy []string + Expand []string +} + +func (in taskListAllInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +func (in taskListProjectInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +func (in taskListViewInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +// collection turns the bound query into a TaskCollection. The search term +// arrives as `q` but reaches the model through DoReadAll's search argument, not +// the collection's Search field. forceFlat keeps a kanban view path returning +// flat tasks; the buckets endpoint leaves it false for the polymorphic shape. +func (f taskListFilters) collection(projectID, viewID int64, forceFlat bool) (*models.TaskCollection, error) { + expand, err := parseTaskExpand(f.Expand) + if err != nil { + return nil, translateDomainError(err) + } + tc := &models.TaskCollection{ + ProjectID: projectID, + ProjectViewID: viewID, + Filter: f.Filter, + FilterTimezone: f.FilterTimezone, + FilterIncludeNulls: f.FilterIncludeNulls, + SortBy: f.SortBy, + OrderBy: f.OrderBy, + Expand: expand, + } + if forceFlat { + tc.SetForceFlatTasks() + } + return tc, nil +} + +func RegisterTaskCollectionRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-list", + Summary: "List tasks across all projects", + Description: "Returns the tasks the authenticated user can see across every project they have access to, paginated and flat. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/tasks", + Tags: tags, + }, tasksListAll) + + Register(api, huma.Operation{ + OperationID: "project-tasks-list", + Summary: "List tasks in a project", + Description: "Returns the tasks in a project, paginated and flat. Requires read access to the project. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/tasks", + Tags: tags, + }, projectTasksList) + + Register(api, huma.Operation{ + OperationID: "project-view-tasks-list", + Summary: "List tasks in a project view", + Description: "Returns the tasks in a project view, paginated and flat. The view's own filter, sort and search are applied on top of the query. Always returns flat tasks, even for a kanban view — use the buckets endpoint to get tasks grouped by bucket. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/views/{view}/tasks", + Tags: tags, + }, projectViewTasksList) + + Register(api, huma.Operation{ + OperationID: "project-view-buckets-tasks-list", + Summary: "List a kanban view's buckets with their tasks", + Description: "Returns the buckets of a project's kanban view, each populated with the tasks in it. Requires read access to the project. Not paginated: the number and size of buckets follow the view's bucket configuration, so page/per_page do not apply. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/views/{view}/buckets/tasks", + Tags: tags, + }, projectViewBucketsTasksList) +} + +func init() { AddRouteRegistrar(RegisterTaskCollectionRoutes) } + +func tasksListAll(ctx context.Context, in *taskListAllInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, 0, 0) +} + +func projectTasksList(ctx context.Context, in *taskListProjectInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, 0) +} + +func projectViewTasksList(ctx context.Context, in *taskListViewInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, in.ViewID) +} + +// readFlatTasks runs DoReadAll for a flat-task endpoint and unwraps the result. +// The model authorizes (project/view CanRead) inside ReadAll, so there's no +// Can* call here. +func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, projectID, viewID int64) (*taskListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tc, err := f.collection(projectID, viewID, true) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, page, perPage) + if err != nil { + return nil, translateDomainError(err) + } + tasks, ok := result.([]*models.Task) + if !ok { + return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result) + } + return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil +} + +func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*bucketsWithTasksBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + f := in.filters() + tc, err := f.collection(in.ProjectID, in.ViewID, false) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + buckets, ok := result.([]*models.Bucket) + if !ok { + // ReadAll only yields []*Bucket from the kanban branch; a flat []*Task + // here means the view has no bucket configuration, so there are no + // buckets to return. That's a client error, not a 500. + if _, isTasks := result.([]*models.Task); isTasks { + return nil, huma.Error400BadRequest("this view has no buckets; use the tasks endpoint for non-kanban views") + } + return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result) + } + out := &bucketsWithTasksBody{} + out.Body.Items = buckets + out.Body.Total = total + return out, nil +} diff --git a/pkg/routes/api/v2/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/api/v2/token_meta.go b/pkg/routes/api/v2/token_meta.go new file mode 100644 index 000000000..120c3c81e --- /dev/null +++ b/pkg/routes/api/v2/token_meta.go @@ -0,0 +1,138 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/danielgtaylor/huma/v2" +) + +// tokenTestBody is the response for the token-check endpoints. +type tokenTestBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A static confirmation message."` + } +} + +// apiRoutesBody is the response for the token-routes endpoint: the available +// API routes grouped by permission, for building API-token scopes. +type apiRoutesBody struct { + Body map[string]models.APITokenRoute +} + +// renewTokenBody wraps a freshly issued link-share JWT. The token field is +// inlined rather than embedding auth.Token because Huma derives schema names +// from the bare Go type name, and a top-level auth.Token body would collide with +// user.Token (the caldav-token schema, also named "Token"). +type renewTokenBody struct { + Body struct { + Token string `json:"token" readOnly:"true" doc:"The renewed JWT auth token."` + } +} + +func init() { AddRouteRegistrar(RegisterTokenMetaRoutes) } + +// RegisterTokenMetaRoutes wires the token introspection helpers and the +// link-share token renewal endpoint. +func RegisterTokenMetaRoutes(api huma.API) { + tags := []string{"auth"} + + // v1 served GET as a 200 "ok" and POST as a 418 teapot easter egg; v2 makes + // both a plain 200 so a token check is an ordinary success. + Register(api, huma.Operation{ + OperationID: "token-test", + Summary: "Test a token", + Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.", + Method: http.MethodGet, + Path: "/token/test", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenTest) + + Register(api, huma.Operation{ + OperationID: "token-check", + Summary: "Check a token", + Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.", + Method: http.MethodPost, + Path: "/token/test", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenCheck) + + Register(api, huma.Operation{ + OperationID: "token-routes", + Summary: "List API token routes", + Description: "Returns every API route available to scope an API token against, grouped by resource and permission. Covers both /api/v1 and /api/v2 routes.", + Method: http.MethodGet, + Path: "/routes", + Tags: []string{"api"}, + }, tokenRoutes) + + Register(api, huma.Operation{ + OperationID: "token-renew", + Summary: "Renew a link-share token", + Description: "Issues a fresh JWT for the current link share. Only link-share tokens can be renewed here; user sessions must use the refresh-token flow.", + Method: http.MethodPost, + Path: "/user/token", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenRenew) +} + +func tokenTest(_ context.Context, _ *struct{}) (*tokenTestBody, error) { + out := &tokenTestBody{} + out.Body.Message = "ok" + return out, nil +} + +func tokenCheck(_ context.Context, _ *struct{}) (*tokenTestBody, error) { + out := &tokenTestBody{} + out.Body.Message = "ok" + return out, nil +} + +func tokenRoutes(_ context.Context, _ *struct{}) (*apiRoutesBody, error) { + return &apiRoutesBody{Body: models.GetAPITokenRoutes()}, nil +} + +func tokenRenew(ctx context.Context, _ *struct{}) (*renewTokenBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + // Only link-share tokens are renewable here; a user JWT lands as *user.User + // and must use the refresh-token flow instead. + share, ok := a.(*models.LinkSharing) + if !ok { + return nil, huma.Error400BadRequest("User tokens cannot be renewed via this endpoint. Use the refresh-token flow instead.") + } + + t, err := auth.NewLinkShareJWTAuthtoken(share) + if err != nil { + return nil, translateDomainError(err) + } + + out := &renewTokenBody{} + out.Body.Token = t + return out, nil +} diff --git a/pkg/routes/api/v2/user_deletion.go b/pkg/routes/api/v2/user_deletion.go new file mode 100644 index 000000000..1ecc5e009 --- /dev/null +++ b/pkg/routes/api/v2/user_deletion.go @@ -0,0 +1,172 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type userDeletionPasswordBody struct { + Body struct { + Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."` + } +} + +type userDeletionConfirmBody struct { + Body struct { + Token string `json:"token" required:"true" minLength:"1" doc:"The deletion confirmation token from the email sent by the request-deletion endpoint."` + } +} + +func RegisterUserDeletionRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-deletion-request", + Summary: "Request account deletion", + Description: "Starts deletion of the authenticated user's account. Local users must provide their password; a confirmation email is then sent and deletion only proceeds once confirmed.", + Method: http.MethodPost, + Path: "/user/deletion/request", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionRequest) + + Register(api, huma.Operation{ + OperationID: "user-deletion-confirm", + Summary: "Confirm account deletion", + Description: "Confirms a requested account deletion using the token from the confirmation email and schedules the account for deletion.", + Method: http.MethodPost, + Path: "/user/deletion/confirm", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionConfirm) + + Register(api, huma.Operation{ + OperationID: "user-deletion-cancel", + Summary: "Cancel account deletion", + Description: "Cancels a scheduled account deletion. Local users must provide their password.", + Method: http.MethodPost, + Path: "/user/deletion/cancel", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionCancel) +} + +func init() { AddRouteRegistrar(RegisterUserDeletionRoutes) } + +// authUserFromCtx resolves the full DB user for the authenticated caller, refusing +// link shares (which have no account to delete) with a 403. +func authUserFromCtx(ctx context.Context, s *xorm.Session) (*user.User, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + authUser, is := a.(*user.User) + if !is { + return nil, huma.Error403Forbidden("only users can manage account deletion") + } + // The auth user from the JWT claims is partial; re-fetch for the password hash. + u, err := user.GetUserByID(s, authUser.ID) + if err != nil { + return nil, translateDomainError(err) + } + return u, nil +} + +func userDeletionRequest(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.RequestDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionConfirm(ctx context.Context, in *userDeletionConfirmBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := user.ConfirmDeletion(s, u, in.Body.Token); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionCancel(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.CancelDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/user_search.go b/pkg/routes/api/v2/user_search.go new file mode 100644 index 000000000..5848ba5c0 --- /dev/null +++ b/pkg/routes/api/v2/user_search.go @@ -0,0 +1,120 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +type userListBody struct { + Body Paginated[*user.User] +} + +// RegisterUserSearchRoutes wires the two user-search endpoints onto the Huma API: +// a global search and a per-project search used for share autocomplete. +func RegisterUserSearchRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "users-search", + Summary: "Search users", + Description: "Searches users by username, name or full email. Matching by name or email requires the target user to have made themselves discoverable, unless both users share an external (OIDC/LDAP) team. Email addresses are never returned.", + Method: http.MethodGet, + Path: "/users", + Tags: []string{"user"}, + }, usersSearch) + + Register(api, huma.Operation{ + OperationID: "projects-users-search", + Summary: "Search users with access to a project", + Description: "Returns the users who can access the project — through ownership, a direct share or a team — optionally filtered by a search string. Intended for share autocomplete. Requires read access to the project.", + Method: http.MethodGet, + Path: "/projects/{project}/users/search", + Tags: []string{"sharing"}, + }, projectUsersSearch) +} + +func init() { AddRouteRegistrar(RegisterUserSearchRoutes) } + +func usersSearch(ctx context.Context, in *struct { + Q string `query:"q" doc:"Search query matched against username, name or full email."` +}) (*userListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + currentUser, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + users, err := user.SearchUsers(s, in.Q, currentUser) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &userListBody{Body: NewPaginated(users, int64(len(users)), 1, len(users))}, nil +} + +func projectUsersSearch(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + Q string `query:"q" doc:"Search query matched against username and name."` +}) (*userListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + currentUser, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + project := &models.Project{ID: in.ProjectID} + users, canRead, err := models.SearchUsersForProject(s, project, a, currentUser, in.Q) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !canRead { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &userListBody{Body: NewPaginated(users, int64(len(users)), 1, len(users))}, nil +} diff --git a/pkg/routes/api/v2/user_settings.go b/pkg/routes/api/v2/user_settings.go new file mode 100644 index 000000000..35a366644 --- /dev/null +++ b/pkg/routes/api/v2/user_settings.go @@ -0,0 +1,334 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/tkuchiki/go-timezone" +) + +// userInfoBody is the GET /user response: the public user fields plus the +// computed account facts v1 returned alongside the user object. +type userInfoBody struct { + user.User + Settings *models.UserGeneralSettings `json:"settings" readOnly:"true" doc:"The current user's settings."` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at" readOnly:"true" doc:"When the account is scheduled for deletion, if a deletion was requested."` + IsLocalUser bool `json:"is_local_user" readOnly:"true" doc:"True if the user authenticates locally (not via LDAP or OpenID)."` + AuthProvider string `json:"auth_provider" readOnly:"true" doc:"The name of the source the user authenticated with: 'local', 'ldap', or the configured OpenID provider name."` + IsAdmin bool `json:"is_admin" readOnly:"true" doc:"True if the user is an instance administrator."` +} + +// userAvatarProviderBody is the get/set body for the user's avatar provider. +type userAvatarProviderBody struct { + AvatarProvider string `json:"avatar_provider" doc:"The avatar provider. One of: gravatar (uses the user email), upload, initials, marble (random per user), ldap (synced from LDAP), openid (synced from OpenID), default."` +} + +type userActionMessageBody struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` +} + +// RegisterUserSettingsRoutes wires the current-user account & settings +// endpoints onto the Huma API. These are not CRUDable resources: each operates +// on the authenticated user pulled from the request context. +func RegisterUserSettingsRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-show", + Summary: "Get the current user", + Description: "Returns the authenticated user together with their settings and computed account facts (auth_provider, is_local_user, is_admin, deletion_scheduled_at).", + Method: http.MethodGet, + Path: "/user", + Tags: tags, + }, userShow) + + Register(api, huma.Operation{ + OperationID: "user-change-password", + Summary: "Change the current user's password", + Description: "Changes the authenticated user's password after verifying the old one. All of the user's existing sessions are invalidated.", + Method: http.MethodPost, + Path: "/user/password", + // Changes a password, it creates nothing — keep 200 over the wrapper's POST→201. + DefaultStatus: http.StatusOK, + Tags: tags, + }, userChangePassword) + + Register(api, huma.Operation{ + OperationID: "user-update-email", + Summary: "Update the current user's email address", + Description: "Sets a new email address for the authenticated user after verifying their password. If the mailer is enabled the change is pending until the user confirms it via a link sent to the new address; otherwise it takes effect immediately.", + Method: http.MethodPut, + Path: "/user/settings/email", + Tags: tags, + }, userUpdateEmail) + + Register(api, huma.Operation{ + OperationID: "user-update-settings", + Summary: "Update the current user's general settings", + Description: "Replaces the authenticated user's general settings (name, reminders, discoverability, default project, week start, language, timezone, frontend settings).", + Method: http.MethodPut, + Path: "/user/settings/general", + Tags: tags, + }, userUpdateSettings) + + // Path differs from v1's /user/settings/avatar: on v2 that path is the + // binary avatar upload (PUT), so the provider get/set live on a sub-path. + Register(api, huma.Operation{ + OperationID: "user-get-avatar-provider", + Summary: "Get the current user's avatar provider", + Description: "Returns the avatar provider configured for the authenticated user.", + Method: http.MethodGet, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userGetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-set-avatar-provider", + Summary: "Set the current user's avatar provider", + Description: "Changes the avatar provider for the authenticated user. Valid values: gravatar, upload, initials, marble, ldap, openid, default.", + Method: http.MethodPut, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userSetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-timezones", + Summary: "List available time zones", + Description: "Returns every time zone this Vikunja instance can handle. The list depends on the host system and is unsorted; sort it client-side.", + Method: http.MethodGet, + Path: "/user/timezones", + Tags: tags, + }, userTimezones) +} + +func init() { AddRouteRegistrar(RegisterUserSettingsRoutes) } + +func userShow(ctx context.Context, _ *struct{}) (*singleBody[userInfoBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + u, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + return nil, translateDomainError(err) + } + + info := &userInfoBody{ + User: *u, + Settings: models.NewUserGeneralSettings(u), + DeletionScheduledAt: u.DeletionScheduledAt, + IsLocalUser: u.Issuer == user.IssuerLocal, + IsAdmin: u.IsAdmin, + } + + // nolint:contextcheck // openid.GetAllProviders/Issuer (called via shared) take + // no context; threading one would change those signatures across both APIs. + info.AuthProvider, err = shared.GetAuthProviderName(u) + if err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userInfoBody]{Body: info}, nil +} + +func userChangePassword(ctx context.Context, in *struct { + Body struct { + OldPassword string `json:"old_password" doc:"The current password, for confirmation."` + NewPassword string `json:"new_password" valid:"bcrypt_password" minLength:"8" maxLength:"72" doc:"The new password. Max 72 bytes (a bcrypt limit), which may be fewer than 72 characters."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := models.ChangeUserPassword(ctx, s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The password was updated successfully."}}, nil +} + +func userUpdateEmail(ctx context.Context, in *struct { + Body struct { + NewEmail string `json:"new_email" valid:"email,length(0|250),required" maxLength:"250" doc:"The new email address."` + Password string `json:"password" doc:"The current password, for confirmation."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := user.ChangeUserEmail(ctx, s, doer, in.Body.Password, in.Body.NewEmail); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "We sent you email with a link to confirm your email address."}}, nil +} + +func userUpdateSettings(ctx context.Context, in *struct { + Body models.UserGeneralSettings +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserGeneralSettings(s, u, &in.Body); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The settings were updated successfully."}}, nil +} + +func userGetAvatarProvider(ctx context.Context, _ *struct{}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +func userSetAvatarProvider(ctx context.Context, in *struct { + Body userAvatarProviderBody +}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserAvatarProvider(s, u, in.Body.AvatarProvider); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +type timezonesBody struct { + Body []string +} + +func userTimezones(ctx context.Context, _ *struct{}) (*timezonesBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + timezoneMap := make(map[string]bool) // de-dupe across the per-abbreviation groups + for _, group := range timezone.New().Timezones() { + for _, t := range group { + timezoneMap[t] = true + } + } + + ts := make([]string, 0, len(timezoneMap)) + for t := range timezoneMap { + ts = append(ts, t) + } + + return &timezonesBody{Body: ts}, nil +} diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go new file mode 100644 index 000000000..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/routes/api/v2/user_webhooks.go b/pkg/routes/api/v2/user_webhooks.go new file mode 100644 index 000000000..b35407c79 --- /dev/null +++ b/pkg/routes/api/v2/user_webhooks.go @@ -0,0 +1,167 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// models.Webhook.ReadAll returns []*models.Webhook, so that's the element type. +type userWebhookListBody struct { + Body Paginated[*models.Webhook] +} + +type userWebhookEventsBody struct { + Body []string +} + +// RegisterUserWebhookRoutes wires the per-user webhook CRUD onto the Huma API. +// User webhooks are the project-less sibling of the project webhooks (see +// webhooks.go): they fire across all of a user's projects and are owned by the +// user, not a project. Both resources share the webhooks.enabled gate; the check +// runs here (not at init()) because RegisterAll fires after config is loaded. +// Like project webhooks there is deliberately no ReadOne — webhooks carry +// credentials — so AutoPatch synthesises no PATCH and update is PUT only. +func RegisterUserWebhookRoutes(api huma.API) { + if !config.WebhooksEnabled.GetBool() { + return + } + + tags := []string{"webhooks"} + + Register(api, huma.Operation{ + OperationID: "user-webhooks-list", + Summary: "List the current user's webhooks", + Description: "Returns the webhook targets the authenticated user has configured for themselves (not project webhooks), paginated. Secret and basic-auth credentials are never included.", + Method: http.MethodGet, + Path: "/user/settings/webhooks", + Tags: tags, + }, userWebhooksList) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-events", + Summary: "List available user-directed webhook events", + Description: "Returns the webhook event names a user-level webhook may subscribe to. This is a subset of the project webhook events — only events that target a single user.", + Method: http.MethodGet, + Path: "/user/settings/webhooks/events", + Tags: tags, + }, userWebhooksEvents) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-create", + Summary: "Create a webhook for the current user", + Description: "Creates a webhook target owned by the authenticated user that receives POST requests across all of their projects. The owning user is taken from the token, not the body. May only subscribe to user-directed events (see the events route). The secret and basic-auth credentials are write-only and not returned in the response.", + Method: http.MethodPost, + Path: "/user/settings/webhooks", + Tags: tags, + }, userWebhooksCreate) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-update", + Summary: "Update a user webhook's events", + Description: "Changes the events a user webhook subscribes to. Only the events list can be changed; target_url, secret and auth are immutable after creation. Only the owning user may update it.", + Method: http.MethodPut, + Path: "/user/settings/webhooks/{webhook}", + Tags: tags, + }, userWebhooksUpdate) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-delete", + Summary: "Delete a user webhook", + Description: "Deletes a webhook owned by the authenticated user. Only the owning user may delete it.", + Method: http.MethodDelete, + Path: "/user/settings/webhooks/{webhook}", + Tags: tags, + }, userWebhooksDelete) +} + +func init() { AddRouteRegistrar(RegisterUserWebhookRoutes) } + +func userWebhooksList(ctx context.Context, in *ListParams) (*userWebhookListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.Webhook{UserID: a.GetID()}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + items, ok := result.([]*models.Webhook) + if !ok { + return nil, fmt.Errorf("webhooks.ReadAll returned unexpected type %T (expected []*models.Webhook)", result) + } + return &userWebhookListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil +} + +func userWebhooksEvents(_ context.Context, _ *struct{}) (*userWebhookEventsBody, error) { + return &userWebhookEventsBody{Body: models.GetUserDirectedWebhookEvents()}, nil +} + +func userWebhooksCreate(ctx context.Context, in *struct { + Body models.Webhook +}) (*singleBody[models.Webhook], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + // Force user ownership: a user webhook is keyed on the user, never a project. + in.Body.UserID = a.GetID() + in.Body.ProjectID = 0 + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Webhook]{Body: &in.Body}, nil +} + +func userWebhooksUpdate(ctx context.Context, in *struct { + ID int64 `path:"webhook"` + Body models.Webhook +}) (*singleBody[models.Webhook], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + // canDoWebhook resolves the owner from the stored row, so only the id is + // needed to gate the update; the rest of the body's ownership fields are + // ignored. Update persists only the events list. + in.Body.ID = in.ID + if err := handler.DoUpdate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Webhook]{Body: &in.Body}, nil +} + +func userWebhooksDelete(ctx context.Context, in *struct { + ID int64 `path:"webhook"` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.Webhook{ID: in.ID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/routes/api/v2/webhook_events.go b/pkg/routes/api/v2/webhook_events.go new file mode 100644 index 000000000..56ad57873 --- /dev/null +++ b/pkg/routes/api/v2/webhook_events.go @@ -0,0 +1,54 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + + "github.com/danielgtaylor/huma/v2" +) + +type webhookEventsBody struct { + Body []string `json:"events" doc:"The events a webhook target can subscribe to."` +} + +// RegisterWebhookEventRoutes wires the available-webhook-events listing onto the +// Huma API. Like v1, the whole endpoint only exists when webhooks are enabled. +func RegisterWebhookEventRoutes(api huma.API) { + if !config.WebhooksEnabled.GetBool() { + return + } + + Register(api, huma.Operation{ + OperationID: "webhooks-events-list", + Summary: "List available webhook events", + Description: "Returns every event a webhook target can subscribe to. Use these values when creating or updating a webhook.", + Method: http.MethodGet, + Path: "/webhooks/events", + Tags: []string{"webhooks"}, + }, webhookEventsList) +} + +func init() { AddRouteRegistrar(RegisterWebhookEventRoutes) } + +func webhookEventsList(_ context.Context, _ *struct{}) (*webhookEventsBody, error) { + return &webhookEventsBody{Body: models.GetAvailableWebhookEvents()}, nil +} diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index 0c9708849..4f146e5a7 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -21,6 +21,7 @@ import ( "strings" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" @@ -89,5 +90,17 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context, sk c.Set("api_token", token) c.Set("api_user", u) + // Guarded by config: this fires on every token-authenticated request and + // only the audit listener consumes it. + if config.AuditEnabled.GetBool() { + err = events.DispatchWithContext(c.Request().Context(), &models.APITokenUsedEvent{ + TokenID: token.ID, + OwnerID: token.OwnerID, + }) + if err != nil { + log.Errorf("Could not dispatch api token used event: %s", err) + } + } + return nil } diff --git a/pkg/routes/caldav/auth.go b/pkg/routes/caldav/auth.go index 930b8f013..fc89d7555 100644 --- a/pkg/routes/caldav/auth.go +++ b/pkg/routes/caldav/auth.go @@ -88,7 +88,7 @@ func BasicAuth(c *echo.Context, username, password string) (bool, error) { return false, nil } if u == nil { - u, err = user.CheckUserCredentials(s, credentials) + u, err = user.CheckUserCredentials(c.Request().Context(), s, credentials) if err != nil { log.Errorf("Error during basic auth for caldav: %v", err) return false, nil diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index 5544d3ec7..60a151e2d 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -17,6 +17,7 @@ package caldav import ( + "context" "slices" "strconv" "strings" @@ -396,7 +397,7 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) ( return nil, err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Build up the proper response rr := VikunjaProjectResourceAdapter{ @@ -473,7 +474,7 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) ( return nil, err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Build up the proper response rr := VikunjaProjectResourceAdapter{ @@ -516,7 +517,7 @@ func (vcls *VikunjaCaldavProjectStorage) DeleteResource(_ string) error { return err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) } return nil diff --git a/pkg/routes/middleware/request_meta.go b/pkg/routes/middleware/request_meta.go new file mode 100644 index 000000000..865cd5cde --- /dev/null +++ b/pkg/routes/middleware/request_meta.go @@ -0,0 +1,42 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package middleware + +import ( + "code.vikunja.io/api/pkg/events" + + "github.com/labstack/echo/v5" +) + +// RequestMeta stashes IP, User-Agent and the request ID on the request +// context so events dispatched while handling the request carry them as +// message metadata (consumed by the audit listeners). Must run after the +// RequestID middleware, which guarantees the response header is populated. +func RequestMeta() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{ + IP: c.RealIP(), + UserAgent: req.UserAgent(), + RequestID: c.Response().Header().Get(echo.HeaderXRequestID), + }) + c.SetRequest(req.WithContext(ctx)) + return next(c) + } + } +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 159994724..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()) @@ -199,6 +204,10 @@ func NewEcho() *echo.Echo { // handler binds them. Runs globally so both /api/v1 and /api/v2 benefit. e.Use(vmiddleware.NormalizeArrayParams()) + if config.AuditEnabled.GetBool() { + e.Use(vmiddleware.RequestMeta()) + } + setupSentry(e) // Validation @@ -343,6 +352,14 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v2/docs": true, "/api/v2/docs/scalar.standalone.js": true, "/api/v2/schemas/:schema": true, + "/api/v2/info": true, + + "/api/v2/register": true, + "/api/v2/user/password/token": true, + "/api/v2/user/password/reset": true, + "/api/v2/user/confirm": true, + "/api/v2/shares/:share/auth": true, + "/api/v2/oauth/token": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 874d9ec09..8f6f0e673 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -42,7 +42,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.Overview" + "$ref": "#/definitions/models.Overview" } }, "404": { @@ -207,7 +207,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } } }, @@ -243,7 +243,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/admin.CreateUserBody" + "$ref": "#/definitions/models.CreateUserBody" } } ], @@ -251,7 +251,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -352,7 +352,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -410,7 +410,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -836,7 +836,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.vikunjaInfos" + "$ref": "#/definitions/shared.VikunjaInfos" } } } @@ -7848,7 +7848,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -8884,44 +8884,6 @@ const docTemplate = `{ } }, "definitions": { - "admin.CreateUserBody": { - "type": "object", - "properties": { - "email": { - "description": "The user's email address", - "type": "string", - "maxLength": 250 - }, - "is_admin": { - "description": "Mark the new user as an instance admin.", - "type": "boolean" - }, - "language": { - "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", - "type": "string" - }, - "name": { - "description": "The full name of the new user. Optional.", - "type": "string" - }, - "password": { - "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", - "type": "string", - "maxLength": 72, - "minLength": 8 - }, - "skip_email_confirm": { - "description": "Activate the new user immediately without email confirmation.", - "type": "boolean" - }, - "username": { - "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", - "type": "string", - "maxLength": 250, - "minLength": 3 - } - } - }, "admin.IsAdminPatch": { "type": "object", "properties": { @@ -8931,29 +8893,6 @@ const docTemplate = `{ } } }, - "admin.Overview": { - "type": "object", - "properties": { - "license": { - "$ref": "#/definitions/license.Info" - }, - "projects": { - "type": "integer" - }, - "shares": { - "$ref": "#/definitions/admin.ShareCounts" - }, - "tasks": { - "type": "integer" - }, - "teams": { - "type": "integer" - }, - "users": { - "type": "integer" - } - } - }, "admin.OwnerPatch": { "type": "object", "properties": { @@ -8962,20 +8901,6 @@ const docTemplate = `{ } } }, - "admin.ShareCounts": { - "type": "object", - "properties": { - "link_shares": { - "type": "integer" - }, - "team_shares": { - "type": "integer" - }, - "user_shares": { - "type": "integer" - } - } - }, "admin.StatusPatch": { "type": "object", "properties": { @@ -8989,57 +8914,6 @@ const docTemplate = `{ } } }, - "admin.User": { - "type": "object", - "properties": { - "auth_provider": { - "type": "string" - }, - "bot_owner_id": { - "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", - "type": "integer" - }, - "created": { - "description": "A timestamp when this task was created. You cannot change this value.", - "type": "string" - }, - "email": { - "description": "The user's email address.", - "type": "string", - "maxLength": 250 - }, - "id": { - "description": "The unique, numeric id of this user.", - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "issuer": { - "type": "string" - }, - "name": { - "description": "The full name of the user.", - "type": "string" - }, - "status": { - "$ref": "#/definitions/user.Status" - }, - "subject": { - "type": "string" - }, - "updated": { - "description": "A timestamp when this task was last updated. You cannot change this value.", - "type": "string" - }, - "username": { - "description": "The username of the user. Is always unique.", - "type": "string", - "maxLength": 250, - "minLength": 1 - } - } - }, "auth.Token": { "type": "object", "properties": { @@ -9470,6 +9344,44 @@ const docTemplate = `{ } } }, + "models.CreateUserBody": { + "type": "object", + "properties": { + "email": { + "description": "The user's email address", + "type": "string", + "maxLength": 250 + }, + "is_admin": { + "description": "Mark the new user as an instance admin.", + "type": "boolean" + }, + "language": { + "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", + "type": "string" + }, + "name": { + "description": "The full name of the new user. Optional.", + "type": "string" + }, + "password": { + "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", + "type": "string", + "maxLength": 72, + "minLength": 8 + }, + "skip_email_confirm": { + "description": "Activate the new user immediately without email confirmation.", + "type": "boolean" + }, + "username": { + "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", + "type": "string", + "maxLength": 250, + "minLength": 3 + } + } + }, "models.DatabaseNotifications": { "type": "object", "properties": { @@ -9629,6 +9541,29 @@ const docTemplate = `{ } } }, + "models.Overview": { + "type": "object", + "properties": { + "license": { + "$ref": "#/definitions/license.Info" + }, + "projects": { + "type": "integer" + }, + "shares": { + "$ref": "#/definitions/models.ShareCounts" + }, + "tasks": { + "type": "integer" + }, + "teams": { + "type": "integer" + }, + "users": { + "type": "integer" + } + } + }, "models.Permission": { "type": "integer", "enum": [ @@ -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": [ @@ -10630,6 +10579,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": { @@ -10766,6 +10758,192 @@ const docTemplate = `{ } } }, + "shared.AdminUser": { + "type": "object", + "properties": { + "auth_provider": { + "type": "string" + }, + "bot_owner_id": { + "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", + "type": "integer" + }, + "created": { + "description": "A timestamp when this task was created. You cannot change this value.", + "type": "string" + }, + "email": { + "description": "The user's email address.", + "type": "string", + "maxLength": 250 + }, + "id": { + "description": "The unique, numeric id of this user.", + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "issuer": { + "type": "string" + }, + "name": { + "description": "The full name of the user.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/user.Status" + }, + "subject": { + "type": "string" + }, + "updated": { + "description": "A timestamp when this task was last updated. You cannot change this value.", + "type": "string" + }, + "username": { + "description": "The username of the user. Is always unique.", + "type": "string", + "maxLength": 250, + "minLength": 1 + } + } + }, + "shared.AuthInfo": { + "type": "object", + "properties": { + "ldap": { + "$ref": "#/definitions/shared.LdapAuthInfo" + }, + "local": { + "$ref": "#/definitions/shared.LocalAuthInfo" + }, + "openid_connect": { + "$ref": "#/definitions/shared.OpenIDAuthInfo" + } + } + }, + "shared.LdapAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "shared.LegalInfo": { + "type": "object", + "properties": { + "imprint_url": { + "type": "string" + }, + "privacy_policy_url": { + "type": "string" + } + } + }, + "shared.LocalAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "registration_enabled": { + "type": "boolean" + } + } + }, + "shared.OpenIDAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider" + } + } + } + }, + "shared.VikunjaInfos": { + "type": "object", + "properties": { + "allow_icon_changes": { + "type": "boolean" + }, + "auth": { + "$ref": "#/definitions/shared.AuthInfo" + }, + "available_migrators": { + "type": "array", + "items": { + "type": "string" + } + }, + "caldav_enabled": { + "type": "boolean" + }, + "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": { @@ -11027,59 +11205,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 +11242,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.", @@ -11131,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 1d9d15f49..4cab9fb5e 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -34,7 +34,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.Overview" + "$ref": "#/definitions/models.Overview" } }, "404": { @@ -199,7 +199,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } } }, @@ -235,7 +235,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/admin.CreateUserBody" + "$ref": "#/definitions/models.CreateUserBody" } } ], @@ -243,7 +243,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -344,7 +344,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -402,7 +402,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/admin.User" + "$ref": "#/definitions/shared.AdminUser" } }, "400": { @@ -828,7 +828,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.vikunjaInfos" + "$ref": "#/definitions/shared.VikunjaInfos" } } } @@ -7840,7 +7840,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -8876,44 +8876,6 @@ } }, "definitions": { - "admin.CreateUserBody": { - "type": "object", - "properties": { - "email": { - "description": "The user's email address", - "type": "string", - "maxLength": 250 - }, - "is_admin": { - "description": "Mark the new user as an instance admin.", - "type": "boolean" - }, - "language": { - "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", - "type": "string" - }, - "name": { - "description": "The full name of the new user. Optional.", - "type": "string" - }, - "password": { - "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", - "type": "string", - "maxLength": 72, - "minLength": 8 - }, - "skip_email_confirm": { - "description": "Activate the new user immediately without email confirmation.", - "type": "boolean" - }, - "username": { - "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", - "type": "string", - "maxLength": 250, - "minLength": 3 - } - } - }, "admin.IsAdminPatch": { "type": "object", "properties": { @@ -8923,29 +8885,6 @@ } } }, - "admin.Overview": { - "type": "object", - "properties": { - "license": { - "$ref": "#/definitions/license.Info" - }, - "projects": { - "type": "integer" - }, - "shares": { - "$ref": "#/definitions/admin.ShareCounts" - }, - "tasks": { - "type": "integer" - }, - "teams": { - "type": "integer" - }, - "users": { - "type": "integer" - } - } - }, "admin.OwnerPatch": { "type": "object", "properties": { @@ -8954,20 +8893,6 @@ } } }, - "admin.ShareCounts": { - "type": "object", - "properties": { - "link_shares": { - "type": "integer" - }, - "team_shares": { - "type": "integer" - }, - "user_shares": { - "type": "integer" - } - } - }, "admin.StatusPatch": { "type": "object", "properties": { @@ -8981,57 +8906,6 @@ } } }, - "admin.User": { - "type": "object", - "properties": { - "auth_provider": { - "type": "string" - }, - "bot_owner_id": { - "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", - "type": "integer" - }, - "created": { - "description": "A timestamp when this task was created. You cannot change this value.", - "type": "string" - }, - "email": { - "description": "The user's email address.", - "type": "string", - "maxLength": 250 - }, - "id": { - "description": "The unique, numeric id of this user.", - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "issuer": { - "type": "string" - }, - "name": { - "description": "The full name of the user.", - "type": "string" - }, - "status": { - "$ref": "#/definitions/user.Status" - }, - "subject": { - "type": "string" - }, - "updated": { - "description": "A timestamp when this task was last updated. You cannot change this value.", - "type": "string" - }, - "username": { - "description": "The username of the user. Is always unique.", - "type": "string", - "maxLength": 250, - "minLength": 1 - } - } - }, "auth.Token": { "type": "object", "properties": { @@ -9462,6 +9336,44 @@ } } }, + "models.CreateUserBody": { + "type": "object", + "properties": { + "email": { + "description": "The user's email address", + "type": "string", + "maxLength": 250 + }, + "is_admin": { + "description": "Mark the new user as an instance admin.", + "type": "boolean" + }, + "language": { + "description": "The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.", + "type": "string" + }, + "name": { + "description": "The full name of the new user. Optional.", + "type": "string" + }, + "password": { + "description": "The user's password in clear text. Only used when registering the user. The maximum limi is 72 bytes, which may be less than 72 characters. This is due to the limit in the bcrypt hashing algorithm used to store passwords in Vikunja.", + "type": "string", + "maxLength": 72, + "minLength": 8 + }, + "skip_email_confirm": { + "description": "Activate the new user immediately without email confirmation.", + "type": "boolean" + }, + "username": { + "description": "The user's username. Cannot contain anything that looks like an url or whitespaces.", + "type": "string", + "maxLength": 250, + "minLength": 3 + } + } + }, "models.DatabaseNotifications": { "type": "object", "properties": { @@ -9621,6 +9533,29 @@ } } }, + "models.Overview": { + "type": "object", + "properties": { + "license": { + "$ref": "#/definitions/license.Info" + }, + "projects": { + "type": "integer" + }, + "shares": { + "$ref": "#/definitions/models.ShareCounts" + }, + "tasks": { + "type": "integer" + }, + "teams": { + "type": "integer" + }, + "users": { + "type": "integer" + } + } + }, "models.Permission": { "type": "integer", "enum": [ @@ -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": [ @@ -10622,6 +10571,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": { @@ -10758,6 +10750,192 @@ } } }, + "shared.AdminUser": { + "type": "object", + "properties": { + "auth_provider": { + "type": "string" + }, + "bot_owner_id": { + "description": "BotOwnerID is the ID of the owning (human) user if this user is a bot.\nA non-zero value means this user is a bot and cannot authenticate via password.", + "type": "integer" + }, + "created": { + "description": "A timestamp when this task was created. You cannot change this value.", + "type": "string" + }, + "email": { + "description": "The user's email address.", + "type": "string", + "maxLength": 250 + }, + "id": { + "description": "The unique, numeric id of this user.", + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "issuer": { + "type": "string" + }, + "name": { + "description": "The full name of the user.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/user.Status" + }, + "subject": { + "type": "string" + }, + "updated": { + "description": "A timestamp when this task was last updated. You cannot change this value.", + "type": "string" + }, + "username": { + "description": "The username of the user. Is always unique.", + "type": "string", + "maxLength": 250, + "minLength": 1 + } + } + }, + "shared.AuthInfo": { + "type": "object", + "properties": { + "ldap": { + "$ref": "#/definitions/shared.LdapAuthInfo" + }, + "local": { + "$ref": "#/definitions/shared.LocalAuthInfo" + }, + "openid_connect": { + "$ref": "#/definitions/shared.OpenIDAuthInfo" + } + } + }, + "shared.LdapAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "shared.LegalInfo": { + "type": "object", + "properties": { + "imprint_url": { + "type": "string" + }, + "privacy_policy_url": { + "type": "string" + } + } + }, + "shared.LocalAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "registration_enabled": { + "type": "boolean" + } + } + }, + "shared.OpenIDAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider" + } + } + } + }, + "shared.VikunjaInfos": { + "type": "object", + "properties": { + "allow_icon_changes": { + "type": "boolean" + }, + "auth": { + "$ref": "#/definitions/shared.AuthInfo" + }, + "available_migrators": { + "type": "array", + "items": { + "type": "string" + } + }, + "caldav_enabled": { + "type": "boolean" + }, + "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": { @@ -11019,59 +11197,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 +11234,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.", @@ -11123,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 6bc114729..af2b209dd 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 @@ -1333,6 +1292,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: @@ -1444,6 +1433,135 @@ definitions: receiving a 412 with error code 1017. See GHSA-8jvc-mcx6-r4cg. type: string type: object + shared.AdminUser: + properties: + auth_provider: + type: string + bot_owner_id: + description: |- + BotOwnerID is the ID of the owning (human) user if this user is a bot. + A non-zero value means this user is a bot and cannot authenticate via password. + type: integer + created: + description: A timestamp when this task was created. You cannot change this + value. + type: string + email: + description: The user's email address. + maxLength: 250 + type: string + id: + description: The unique, numeric id of this user. + type: integer + is_admin: + type: boolean + issuer: + type: string + name: + description: The full name of the user. + type: string + status: + $ref: '#/definitions/user.Status' + subject: + type: string + updated: + description: A timestamp when this task was last updated. You cannot change + this value. + type: string + username: + description: The username of the user. Is always unique. + maxLength: 250 + minLength: 1 + type: string + type: object + shared.AuthInfo: + properties: + ldap: + $ref: '#/definitions/shared.LdapAuthInfo' + local: + $ref: '#/definitions/shared.LocalAuthInfo' + openid_connect: + $ref: '#/definitions/shared.OpenIDAuthInfo' + type: object + shared.LdapAuthInfo: + properties: + enabled: + type: boolean + type: object + shared.LegalInfo: + properties: + imprint_url: + type: string + privacy_policy_url: + type: string + type: object + shared.LocalAuthInfo: + properties: + enabled: + type: boolean + registration_enabled: + type: boolean + type: object + shared.OpenIDAuthInfo: + properties: + enabled: + type: boolean + providers: + items: + $ref: '#/definitions/code_vikunja_io_api_pkg_modules_auth_openid.Provider' + type: array + type: object + shared.VikunjaInfos: + properties: + allow_icon_changes: + type: boolean + auth: + $ref: '#/definitions/shared.AuthInfo' + available_migrators: + items: + type: string + type: array + caldav_enabled: + type: boolean + 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: @@ -1640,53 +1758,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 +1788,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. @@ -1728,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: @@ -2018,7 +2001,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.Overview' + $ref: '#/definitions/models.Overview' "404": description: Not Found schema: @@ -2126,7 +2109,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' type: array "404": description: Not Found @@ -2148,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: @@ -2223,7 +2206,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' "400": description: Bad Request schema: @@ -2260,7 +2243,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/admin.User' + $ref: '#/definitions/shared.AdminUser' "400": description: Bad Request schema: @@ -2538,7 +2521,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.vikunjaInfos' + $ref: '#/definitions/shared.VikunjaInfos' summary: Info tags: - service @@ -7199,7 +7182,7 @@ paths: name: avatar required: true schema: - $ref: '#/definitions/v1.UserSettings' + $ref: '#/definitions/models.UserGeneralSettings' produces: - application/json responses: diff --git a/pkg/user/events.go b/pkg/user/events.go index ff7866149..12b17a957 100644 --- a/pkg/user/events.go +++ b/pkg/user/events.go @@ -25,3 +25,34 @@ type CreatedEvent struct { func (t *CreatedEvent) Name() string { return "user.created" } + +// LoginSucceededEvent is fired after a user successfully authenticated, +// regardless of the auth provider (local, LDAP, OpenID). +type LoginSucceededEvent struct { + User *User `json:"user"` +} + +// Name defines the name for LoginSucceededEvent +func (t *LoginSucceededEvent) Name() string { + return "user.login.succeeded" +} + +// LoginFailedEvent is fired for every failed password check of a known user. +type LoginFailedEvent struct { + User *User `json:"user"` +} + +// Name defines the name for LoginFailedEvent +func (t *LoginFailedEvent) Name() string { + return "user.login.failed" +} + +// LogoutEvent is fired when a user destroys their session. +type LogoutEvent struct { + UserID int64 `json:"user_id"` +} + +// Name defines the name for LogoutEvent +func (t *LogoutEvent) Name() string { + return "user.logout" +} diff --git a/pkg/user/token.go b/pkg/user/token.go index 565270289..ee4e844da 100644 --- a/pkg/user/token.go +++ b/pkg/user/token.go @@ -41,12 +41,12 @@ const ( // Token is a token a user can use to do things like verify their email or resetting their password type Token struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this token."` UserID int64 `xorm:"not null" json:"-"` Token string `xorm:"varchar(450) not null index" json:"-"` - ClearTextToken string `xorm:"-" json:"token"` + ClearTextToken string `xorm:"-" json:"token" readOnly:"true" doc:"The token in clear text. Only returned once when the token is created; never on subsequent reads."` Kind TokenKind `xorm:"not null" json:"-"` - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this token was created. You cannot change this value."` } // TableName returns the real table name for user tokens diff --git a/pkg/user/totp.go b/pkg/user/totp.go index 66abb813c..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 diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index 73e104682..26606c152 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -17,6 +17,8 @@ package user import ( + "context" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/notifications" "xorm.io/xorm" @@ -31,6 +33,17 @@ type EmailUpdate struct { Password string `json:"password"` } +// ChangeUserEmail verifies the user's password, then sets a new email address +// (kicking off confirmation when the mailer is enabled). Shared by the v1 and +// v2 email-update handlers; only HTTP input binding stays in the handlers. +func ChangeUserEmail(ctx context.Context, s *xorm.Session, u *User, password, newEmail string) error { + verified, err := CheckUserCredentials(ctx, s, &Login{Username: u.Username, Password: password}) + if err != nil { + return err + } + return UpdateEmail(s, &EmailUpdate{User: verified, NewEmail: newEmail}) +} + // UpdateEmail lets a user update their email address func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) { diff --git a/pkg/user/user.go b/pkg/user/user.go index 1aec85853..09fef2565 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -17,6 +17,7 @@ package user import ( + "context" "encoding/json" "errors" "fmt" @@ -27,6 +28,7 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/notifications" @@ -362,8 +364,9 @@ func getUserByUsernameOrEmail(s *xorm.Session, usernameOrEmail string) (u *User, return } -// CheckUserCredentials checks user credentials -func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { +// CheckUserCredentials checks user credentials. The context carries request +// metadata for the audit trail of failed attempts. +func CheckUserCredentials(ctx context.Context, s *xorm.Session, u *Login) (*User, error) { // Check if we have any credentials if u.Password == "" || u.Username == "" { return nil, ErrNoUsernamePassword{} @@ -390,7 +393,7 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { err = CheckUserPassword(user, u.Password) if err != nil { if IsErrWrongUsernameOrPassword(err) { - handleFailedPassword(user) + handleFailedPassword(ctx, user) } return user, err } @@ -410,7 +413,11 @@ func (u *User) IsLocalUser() bool { return u.Issuer == IssuerLocal } -func handleFailedPassword(user *User) { +func handleFailedPassword(ctx context.Context, user *User) { + if err := events.DispatchWithContext(ctx, &LoginFailedEvent{User: user}); err != nil { + log.Errorf("Could not dispatch login failed event: %s", err) + } + key := user.GetFailedPasswordAttemptsKey() err := keyvalue.IncrBy(key, 1) if err != nil { diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index 776a60b5d..38287c6e0 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -17,6 +17,7 @@ package user import ( + "context" "testing" "code.vikunja.io/api/pkg/db" @@ -357,7 +358,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345678"}) require.NoError(t, err) }) t.Run("unverified email", func(t *testing.T) { @@ -365,7 +366,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user5", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user5", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrEmailNotConfirmed(err)) }) @@ -374,7 +375,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345"}) require.Error(t, err) assert.True(t, IsErrWrongUsernameOrPassword(err)) }) @@ -383,7 +384,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "dfstestuu", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "dfstestuu", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrWrongUsernameOrPassword(err)) }) @@ -392,7 +393,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1"}) require.Error(t, err) assert.True(t, IsErrNoUsernamePassword(err)) }) @@ -401,7 +402,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrNoUsernamePassword(err)) }) @@ -410,7 +411,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1@example.com", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1@example.com", Password: "12345678"}) require.NoError(t, err) }) t.Run("disabled user", func(t *testing.T) { @@ -419,7 +420,7 @@ func TestCheckUserCredentials(t *testing.T) { defer s.Close() // user17 is disabled (status=2), password is "12345678" - _, err := CheckUserCredentials(s, &Login{Username: "user17", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user17", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrAccountDisabled(err)) }) @@ -429,7 +430,7 @@ func TestCheckUserCredentials(t *testing.T) { defer s.Close() // user18 is locked (status=3), password is "12345678" - _, err := CheckUserCredentials(s, &Login{Username: "user18", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user18", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrAccountLocked(err)) }) diff --git a/pkg/user/users_project.go b/pkg/user/users_project.go index ab94a4bdf..c08cc23c2 100644 --- a/pkg/user/users_project.go +++ b/pkg/user/users_project.go @@ -34,6 +34,20 @@ type ProjectUserOpts struct { MatchFuzzily bool } +// SearchUsers performs the global user search shared by both API versions: +// it lists users matching the search string and obfuscates their email +// addresses before returning. +func SearchUsers(s *xorm.Session, search string, currentUser *User) (users []*User, err error) { + users, err = ListUsers(s, search, currentUser, nil) + if err != nil { + return nil, err + } + for i := range users { + users[i].Email = "" + } + return users, nil +} + // ListUsers returns a list with all users, filtered by an optional search string func ListUsers(s *xorm.Session, search string, currentUser *User, opts *ProjectUserOpts) (users []*User, err error) { if opts == nil { diff --git a/pkg/web/handler/core.go b/pkg/web/handler/core.go index 01474b874..25c91c069 100644 --- a/pkg/web/handler/core.go +++ b/pkg/web/handler/core.go @@ -28,7 +28,7 @@ import ( // DoCreate runs the permission check + model Create + commit pipeline for a // CObject. Framework-agnostic: callable from both Echo (CreateWeb) and Huma. // Caller is responsible for body/path binding and validation before calling. -func DoCreate(_ context.Context, obj CObject, a web.Auth) error { +func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() defer func() { if err := s.Close(); err != nil { @@ -60,7 +60,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } @@ -68,7 +68,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error { // CObject. obj should have its identifying fields set before call. On success, // obj is fully populated. maxPermission is exposed via the x-max-permission // header in the Echo wrapper; Huma wrapper may ignore it. -func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { +func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { s := db.NewSession() defer func() { if cerr := s.Close(); cerr != nil { @@ -100,7 +100,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e return 0, err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return maxPermission, nil } @@ -108,7 +108,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e // scoping context (e.g., TaskID on LabelTask). Returns the result slice/ // interface, the result count, and total count. Pagination header math and // nil-slice normalization remain the caller's responsibility. -func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) { +func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) { s := db.NewSession() defer func() { if cerr := s.Close(); cerr != nil { @@ -128,14 +128,14 @@ func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, return nil, 0, 0, err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return result, resultCount, total, nil } // DoUpdate runs the permission check + model Update + commit pipeline for a // CObject. Framework-agnostic. Caller is responsible for body/path binding // and validation before calling. -func DoUpdate(_ context.Context, obj CObject, a web.Auth) error { +func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() defer func() { if err := s.Close(); err != nil { @@ -167,14 +167,14 @@ func DoUpdate(_ context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } // DoDelete runs the permission check + model Delete + commit pipeline for a // CObject. Framework-agnostic. Caller is responsible for path binding before // calling. -func DoDelete(_ context.Context, obj CObject, a web.Auth) error { +func DoDelete(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() defer func() { if err := s.Close(); err != nil { @@ -206,6 +206,6 @@ func DoDelete(_ context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } diff --git a/pkg/webtests/huma_admin_actions_test.go b/pkg/webtests/huma_admin_actions_test.go new file mode 100644 index 000000000..806036a32 --- /dev/null +++ b/pkg/webtests/huma_admin_actions_test.go @@ -0,0 +1,387 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Gate behaviour (404 on non-admin/unlicensed, 401 unauthenticated) is shared by +// every /api/v2/admin route; covered once here against the overview endpoint. +func TestHumaAdminOverview(t *testing.T) { + t.Run("non-admin user gets 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 1) + require.NoError(t, err) + require.False(t, u.IsAdmin, "fixture precondition: user1 is not an admin") + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", u, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("admin without the feature gets 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("unauthenticated caller gets 401", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", nil, "") + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("admin with the feature sees the overview", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "") + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + body := res.Body.String() + assert.Contains(t, body, `"users"`) + assert.Contains(t, body, `"projects"`) + assert.Contains(t, body, `"tasks"`) + assert.Contains(t, body, `"shares"`) + assert.Contains(t, body, `"license"`) + assert.Contains(t, body, `"licensed":true`) + assert.Contains(t, body, `"instance_id"`) + }) +} + +func TestHumaAdminCreateUser(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + // Admin endpoint must bypass the public-registration toggle. + prev := config.ServiceEnableRegistration.GetBool() + config.ServiceEnableRegistration.Set(false) + defer config.ServiceEnableRegistration.Set(prev) + + admin := promoteToAdmin(t, 1) + + t.Run("creates a plain user and returns 201", func(t *testing.T) { + body := `{"username":"v2adm-create-1","password":"averyl0ngpassword","email":"v2adm-create-1@example.com"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + assert.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + assert.Contains(t, res.Body.String(), `"username":"v2adm-create-1"`) + }) + + t.Run("creates an is_admin user", func(t *testing.T) { + body := `{"username":"v2adm-create-2","password":"averyl0ngpassword","email":"v2adm-create-2@example.com","is_admin":true}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByUsername(s, "v2adm-create-2") + require.NoError(t, err) + assert.True(t, u.IsAdmin, "new user should have been promoted") + }) + + t.Run("skip_email_confirm forces Status=Active", func(t *testing.T) { + body := `{"username":"v2adm-create-3","password":"averyl0ngpassword","email":"v2adm-create-3@example.com","skip_email_confirm":true}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByUsername(s, "v2adm-create-3") + require.NoError(t, err) + assert.Equal(t, user.StatusActive, u.Status) + }) + + t.Run("persists the name field", func(t *testing.T) { + body := `{"username":"v2adm-create-4","password":"averyl0ngpassword","email":"v2adm-create-4@example.com","name":"Adm Create"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByUsername(s, "v2adm-create-4") + require.NoError(t, err) + assert.Equal(t, "Adm Create", u.Name) + }) + + t.Run("rejects an invalid body with 422", func(t *testing.T) { + // Password below the 8-char minimum fails govalidator before the create. + body := `{"username":"v2adm-invalid","password":"short","email":"v2adm-invalid@example.com"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body) + assert.Equal(t, http.StatusUnprocessableEntity, res.Code, res.Body.String()) + }) + + t.Run("non-admin caller gets 404", func(t *testing.T) { + s := db.NewSession() + u2, err := user.GetUserByID(s, 2) + require.NoError(t, err) + require.False(t, u2.IsAdmin, "fixture precondition: user2 is not an admin") + s.Close() + + body := `{"username":"v2nonadmin","password":"averyl0ngpassword","email":"v2nonadmin@example.com"}` + res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", u2, body) + assert.Equal(t, http.StatusNotFound, res.Code) + }) +} + +func TestHumaAdminPatchAdmin(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + t.Run("promote a non-admin user", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`) + assert.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 2) + require.NoError(t, err) + assert.True(t, u.IsAdmin) + }) + + t.Run("demote when another admin exists is allowed", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":false}`) + assert.Equal(t, http.StatusOK, res.Code) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 2) + require.NoError(t, err) + assert.False(t, u.IsAdmin) + }) + + t.Run("last-admin guard refuses demotion with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/admin", admin, `{"is_admin":false}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 1) + require.NoError(t, err) + assert.True(t, u.IsAdmin, "last admin must remain admin after refused demotion") + }) + + t.Run("unknown user returns 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/9999999/admin", admin, `{"is_admin":true}`) + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("omitted is_admin is rejected rather than demoting", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`) + require.Equal(t, http.StatusOK, res.Code) + + res = adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 2) + require.NoError(t, err) + assert.True(t, u.IsAdmin, "omitted is_admin must not silently demote") + }) +} + +func TestHumaAdminPatchStatus(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":2}`) + assert.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + // GetUserByID refuses disabled accounts, so assert against the raw row. + s := db.NewSession() + defer s.Close() + var row struct { + Status int `xorm:"status"` + } + _, err = s.Table("users").Where("id = ?", 2).Get(&row) + require.NoError(t, err) + assert.Equal(t, 2, row.Status) + + t.Run("last-admin guard refuses self-disable with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/status", admin, `{"status":2}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + var row struct { + Status int `xorm:"status"` + } + _, err := s.Table("users").Where("id = ?", 1).Get(&row) + require.NoError(t, err) + assert.Equal(t, int(user.StatusActive), row.Status, "last admin must stay active after refused disable") + }) + + t.Run("rejects invalid status value with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":99}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + assert.Contains(t, res.Body.String(), "invalid status") + }) + + t.Run("omitted status is rejected rather than reactivating", func(t *testing.T) { + // User 2 was disabled above; an empty body must leave that intact. + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + + var row struct { + Status int `xorm:"status"` + } + _, err := s.Table("users").Where("id = ?", 2).Get(&row) + require.NoError(t, err) + assert.Equal(t, int(user.StatusDisabled), row.Status, "omitted status must not silently reactivate") + }) +} + +func TestHumaAdminDeleteUser(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + t.Run("mode=now deletes a regular user immediately with 204", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/15?mode=now", admin, "") + assert.Equal(t, http.StatusNoContent, res.Code) + + s := db.NewSession() + defer s.Close() + _, err := user.GetUserByID(s, 15) + assert.Error(t, err, "deleted user must no longer be fetchable") + }) + + t.Run("mode=scheduled keeps the user row", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/16?mode=scheduled", admin, "") + assert.Equal(t, http.StatusNoContent, res.Code) + + s := db.NewSession() + defer s.Close() + u := &user.User{ID: 16} + has, err := s.Get(u) + require.NoError(t, err) + assert.True(t, has, "scheduled deletion must not remove the user row") + }) + + t.Run("default (no mode) is scheduled", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/2", admin, "") + assert.Equal(t, http.StatusNoContent, res.Code) + + s := db.NewSession() + defer s.Close() + u := &user.User{ID: 2} + has, err := s.Get(u) + require.NoError(t, err) + assert.True(t, has, "default mode must not remove the user row") + }) + + t.Run("rejects invalid mode with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/3?mode=bogus", admin, "") + assert.Equal(t, http.StatusBadRequest, res.Code) + }) + + t.Run("mode=now last-admin guard refuses self-delete with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/1?mode=now", admin, "") + assert.Equal(t, http.StatusBadRequest, res.Code) + }) + + t.Run("unknown user returns 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/9999999?mode=now", admin, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) +} + +func TestHumaAdminReassignProjectOwner(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + t.Run("updates owner_id", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":2}`) + assert.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + s := db.NewSession() + defer s.Close() + var row struct { + OwnerID int64 `xorm:"owner_id"` + } + _, err := s.Table("projects").Where("id = ?", 2).Get(&row) + require.NoError(t, err) + assert.Equal(t, int64(2), row.OwnerID) + }) + + t.Run("rejects nonexistent owner with 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":99999}`) + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("nonexistent project returns 404", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/99999/owner", admin, `{"owner_id":1}`) + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("rejects disabled user as new owner with 412", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":17}`) + assert.Equal(t, http.StatusPreconditionFailed, res.Code) + }) + + t.Run("rejects locked user as new owner with 412", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":18}`) + assert.Equal(t, http.StatusPreconditionFailed, res.Code) + }) + + t.Run("rejects deletion-scheduled user as new owner with 400", func(t *testing.T) { + res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":20}`) + assert.Equal(t, http.StatusBadRequest, res.Code) + }) +} diff --git a/pkg/webtests/huma_auth_test.go b/pkg/webtests/huma_auth_test.go new file mode 100644 index 000000000..404f89f00 --- /dev/null +++ b/pkg/webtests/huma_auth_test.go @@ -0,0 +1,293 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/oauth2server" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaAuthPublic ports the v1 coverage of the public local-account flows +// (register, password reset, email confirm) to /api/v2. These endpoints opt out +// of the global auth, so requests carry no token. +func TestHumaAuthPublic(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + post := func(path, body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, path, body, "", "") + } + + t.Run("Register", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"newhumauser","password":"12345678","email":"newhuma@example.com"}`) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"username":"newhumauser"`) + }) + t.Run("already existing username", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"user1","password":"12345678","email":"x@example.com"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + t.Run("empty username", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"","password":"12345678","email":"x@example.com"}`) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest) + }) + }) + + t.Run("Request password reset token", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/password/token", `{"email":"user1@example.com"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "Token was sent.") + }) + t.Run("no user with that email", func(t *testing.T) { + rec := post("/api/v2/user/password/token", `{"email":"user1000@example.com"}`) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + }) + + t.Run("Reset password", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/password/reset", `{"token":"passwordresettesttoken","new_password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The password was updated successfully.") + }) + t.Run("invalid token", func(t *testing.T) { + rec := post("/api/v2/user/password/reset", `{"token":"invalidtoken","new_password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + }) + }) + + t.Run("Confirm email", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/confirm", `{"token":"tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The email was confirmed successfully.") + }) + t.Run("invalid token", func(t *testing.T) { + rec := post("/api/v2/user/confirm", `{"token":"invalidToken"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + }) + }) +} + +// TestHumaRegisterDisabled proves the registration endpoint 404s when +// registration is disabled, mirroring v1. +func TestHumaRegisterDisabled(t *testing.T) { + config.ServiceEnableRegistration.Set(false) + defer config.ServiceEnableRegistration.Set(true) + + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/register", + `{"username":"nope","password":"12345678","email":"nope@example.com"}`, "", "") + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestHumaLinkShareAuth ports the v1 link-share auth coverage to /api/v2. +func TestHumaLinkShareAuth(t *testing.T) { + config.ServiceEnableLinkSharing.Set(true) + + e, err := setupTestEnv() + require.NoError(t, err) + + post := func(share, body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, "/api/v2/shares/"+share+"/auth", body, "", "") + } + + t.Run("without password", func(t *testing.T) { + rec := post("test", ``) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Contains(t, rec.Body.String(), `"project_id":1`) + }) + t.Run("with password, correct", func(t *testing.T) { + rec := post("testWithPassword", `{"password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + }) + t.Run("with password, missing", func(t *testing.T) { + rec := post("testWithPassword", ``) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, models.ErrCodeLinkSharePasswordRequired, problemCode(t, rec)) + }) + t.Run("with password, wrong", func(t *testing.T) { + rec := post("testWithPassword", `{"password":"wrong"}`) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, models.ErrCodeLinkSharePasswordInvalid, problemCode(t, rec)) + }) +} + +// TestHumaTokenMeta ports the token-introspection and link-share renew +// endpoints to /api/v2. +func TestHumaTokenMeta(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + userToken := humaTokenFor(t, &testuser1) + + t.Run("token test (GET) returns ok", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"ok"`) + }) + t.Run("token check (POST) returns 200, not 418", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/token/test", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"ok"`) + }) + t.Run("token check unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + t.Run("routes lists token routes", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/routes", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var routes map[string]map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &routes)) + assert.Contains(t, routes, "tasks") + }) + + t.Run("renew link-share token", func(t *testing.T) { + share := &models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + } + shareToken, err := auth.NewLinkShareJWTAuthtoken(share) + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", shareToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + }) + t.Run("renew rejects user token", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", userToken, "") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +// TestHumaOAuth ports the OAuth 2.0 token and authorize flows to /api/v2 and +// exercises both the JSON and the spec-compliant form-urlencoded encodings of +// the token endpoint. +func TestHumaOAuth(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("authorize requires authentication", func(t *testing.T) { + body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "abc", "S256", "s") + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("full code flow with PKCE (JSON token request)", func(t *testing.T) { + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := pkceChallenge(verifier) + code := authorizeV2(t, e, challenge, "xyz") + + body, _ := json.Marshal(map[string]string{ //nolint:errchkjson + "grant_type": "authorization_code", + "code": code, + "client_id": "vikunja", + "redirect_uri": "vikunja-flutter://callback", + "code_verifier": verifier, + }) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", string(body), "", "application/json") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + var resp oauth2server.TokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.AccessToken) + assert.Equal(t, "bearer", resp.TokenType) + assert.NotEmpty(t, resp.RefreshToken) + }) + + t.Run("full code flow with PKCE (form-urlencoded token request)", func(t *testing.T) { + verifier := "form-encoded-flow-verifier" + challenge := pkceChallenge(verifier) + code := authorizeV2(t, e, challenge, "") + + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {"vikunja"}, + "redirect_uri": {"vikunja-flutter://callback"}, + "code_verifier": {verifier}, + } + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp oauth2server.TokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.AccessToken) + assert.NotEmpty(t, resp.RefreshToken) + }) + + t.Run("invalid grant type", func(t *testing.T) { + form := url.Values{"grant_type": {"password"}, "client_id": {"vikunja"}} + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func pkceChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// authorizeV2 runs the v2 authorize step for testuser1 and returns the code. +func authorizeV2(t *testing.T, e *echo.Echo, challenge, state string) string { + t.Helper() + token := humaTokenFor(t, &testuser1) + body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", challenge, "S256", state) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp oauth2server.AuthorizeResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.NotEmpty(t, resp.Code) + return resp.Code +} + +// problemCode pulls the Vikunja numeric error code out of an RFC 9457 body. +func problemCode(t *testing.T, rec *httptest.ResponseRecorder) int { + t.Helper() + var body struct { + Code int `json:"code"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body.Code +} diff --git a/pkg/webtests/huma_background_test.go b/pkg/webtests/huma_background_test.go new file mode 100644 index 000000000..8efdc3c2b --- /dev/null +++ b/pkg/webtests/huma_background_test.go @@ -0,0 +1,112 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaProjectBackgroundDelete covers removing a project background. It +// mirrors the v1 background_test.go matrix: the owner clears the background +// (and keeps the title), a read-only user is refused. +func TestHumaProjectBackgroundDelete(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner clears the background, title preserved", func(t *testing.T) { + // testuser6 owns project 35 (title "Test35 with background", background_file_id 1). + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + s := db.NewSession() + defer s.Close() + project := models.Project{ID: 35} + has, err := s.Get(&project) + require.NoError(t, err) + require.True(t, has) + assert.Equal(t, "Test35 with background", project.Title) + assert.Equal(t, int64(0), project.BackgroundFileID) + }) + t.Run("Read-only user is forbidden", func(t *testing.T) { + // testuser15 has read-only (permission 0) access to project 35. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaBackgroundDisabledByConfig verifies the registrar early-returns when +// project backgrounds are disabled: the DELETE route is then absent (404). +func TestHumaBackgroundDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(false) + defer config.BackgroundsEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/projects/35/background", "", humaTokenFor(t, &testuser6), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when backgrounds are disabled; body: %s", rec.Body.String()) +} + +// TestHumaUnsplashBackground covers the Unsplash routes' auth and permission +// gates. They are only registered when the unsplash provider is enabled (off by +// default), so the router is rebuilt with the flag on. The set route's +// permission check runs before any Unsplash network call, so the negative cases +// are exercised without hitting the real API; the happy path needs the network +// and is therefore not covered here (matching v1). +func TestHumaUnsplashBackground(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(true) + config.BackgroundsUnsplashEnabled.Set(true) + defer config.BackgroundsUnsplashEnabled.Set(false) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + t.Run("Search requires auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/search?q=mountain", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Set requires auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Set forbidden for read-only user", func(t *testing.T) { + // testuser15 has read-only access to project 35; CanUpdate fails before + // p.Set reaches Unsplash. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/projects/35/backgrounds/unsplash", `{"id":"abc"}`, humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_background_upload_test.go b/pkg/webtests/huma_background_upload_test.go new file mode 100644 index 000000000..68755f53a --- /dev/null +++ b/pkg/webtests/huma_background_upload_test.go @@ -0,0 +1,151 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartFileBody builds a multipart body with a single file part under the +// given field name. CreateFormFile sets the part Content-Type to +// application/octet-stream, mirroring how many programmatic clients upload. +func multipartFileBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile(fieldName, filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func uploadBackgroundRequest(t *testing.T, e *echo.Echo, project, token string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/api/v2/projects/"+project+"/backgrounds/upload", body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func TestHumaProjectBackgroundUpload(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner uploads a background", func(t *testing.T) { + // testuser1 owns project 1, which starts without a background. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + s := db.NewSession() + defer s.Close() + project := models.Project{ID: 1} + has, err := s.Get(&project) + require.NoError(t, err) + require.True(t, has) + assert.NotZero(t, project.BackgroundFileID, "the upload must set a background file id") + assert.NotEmpty(t, project.BackgroundBlurHash, "the upload must compute a blur hash") + }) + + t.Run("Non-image rejected with 400", func(t *testing.T) { + body, contentType := multipartFileBody(t, "background", "not-an-image.txt", []byte("this is plain text, not an image")) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Read-only user is forbidden", func(t *testing.T) { + // testuser15 has read-only access to project 35. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), body, contentType) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), body, contentType) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Unauthenticated", func(t *testing.T) { + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", "", body, contentType) + require.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Renders as multipart in the OpenAPI spec", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var spec map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec)) + + paths, _ := spec["paths"].(map[string]any) + op, _ := paths["/projects/{project}/backgrounds/upload"].(map[string]any) + put, ok := op["put"].(map[string]any) + require.True(t, ok, "PUT /projects/{project}/backgrounds/upload must be in the spec") + content, _ := put["requestBody"].(map[string]any) + contentMap, _ := content["content"].(map[string]any) + mp, ok := contentMap["multipart/form-data"].(map[string]any) + require.True(t, ok, "background upload must be modeled as multipart/form-data") + schema, _ := mp["schema"].(map[string]any) + props, _ := schema["properties"].(map[string]any) + bgProp, ok := props["background"].(map[string]any) + require.True(t, ok, "the background field must appear in the multipart schema") + assert.Equal(t, "binary", bgProp["format"], "background field must be a binary file in the spec") + }) +} + +// TestHumaProjectBackgroundUploadDisabledByConfig verifies the upload route is +// absent (404) when the upload provider is disabled, even though backgrounds +// themselves are enabled. +func TestHumaProjectBackgroundUploadDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsUploadEnabled.Set(false) + defer config.BackgroundsUploadEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when background upload is disabled; body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_caldav_token_test.go b/pkg/webtests/huma_caldav_token_test.go new file mode 100644 index 000000000..f8e2663ee --- /dev/null +++ b/pkg/webtests/huma_caldav_token_test.go @@ -0,0 +1,165 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaCalDAVToken covers the v2 CalDAV token lifecycle. All calls share one +// echo env because setupTestEnv rotates the JWT signing key per call, which would +// 401 a token minted against an earlier env. +// +// Fixture (pkg/db/fixtures/user_tokens.yml): token id 6, kind 4 (CalDAV), +// belongs to user10. user1 starts with no CalDAV tokens. +func TestHumaCalDAVToken(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + user1Token := humaTokenFor(t, &testuser1) + user10Token := humaTokenFor(t, &testuser10) + + t.Run("Create returns the clear-text token", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var created struct { + ID int64 `json:"id"` + Token string `json:"token"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created), "body: %s", rec.Body.String()) + assert.NotZero(t, created.ID) + assert.NotEmpty(t, created.Token, "the clear-text token must be returned on create") + }) + + t.Run("List omits the token value", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + ids := caldavTokenIDsFromList(t, rec.Body.Bytes()) + assert.NotEmpty(t, ids, "the token created above must show up in the list") + assert.Empty(t, caldavTokenValuesFromList(t, rec.Body.Bytes()), + "the clear-text token must never appear in the list; body: %s", rec.Body.String()) + }) + + t.Run("List is scoped to the current user", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6), + "user10's fixture token #6 must be listed; body: %s", rec.Body.String()) + }) + + t.Run("Delete removes the token", func(t *testing.T) { + listRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, listRec.Code, "body: %s", listRec.Body.String()) + ids := caldavTokenIDsFromList(t, listRec.Body.Bytes()) + require.NotEmpty(t, ids) + + del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/"+strconv.FormatInt(ids[0], 10), "", user1Token, "") + require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String()) + + afterRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, afterRec.Code, "body: %s", afterRec.Body.String()) + assert.NotContains(t, caldavTokenIDsFromList(t, afterRec.Body.Bytes()), ids[0], + "the deleted token must be gone; body: %s", afterRec.Body.String()) + }) + + t.Run("Delete is scoped to the current user", func(t *testing.T) { + // Token #6 belongs to user10; user1 deleting it is a no-op (204), not an error. + del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", user1Token, "") + require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String()) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6), + "user10's token #6 must survive a delete attempt by another user; body: %s", rec.Body.String()) + }) +} + +// TestHumaCalDAVToken_LinkShareForbidden ports v1's implicit guard: a link share +// is not a user, so create / list / delete all refuse it (403). +func TestHumaCalDAVToken_LinkShareForbidden(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + }) + require.NoError(t, err) + + t.Run("create", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("list", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("delete", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func caldavTokenIDsFromList(t *testing.T, body []byte) []int64 { + t.Helper() + items := caldavTokenItemsFromList(t, body) + ids := make([]int64, 0, len(items)) + for _, it := range items { + ids = append(ids, it.ID) + } + return ids +} + +func caldavTokenValuesFromList(t *testing.T, body []byte) []string { + t.Helper() + values := []string{} + for _, it := range caldavTokenItemsFromList(t, body) { + if it.Token != "" { + values = append(values, it.Token) + } + } + return values +} + +func caldavTokenItemsFromList(t *testing.T, body []byte) []struct { + ID int64 `json:"id"` + Token string `json:"token"` +} { + t.Helper() + var resp struct { + Items []struct { + ID int64 `json:"id"` + Token string `json:"token"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "list body must be a paginated envelope: %s", string(body)) + return resp.Items +} diff --git a/pkg/webtests/huma_info_test.go b/pkg/webtests/huma_info_test.go new file mode 100644 index 000000000..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_migration_csv_test.go b/pkg/webtests/huma_migration_csv_test.go new file mode 100644 index 000000000..ff269f46e --- /dev/null +++ b/pkg/webtests/huma_migration_csv_test.go @@ -0,0 +1,125 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const csvTestFile = `Title,Description,Done,Priority +Task 1,Description 1,true,high +Task 2,Description 2,false,low` + +const csvTestConfig = `{"delimiter":",","quote_char":"\"","date_format":"2006-01-02","mapping":[` + + `{"column_index":0,"column_name":"Title","attribute":"title"},` + + `{"column_index":1,"column_name":"Description","attribute":"description"},` + + `{"column_index":2,"column_name":"Done","attribute":"done"},` + + `{"column_index":3,"column_name":"Priority","attribute":"priority"}]}` + +// TestHumaMigrationCSV covers the generic CSV importer's v2 endpoints: +// status, detect, preview and migrate. No v1 webtest exists to mirror. +func TestHumaMigrationCSV(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run("detect returns columns and a suggested mapping", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"columns"`) + assert.Contains(t, rec.Body.String(), `"suggested_mapping"`) + assert.Contains(t, rec.Body.String(), "Title") + }) + + t.Run("preview returns tasks without importing", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/preview", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"tasks"`) + assert.Contains(t, rec.Body.String(), "Task 1") + }) + + t.Run("migrate imports the file", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"Everything was migrated successfully."`) + + // The status now reflects a finished migration. + rec = humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotContains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, + "after migrating, the status must carry a real started_at; body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_BadInput covers the negative paths: missing config, +// malformed config JSON, and an empty file. +func TestHumaMigrationCSV_BadInput(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("missing config is rejected with 422", func(t *testing.T) { + // The config form value is required:"true", so Huma's multipart + // validation refuses the request before the handler runs. + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("malformed config JSON is rejected with 400", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": "{not json"}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("empty file is rejected with a domain error", func(t *testing.T) { + body, contentType := multipartImportBody(t, "empty.csv", []byte{}, map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_Unauthenticated proves all CSV ops require auth. +func TestHumaMigrationCSV_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("detect", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_migration_file_test.go b/pkg/webtests/huma_migration_file_test.go new file mode 100644 index 000000000..9430127aa --- /dev/null +++ b/pkg/webtests/huma_migration_file_test.go @@ -0,0 +1,128 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartImportBody builds a multipart/form-data body with the file under the +// "import" field plus any extra string form values (e.g. the CSV "config"), +// matching the v2 file/CSV migrator form schemas. +func multipartImportBody(t *testing.T, filename string, content []byte, values map[string]string) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile("import", filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + for k, v := range values { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func migrationUploadRequest(t *testing.T, e *echo.Echo, path string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaMigrationFile covers the always-registered file migrators +// (vikunja-file, ticktick, wekan) status + migrate endpoints. There is no v1 +// webtest for these handlers to mirror, so this is the parity baseline. +func TestHumaMigrationFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + // payload is shaped per migrator to hit a *domain* rejection (4xx) rather + // than a raw parse error: a wekan board with no title/cards is "empty", a + // ticktick CSV with no data rows is "empty", and a vikunja-file that isn't + // a zip is rejected as such. (Syntactically-malformed input would surface a + // raw json/zip error that maps to 500 in both v1 and v2 alike.) + migrators := map[string][]byte{ + "vikunja-file": []byte("not a zip archive"), + "ticktick": []byte("Title,Content\n"), + "wekan": []byte(`{"title":"","cards":[]}`), + } + + for name, payload := range migrators { + t.Run(name+" status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // A user who never migrated has a zero-value status. + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run(name+" migrate maps a rejected file to a 4xx domain error", func(t *testing.T) { + // Drives the request through the multipart binding and into the + // migrator, which rejects it with a domain error that + // translateDomainError turns into a 4xx — proving the v2 plumbing + // (bind, run, error bridge) is wired, not the parsing itself. + body, contentType := multipartImportBody(t, "bad."+name, payload, nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/"+name+"/migrate", body, contentType, token) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest, "body: %s", rec.Body.String()) + assert.Less(t, rec.Code, http.StatusInternalServerError, + "a rejected upload must map to a 4xx domain error, not a 500; body: %s", rec.Body.String()) + }) + } +} + +// TestHumaMigrationFile_Unauthenticated proves the file migrator ops require auth. +func TestHumaMigrationFile_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/ticktick/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "x.csv", []byte("x"), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationFile_MissingFile proves the required "import" form field is +// enforced by Huma's multipart validation (422), not a 500. +func TestHumaMigrationFile_MissingFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + require.NoError(t, w.Close()) + + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", buf, w.FormDataContentType(), token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_migration_oauth_test.go b/pkg/webtests/huma_migration_oauth_test.go new file mode 100644 index 000000000..7d15c576a --- /dev/null +++ b/pkg/webtests/huma_migration_oauth_test.go @@ -0,0 +1,153 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupMigrationTestEnv builds a test env with the OAuth migrators enabled so +// their v2 routes are registered (they are gated behind config flags that +// default to false). setupTestEnv resets config to defaults, so the flags must +// be set after it and the router rebuilt. +func setupMigrationTestEnv(t *testing.T) *echo.Echo { + t.Helper() + _, err := setupTestEnv() + require.NoError(t, err) + + // migration.Status is not part of models.GetTables() (pkg/models cannot + // import pkg/modules/migration without a cycle), so SetupTests never syncs + // migration_status. Create it here so the status/migrate handlers can query. + s := db.NewSession() + require.NoError(t, s.Sync2(&migration.Status{})) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + config.MigrationTodoistEnable.Set(true) + config.MigrationTrelloEnable.Set(true) + config.MigrationMicrosoftTodoEnable.Set(true) + t.Cleanup(func() { + config.MigrationTodoistEnable.Set(false) + config.MigrationTrelloEnable.Set(false) + config.MigrationMicrosoftTodoEnable.Set(false) + }) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + return e +} + +// TestHumaMigrationOAuth covers the three OAuth migrators' v2 endpoints. There +// is no v1 webtest for these handlers to mirror, so this is the parity baseline. +func TestHumaMigrationOAuth(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + // The generic registration helper wires the same three ops for every + // migrator, so exercising each name guards against a copy-paste regression. + for _, name := range []string{"todoist", "trello", "microsoft-todo"} { + t.Run(name+" auth url", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/auth", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"url":"http`, "auth url must be returned; body: %s", rec.Body.String()) + }) + + t.Run(name+" status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // A user who never migrated has a zero-value status. + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + } + + t.Run("migrate kicks off the migration", func(t *testing.T) { + events.ClearDispatchedEvents() + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "") + // 200, not the wrapper's POST default 201: this queues a job, it does + // not create a REST resource. + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"Migration was started successfully."`) + events.AssertDispatched(t, &migrationHandler.MigrationRequestedEvent{}) + }) +} + +// TestHumaMigrationOAuth_AlreadyRunning ports v1's guard: starting a migration +// while one is already in progress (started, not finished) is refused with 412. +func TestHumaMigrationOAuth_AlreadyRunning(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + s := db.NewSession() + _, err := s.Insert(&migration.Status{ + UserID: testuser1.ID, + MigratorName: "todoist", + StartedAt: time.Now(), + }) + require.NoError(t, err) + require.NoError(t, s.Commit()) + _ = s.Close() + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"test-code"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) +} + +// TestHumaMigrationOAuth_Unauthenticated proves all three ops require auth. +func TestHumaMigrationOAuth_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("auth", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/migration/todoist/migrate", `{"code":"x"}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationOAuth_Disabled proves a migrator's routes are absent when its +// config flag is off. +func TestHumaMigrationOAuth_Disabled(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + // All migration flags default to false after InitDefaultConfig. + + e := routes.NewEcho() + routes.RegisterRoutes(e) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/todoist/auth", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, + "migration routes must not be registered when the flag is off; body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_task_collection_test.go b/pkg/webtests/huma_task_collection_test.go new file mode 100644 index 000000000..110b04b61 --- /dev/null +++ b/pkg/webtests/huma_task_collection_test.go @@ -0,0 +1,248 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// decodePaginatedTaskItems pulls the items slice out of a Paginated[*Task] +// response so length assertions don't have to regex over nested task JSON. +func decodePaginatedTaskItems(t *testing.T, rec *httptest.ResponseRecorder) []json.RawMessage { + t.Helper() + var body struct { + Items []json.RawMessage `json:"items"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body.Items +} + +// TestHumaTaskCollection covers the v2 task-list endpoints. v2 splits v1's +// single polymorphic /tasks endpoint into flat-task endpoints (always []*Task, +// paginated) and a dedicated buckets-with-tasks endpoint (always []*Bucket). +// Mirrors v1's TestTaskCollection where the surface overlaps. +func TestHumaTaskCollection(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("project-scoped", func(t *testing.T) { + t.Run("returns the project's tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"items":[`) + assert.Contains(t, body, `task #1`) + assert.Contains(t, body, `task #12`) + assert.NotContains(t, body, `task #13`) // other project + assert.NotContains(t, body, `task #14`) + }) + t.Run("forbidden project", func(t *testing.T) { + // Project 2 is inaccessible to user1. + rec := get("/api/v2/projects/2/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("nonexistent project", func(t *testing.T) { + rec := get("/api/v2/projects/99999/tasks") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("pagination", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?page=1&per_page=2") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Len(t, decodePaginatedTaskItems(t, rec), 2, "per_page caps the page to two tasks") + body := rec.Body.String() + assert.Contains(t, body, `"page":1`) + assert.Contains(t, body, `"per_page":2`) + }) + t.Run("filter", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?filter=" + + "start_date%20%3E%20%272018-12-11T03%3A46%3A40%2B00%3A00%27%20%7C%7C%20" + + "end_date%20%3C%20%272018-12-13T11%3A20%3A01%2B00%3A00%27%20%7C%7C%20" + + "due_date%20%3E%20%272018-11-29T14%3A00%3A00%2B00%3A00%27") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.NotContains(t, body, `task #1`) + assert.Contains(t, body, `task #5 `) + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #10`) + }) + t.Run("invalid filter value", func(t *testing.T) { + // ErrInvalidTaskFilterValue carries an explicit 400; only govalidator + // failures map to 422 in v2. + rec := get("/api/v2/projects/1/tasks?filter=due_date%20%3E%20invalid") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("search via q", func(t *testing.T) { + // Only task #6 has the word "unique" in its description. + rec := get("/api/v2/projects/1/tasks?q=unique") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #1`) + assert.NotContains(t, body, `task #2 `) + }) + + t.Run("sort by repeated params", func(t *testing.T) { + // Two sort_by + two order_by prove ,explode binds every value. + rec := get("/api/v2/projects/1/tasks?sort_by=priority&sort_by=id&order_by=desc&order_by=asc") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // task #3 has priority 100, the highest; desc puts it first. + assert.Regexp(t, `"items":\[\{"id":3,`, rec.Body.String()) + }) + + t.Run("invalid sort field", func(t *testing.T) { + // A 400 (not 200) proves sort_by binds: the model validated the field + // and rejected it. ErrInvalidTaskField carries an explicit 400. + rec := get("/api/v2/projects/1/tasks?sort_by=loremipsum") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("cross-project", func(t *testing.T) { + // /tasks returns tasks from every project the user can see, including + // shared ones, but not tasks in projects they have no access to. + rec := get("/api/v2/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #1`) // own project + assert.Contains(t, body, `task #15`) // shared via team readonly + assert.Contains(t, body, `task #21`) // shared via parent project team + assert.NotContains(t, body, `task #13`) // no access + assert.NotContains(t, body, `task #14`) + }) + + t.Run("view-scoped", func(t *testing.T) { + t.Run("list view returns flat tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/views/1/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #1`) + assert.NotContains(t, body, `testbucket`) // not buckets + }) + t.Run("kanban view still returns flat tasks", func(t *testing.T) { + // View 4 is project 1's kanban view. v1 would return buckets here; + // v2's tasks endpoint forces flat tasks. + rec := get("/api/v2/projects/1/views/4/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"items":[`) + assert.Contains(t, body, `task #1`) + assert.NotContains(t, body, `testbucket`) + }) + t.Run("forbidden view", func(t *testing.T) { + // Project 2 (and its view 8) is inaccessible to user1. + rec := get("/api/v2/projects/2/views/8/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("saved filter project", func(t *testing.T) { + // Project -2 maps to saved filter #1, whose stored filter matches the + // date-range tasks. Recurses inside the model. + rec := get("/api/v2/projects/-2/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #5 `) + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #1`) + assert.NotContains(t, body, `task #10`) + }) +} + +// TestHumaTaskCollection_Expand proves expand binds every repeated value +// (,explode) and routes through parseTaskExpand. +func TestHumaTaskCollection_Expand(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("repeated expand applies every value", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?expand=comment_count&expand=reactions") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"comment_count":`) + assert.Contains(t, body, `"reactions":`) + }) + t.Run("invalid expand rejected", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?expand=bogus") + // enum on the query param makes Huma reject before the handler. + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTaskCollection_Buckets covers the dedicated buckets-with-tasks +// endpoint: a kanban view returns []*Bucket with each bucket's tasks populated, +// not paginated. +func TestHumaTaskCollection_Buckets(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("kanban view returns buckets with tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/views/4/buckets/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `testbucket1`) + assert.Contains(t, body, `testbucket2`) + assert.Contains(t, body, `testbucket3`) + assert.NotContains(t, body, `testbucket4`) // belongs to project 2's view + // Tasks are nested under their bucket, not at the top level. + assert.Contains(t, body, `"tasks":[`) + assert.Contains(t, body, `task #1`) + // total counts buckets, not tasks. + assert.Contains(t, body, `"total":3`) + }) + + t.Run("forbidden project", func(t *testing.T) { + rec := get("/api/v2/projects/2/views/8/buckets/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("non-kanban view is a 400, not a 500", func(t *testing.T) { + // View 1 is project 1's list view; it has no bucket configuration, so + // the model returns flat tasks and the handler refuses cleanly. + rec := get("/api/v2/projects/1/views/1/buckets/tasks") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("static tasks segment does not collide with the bucket-update route", func(t *testing.T) { + // PUT .../buckets/{bucket}/tasks exists; GET .../buckets/tasks must hit + // this handler, not parse "tasks" as a bucket id. + rec := get("/api/v2/projects/1/views/4/buckets/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `testbucket1`) + }) +} diff --git a/pkg/webtests/huma_user_deletion_test.go b/pkg/webtests/huma_user_deletion_test.go new file mode 100644 index 000000000..081db594d --- /dev/null +++ b/pkg/webtests/huma_user_deletion_test.go @@ -0,0 +1,194 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + userDeletionRequestPath = "/api/v2/user/deletion/request" + userDeletionConfirmPath = "/api/v2/user/deletion/confirm" + userDeletionCancelPath = "/api/v2/user/deletion/cancel" + // testUserPassword is the plaintext password for every local fixture user. + testUserPassword = "12345678" +) + +// deletionTokenFor reads the cleartext account-deletion token RequestDeletion +// stored for the user. RequestDeletion only mails the token, so the test pulls +// it straight from user_tokens (kind 3 = TokenAccountDeletion). +func deletionTokenFor(t *testing.T, userID int64) string { + t.Helper() + s := db.NewSession() + defer s.Close() + tok := struct { + Token string `xorm:"token"` + }{} + has, err := s.Table("user_tokens"). + Where("user_id = ? AND kind = ?", userID, 3). + Get(&tok) + require.NoError(t, err) + require.True(t, has, "RequestDeletion must have stored a deletion token for user %d", userID) + return tok.Token +} + +func deletionScheduledFor(t *testing.T, userID int64) bool { + t.Helper() + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, userID) + require.NoError(t, err) + return !u.DeletionScheduledAt.IsZero() +} + +// TestHumaUserDeletion ports v1's account-deletion flow (request → confirm → +// cancel) to v2. v1 returned 200/204 with a confirmation message body; v2 +// normalises all three to an empty 204 (the action returns no resource), so +// every success here asserts 204 + empty body. +func TestHumaUserDeletion(t *testing.T) { + t.Run("Request - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "a rejected request must not schedule deletion") + }) + + t.Run("Confirm - invalid token rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":"not-a-real-token"}`, token, "") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID)) + }) + + t.Run("Confirm - missing token is a validation error", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":""}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Request then confirm schedules deletion", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + assert.Empty(t, req.Body.String(), "v2 normalises the request action to an empty 204") + assert.False(t, deletionScheduledFor(t, testuser1.ID), "request alone must not schedule; confirmation does") + + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + assert.Empty(t, confirm.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "confirm must schedule the deletion") + }) + + t.Run("Cancel - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Schedule first so there is something to cancel. + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, cancel.Code, "body: %s", cancel.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "a rejected cancel must leave the deletion scheduled") + }) + + t.Run("Cancel - correct password clears the schedule", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, cancel.Code, "body: %s", cancel.Body.String()) + assert.Empty(t, cancel.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "cancel must clear the scheduled deletion") + }) + + t.Run("Unauthenticated", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + for _, path := range []string{userDeletionRequestPath, userDeletionConfirmPath, userDeletionCancelPath} { + rec := humaRequest(t, e, http.MethodPost, path, `{}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "%s body: %s", path, rec.Body.String()) + } + }) +} + +// TestHumaUserDeletion_LinkShareForbidden asserts a link share — which has no +// account — is refused (403) on every deletion action. +func TestHumaUserDeletion_LinkShareForbidden(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + }) + require.NoError(t, err) + + for _, tc := range []struct { + name string + path string + body string + }{ + {"request", userDeletionRequestPath, `{"password":"` + testUserPassword + `"}`}, + {"confirm", userDeletionConfirmPath, `{"token":"x"}`}, + {"cancel", userDeletionCancelPath, `{"password":"` + testUserPassword + `"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, tc.path, tc.body, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + } +} diff --git a/pkg/webtests/huma_user_search_test.go b/pkg/webtests/huma_user_search_test.go new file mode 100644 index 000000000..821095ea6 --- /dev/null +++ b/pkg/webtests/huma_user_search_test.go @@ -0,0 +1,89 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserSearch covers the global user search. Emails must never leak. +func TestHumaUserSearch(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Search by username", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/users?q=user2", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + usernames, emails := usersFromSearch(t, rec.Body.Bytes()) + assert.Contains(t, usernames, "user2") + for _, em := range emails { + assert.Empty(t, em, "user search must never return email addresses") + } + }) + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/users?q=user2", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaProjectUserSearch covers the per-project user search used for share +// autocomplete. It requires read access to the project. +func TestHumaProjectUserSearch(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Owned project", func(t *testing.T) { + // testuser1 owns project 1. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/users/search", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"items"`) + }) + t.Run("Forbidden - no access", func(t *testing.T) { + // project 2 is owned by user3; testuser1 has no access. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/2/users/search", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Nonexistent project", func(t *testing.T) { + // CanRead surfaces ErrProjectDoesNotExist (404), not a bare forbidden. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/99999/users/search", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func usersFromSearch(t *testing.T, body []byte) (usernames, emails []string) { + t.Helper() + var resp struct { + Items []struct { + Username string `json:"username"` + Email string `json:"email"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "search body must be a paginated envelope: %s", string(body)) + for _, it := range resp.Items { + usernames = append(usernames, it.Username) + emails = append(emails, it.Email) + } + return usernames, emails +} diff --git a/pkg/webtests/huma_user_settings_test.go b/pkg/webtests/huma_user_settings_test.go new file mode 100644 index 000000000..24e7469f3 --- /dev/null +++ b/pkg/webtests/huma_user_settings_test.go @@ -0,0 +1,195 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All subtests in a Test* func share one env: setupTestEnv rotates the JWT +// secret per call, so a token must be issued from the same env it's used +// against. Where a subtest mutates the user, later subtests account for it. + +func TestHumaUserShow(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + body := rec.Body.String() + assert.Contains(t, body, `"id":1`) + assert.Contains(t, body, `"username":"user1"`) + // Like v1, /user does not disclose the email (GetUserByID strips it); the + // json:"email,omitempty" tag then drops the field entirely. + assert.NotContains(t, body, `"email":""`) + // Computed account facts v1 returned alongside the user object. + assert.Contains(t, body, `"auth_provider":"local"`) + assert.Contains(t, body, `"is_local_user":true`) + assert.Contains(t, body, `"is_admin":false`) + // The nested settings use the shared models.UserGeneralSettings shape. + assert.Contains(t, body, `"settings":`) + assert.Contains(t, body, `"frontend_settings":`) + assert.Contains(t, body, `"extra_settings_links":`) + + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} + +func TestHumaUserChangePassword(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong old password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"invalid","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Empty old password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("New password too short", func(t *testing.T) { + // v2 maps govalidator failures (bcrypt_password) to 422, not v1's 412. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"1234567"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal - run last, it changes the password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"123456789"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The password was updated successfully.") + }) +} + +func TestHumaUserUpdateEmail(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"invalid"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Missing new email", func(t *testing.T) { + // new_email carries valid:"...,required"; v2 maps the failure to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"password":"12345678"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"12345678"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "confirm your email address") + }) +} + +func TestHumaUserUpdateSettings(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"name":"New Name","week_start":1,"overdue_tasks_reminders_time":"10:00","timezone":"Europe/Berlin"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The settings were updated successfully.") + + // The change is observable through user-show. + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + assert.Contains(t, show.Body.String(), `"name":"New Name"`) + }) + t.Run("Frontend settings round-trip as arbitrary JSON", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"overdue_tasks_reminders_time":"09:00","frontend_settings":{"color_schema":"dark","nested":{"a":1}}}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + var resp struct { + Settings struct { + FrontendSettings map[string]any `json:"frontend_settings"` + } `json:"settings"` + } + require.NoError(t, json.Unmarshal(show.Body.Bytes(), &resp)) + assert.Equal(t, "dark", resp.Settings.FrontendSettings["color_schema"]) + }) + t.Run("Invalid week_start", func(t *testing.T) { + // week_start carries valid:"range(0|6)"; out of range maps to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"week_start":9,"overdue_tasks_reminders_time":"09:00"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserAvatarProvider(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Get", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":`) + }) + t.Run("Set then get reflects the change", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"initials"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":"initials"`) + + get := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, get.Code) + assert.Contains(t, get.Body.String(), `"avatar_provider":"initials"`) + }) + t.Run("Invalid provider", func(t *testing.T) { + // UpdateUser rejects unknown providers with ErrInvalidAvatarProvider (412). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"nonsense"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserTimezones(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/timezones", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var zones []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &zones)) + assert.NotEmpty(t, zones) + assert.Contains(t, zones, "Europe/Berlin") +} diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go new file mode 100644 index 000000000..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()) + } + }) +} diff --git a/pkg/webtests/huma_user_webhook_test.go b/pkg/webtests/huma_user_webhook_test.go new file mode 100644 index 000000000..8c061a6ff --- /dev/null +++ b/pkg/webtests/huma_user_webhook_test.go @@ -0,0 +1,189 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserWebhook ports the v1 user-webhook coverage (the per-user sibling of +// the project webhooks tested in TestHumaWebhook) to /api/v2. User webhooks live +// at /user/settings/webhooks{,/{webhook}} — list, events, create, update, delete; +// there is deliberately no ReadOne (webhooks carry credentials). +// +// Ownership gradient — a user webhook is owned by its UserID, and every Can* boils +// down to "are you that user". Fixtures: webhooks #6/#7 belong to user6, #8 to +// user1. The actor is user6 (not user1): the user-webhook e2e tests dispatch +// user-directed events only for users 1 and 2, so user6-owned fixtures never fire +// there. The point of these cases is that user6 sees and mutates only their own +// webhooks and is forbidden on user1's. +func TestHumaUserWebhook(t *testing.T) { + // availableWebhookEvents / userDirectedWebhookEvents are populated by + // RegisterListeners(), which the webtests harness does not call. Register the + // one user-directed event the fixtures and these cases use so Create/Update + // validation accepts it. + models.RegisterUserDirectedEventForWebhook(&models.TaskReminderFiredEvent{}) + + owner := webHandlerTestV2{ + user: &testuser6, + basePath: "/api/v2/user/settings/webhooks", + idParam: "webhook", + t: t, + } + require.NoError(t, owner.ensureEnv()) + + t.Run("ReadAll", func(t *testing.T) { + t.Run("Normal - sees only own webhooks", func(t *testing.T) { + rec, err := owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + ids := webhookIDsFromReadAll(t, rec.Body.Bytes()) + // user6 owns #6 and #7; #8 belongs to user1 and must not appear. + assert.ElementsMatch(t, []int64{6, 7}, ids, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"target_url"`) + }) + t.Run("Secret and basic auth credentials are never exposed", func(t *testing.T) { + rec, err := owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.NotContains(t, rec.Body.String(), `uwh-secret-fixture`) + assert.NotContains(t, rec.Body.String(), `uwh-basicauth-user`) + assert.NotContains(t, rec.Body.String(), `uwh-basicauth-pass`) + }) + }) + + t.Run("Events", func(t *testing.T) { + // The events route reports only user-directed events. task.reminder.fired + // is registered above; task.updated (project-only) must not be listed. + token := humaTokenFor(t, &testuser6) + rec := humaRequest(t, owner.e, http.MethodGet, "/api/v2/user/settings/webhooks/events", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + var events []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events), "body: %s", rec.Body.String()) + assert.Contains(t, events, "task.reminder.fired") + assert.NotContains(t, events, "task.updated") + }) + + t.Run("Create", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/new","events":["task.reminder.fired"]}`) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Contains(t, rec.Body.String(), `"target_url":"https://example.com/new"`) + // Ownership comes from the token, not the body. + assert.Contains(t, rec.Body.String(), `"user_id":6`) + }) + t.Run("Secret and basic auth are not echoed back", func(t *testing.T) { + rec, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/secret","events":["task.reminder.fired"],"secret":"top-secret","basic_auth_user":"u","basic_auth_password":"p"}`) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.NotContains(t, rec.Body.String(), `top-secret`) + assert.NotContains(t, rec.Body.String(), `"basic_auth_user":"u"`) + assert.NotContains(t, rec.Body.String(), `"basic_auth_password":"p"`) + }) + t.Run("Non user-directed event rejected", func(t *testing.T) { + // task.updated is a project event, not user-directed; Create rejects it + // → InvalidFieldError, surfaced as 422 on v2. + _, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/x","events":["task.updated"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err)) + }) + t.Run("Missing target url", func(t *testing.T) { + _, err := owner.testCreateWithUser(nil, nil, `{"events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err)) + }) + }) + + t.Run("Update", func(t *testing.T) { + t.Run("Normal - only events change", func(t *testing.T) { + rec, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "6"}, + `{"events":["task.reminder.fired"],"target_url":"https://example.com/ignored"}`) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"id":6`) + + rec, err = owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `https://example.com/user-webhook-fixture`, + "target_url must stay the fixture value; only events are mutable") + assert.NotContains(t, rec.Body.String(), `https://example.com/ignored`) + }) + t.Run("Cannot update another user's webhook", func(t *testing.T) { + // webhook #8 belongs to user1; canDoWebhook resolves ownership from the + // stored row, so user6 is forbidden regardless of the URL. + _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "8"}, + `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Nonexisting", func(t *testing.T) { + // canDoWebhook returns false for a missing webhook → 403, not 404. + _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "9999"}, + `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Cannot delete another user's webhook", func(t *testing.T) { + _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "8"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "9999"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Normal", func(t *testing.T) { + rec, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "7"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + }) + }) +} + +// TestHumaUserWebhook_DisabledByConfig confirms RegisterUserWebhookRoutes skips +// the resource when webhooks.enabled is false, so the v2 user-webhook routes 404 +// rather than running with the feature toggled off. +func TestHumaUserWebhook_DisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.WebhooksEnabled.Set(false) + defer config.WebhooksEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/webhooks", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when webhooks are disabled; body: %s", rec.Body.String()) +} diff --git a/pkg/webtests/huma_webhook_event_test.go b/pkg/webtests/huma_webhook_event_test.go new file mode 100644 index 000000000..6db2dbc5d --- /dev/null +++ b/pkg/webtests/huma_webhook_event_test.go @@ -0,0 +1,48 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaWebhookEvents covers the available-webhook-events listing. The route +// is only registered when webhooks are enabled (the test config default). +func TestHumaWebhookEvents(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Returns the events", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/webhooks/events", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var events []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events)) + assert.ElementsMatch(t, models.GetAvailableWebhookEvents(), events) + }) + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/webhooks/events", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/veans/go.mod b/veans/go.mod index c025994dc..88e1cfca7 100644 --- a/veans/go.mod +++ b/veans/go.mod @@ -3,9 +3,12 @@ module code.vikunja.io/veans go 1.25.0 require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b github.com/magefile/mage v1.17.2 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/sahilm/fuzzy v0.1.2 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/sys v0.43.0 @@ -14,8 +17,24 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/veans/go.sum b/veans/go.sum index 3e0c9d612..5490b412a 100644 --- a/veans/go.sum +++ b/veans/go.sum @@ -1,3 +1,17 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= @@ -5,17 +19,40 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= +github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -24,14 +61,22 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/veans/internal/bootstrap/bootstrap.go b/veans/internal/bootstrap/bootstrap.go index 3a9381e6e..a1251be89 100644 --- a/veans/internal/bootstrap/bootstrap.go +++ b/veans/internal/bootstrap/bootstrap.go @@ -31,7 +31,6 @@ import ( "io" "os" "regexp" - "sort" "strconv" "strings" @@ -40,6 +39,7 @@ import ( "code.vikunja.io/veans/internal/config" "code.vikunja.io/veans/internal/credentials" "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/picker" "code.vikunja.io/veans/internal/status" ) @@ -388,44 +388,29 @@ func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompte } active = append(active, pr) } - sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title }) - - // The "create a new project" option sits at len(active)+1 in the menu; - // when the user has nothing to pick from, it's the only choice. - createIdx := len(active) + 1 if len(active) == 0 { fmt.Fprintln(out, "No projects yet — let's create one.") return createProject(ctx, c, p, out) } - fmt.Fprintln(out, "Available projects:") - for i, pr := range active { - ident := pr.Identifier - if ident == "" { - ident = "(no identifier)" - } - fmt.Fprintf(out, " [%d] #%d %s — %s\n", i+1, pr.ID, pr.Title, ident) - } - fmt.Fprintf(out, " [%d] Create a new project\n", createIdx) - - choice, err := p.ReadLine("Pick a project [1]: ") - if err != nil { + // picker.Pick reads os.Stdin directly via bubbletea. The prompter's + // buffered reader is idle here (all earlier prompts blocked at a + // newline in canonical mode), so there's no buffered input to lose; + // the terminal is restored to canonical mode when Pick returns. + res, err := picker.Pick(active) + switch { + case errors.Is(err, picker.ErrCanceled): + return nil, output.New(output.CodeValidation, "project selection canceled") + case errors.Is(err, picker.ErrNotATerminal): + return nil, output.New(output.CodeValidation, "not a terminal — pass --project ") + case err != nil: return nil, err } - choice = strings.TrimSpace(choice) - idx := 1 - if choice != "" { - v, err := strconv.Atoi(choice) - if err != nil || v < 1 || v > createIdx { - return nil, output.New(output.CodeValidation, "invalid project choice %q", choice) - } - idx = v - } - if idx == createIdx { + if res.CreateNew { return createProject(ctx, c, p, out) } - return active[idx-1], nil + return res.Project, nil } // createProject prompts for the new project's title and identifier and diff --git a/veans/internal/client/types.go b/veans/internal/client/types.go index e661f5bc2..edcabc766 100644 --- a/veans/internal/client/types.go +++ b/veans/internal/client/types.go @@ -46,11 +46,13 @@ type BotUserCreate struct { // Project mirrors pkg/models/project.Project. type Project struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Identifier string `json:"identifier,omitempty"` - IsArchived bool `json:"is_archived,omitempty"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Identifier string `json:"identifier,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + ParentProjectID int64 `json:"parent_project_id,omitempty"` + Position float64 `json:"position,omitempty"` } // ProjectView is a saved view (Kanban/List/Gantt/Table) on a project. diff --git a/veans/internal/picker/flatten.go b/veans/internal/picker/flatten.go new file mode 100644 index 000000000..bb9561a4a --- /dev/null +++ b/veans/internal/picker/flatten.go @@ -0,0 +1,118 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "unicode/utf8" + + "code.vikunja.io/veans/internal/client" + "github.com/sahilm/fuzzy" +) + +// row is one visible line in the picker. matches holds rune indexes into the +// title for highlighting; dimmed rows are kept only as context for a matching +// descendant and are skipped by the cursor. +type row struct { + project *client.Project + depth int + dimmed bool + matches []int +} + +// flatten walks the forest depth-first into a render list. An empty query +// returns every node undimmed. A non-empty query fuzzy-matches each title +// (case-insensitive, via sahilm/fuzzy) and keeps a node iff it matches or any +// descendant is kept; a kept-but-non-matching node is dimmed context. +func flatten(forest []*node, query string) []row { + if query == "" { + var rows []row + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + rows = append(rows, row{project: n.project, depth: n.depth}) + walk(n.children) + } + } + walk(forest) + return rows + } + + var rows []row + var walk func(n *node) bool + walk = func(n *node) bool { + matches, matched := matchTitle(query, n.project.Title) + + start := len(rows) + rows = append(rows, row{}) // placeholder; finalized only if kept + + descendantKept := false + for _, c := range n.children { + if walk(c) { + descendantKept = true + } + } + + if !matched && !descendantKept { + rows = rows[:start] + return false + } + rows[start] = row{ + project: n.project, + depth: n.depth, + dimmed: !matched, + matches: matches, + } + return true + } + + for _, n := range forest { + walk(n) + } + return rows +} + +// matchTitle reports whether title fuzzy-matches query and, if so, the rune +// indexes of the matched characters. sahilm/fuzzy reports byte indexes, so we +// translate them to rune offsets for correct highlighting of multibyte titles. +func matchTitle(query, title string) (runeMatches []int, matched bool) { + results := fuzzy.Find(query, []string{title}) + if len(results) == 0 { + return nil, false + } + return byteToRuneIndexes(title, results[0].MatchedIndexes), true +} + +func byteToRuneIndexes(s string, byteIdx []int) []int { + if len(byteIdx) == 0 { + return nil + } + want := make(map[int]bool, len(byteIdx)) + for _, b := range byteIdx { + want[b] = true + } + out := make([]int, 0, len(byteIdx)) + runePos := 0 + for b := 0; b < len(s); { + if want[b] { + out = append(out, runePos) + } + _, size := utf8.DecodeRuneInString(s[b:]) + b += size + runePos++ + } + return out +} diff --git a/veans/internal/picker/flatten_test.go b/veans/internal/picker/flatten_test.go new file mode 100644 index 000000000..fe60411fb --- /dev/null +++ b/veans/internal/picker/flatten_test.go @@ -0,0 +1,122 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "reflect" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func sampleForest() []*node { + return buildForest([]*client.Project{ + proj(1, 0, 1, "Backend"), + proj(2, 1, 1, "Frontend"), + proj(3, 1, 2, "Database"), + proj(4, 0, 2, "Marketing"), + }) +} + +func rowTitles(rows []row) []string { + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r.project.Title + } + return out +} + +func TestFlatten_EmptyQuery(t *testing.T) { + rows := flatten(sampleForest(), "") + wantTitles := []string{"Backend", "Frontend", "Database", "Marketing"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } + wantDepths := []int{0, 1, 1, 0} + for i, r := range rows { + if r.depth != wantDepths[i] { + t.Errorf("row %d depth = %d, want %d", i, r.depth, wantDepths[i]) + } + if r.dimmed { + t.Errorf("row %d should not be dimmed on empty query", i) + } + if r.matches != nil { + t.Errorf("row %d should have nil matches on empty query", i) + } + } +} + +func TestFlatten_DeepChildSurfacesDimmedAncestor(t *testing.T) { + // "Frontend" is a child of "Backend"; matching it must keep "Backend" + // as a dimmed context row. + rows := flatten(sampleForest(), "frontend") + wantTitles := []string{"Backend", "Frontend"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } + if !rows[0].dimmed { + t.Error("ancestor Backend should be dimmed (context only)") + } + if rows[1].dimmed { + t.Error("matching Frontend should not be dimmed") + } +} + +func TestFlatten_MatchingNodeCarriesMatchIndexes(t *testing.T) { + rows := flatten(sampleForest(), "front") + var frontend *row + for i := range rows { + if rows[i].project.Title == "Frontend" { + frontend = &rows[i] + } + } + if frontend == nil { + t.Fatal("Frontend row missing") + } + // "front" should match the leading runes of "Frontend". + want := []int{0, 1, 2, 3, 4} + if !reflect.DeepEqual(frontend.matches, want) { + t.Fatalf("matches: got %v, want %v", frontend.matches, want) + } +} + +func TestFlatten_NonMatchingSiblingsDropped(t *testing.T) { + // Matching "Marketing" must not pull in "Backend"/"Frontend"/"Database". + rows := flatten(sampleForest(), "marketing") + wantTitles := []string{"Marketing"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } +} + +func TestFlatten_NoMatchYieldsEmpty(t *testing.T) { + rows := flatten(sampleForest(), "zzzzz") + if len(rows) != 0 { + t.Fatalf("expected no rows, got %v", rowTitles(rows)) + } +} + +func TestFlatten_CaseInsensitive(t *testing.T) { + lower := flatten(sampleForest(), "backend") + upper := flatten(sampleForest(), "BACKEND") + if !reflect.DeepEqual(rowTitles(lower), rowTitles(upper)) { + t.Fatalf("case sensitivity differs: %v vs %v", rowTitles(lower), rowTitles(upper)) + } + if len(lower) == 0 { + t.Fatal("expected at least one match for 'backend'") + } +} diff --git a/veans/internal/picker/model.go b/veans/internal/picker/model.go new file mode 100644 index 000000000..4139d0f14 --- /dev/null +++ b/veans/internal/picker/model.go @@ -0,0 +1,238 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "fmt" + "strings" + + "code.vikunja.io/veans/internal/client" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const maxVisibleRows = 12 + +var ( + dimStyle = lipgloss.NewStyle().Faint(true) + matchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + cursorMark = "❯" +) + +// model is the bubbletea state for the picker. The pinned "create a new +// project" entry is the trailing row with a nil project; it is always +// selectable and never filtered out. +type model struct { + forest []*node + query string + rows []row + cursor int // index into rows, always on a selectable row + offset int // first visible row index + + result *client.Project + createNew bool + canceled bool +} + +func newModel(forest []*node) *model { + m := &model{forest: forest} + m.recompute() + return m +} + +func (m *model) recompute() { + rows := flatten(m.forest, m.query) + rows = append(rows, row{project: nil}) // pinned create row + m.rows = rows + // recompute only runs when the query changes (or on init), so snap to the + // first match. Keeping the old cursor could leave it on the trailing create + // row after the list narrows, making Enter create a project instead of + // picking the visible match. + m.cursor = 0 + m.offset = 0 + m.clampCursor() + m.ensureVisible() +} + +func (r row) isCreate() bool { return r.project == nil } + +func (r row) selectable() bool { return r.isCreate() || !r.dimmed } + +func (m *model) clampCursor() { + if m.cursor >= len(m.rows) { + m.cursor = len(m.rows) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.rows[m.cursor].selectable() { + return + } + // Snap to the nearest selectable row, preferring downward. + for i := m.cursor; i < len(m.rows); i++ { + if m.rows[i].selectable() { + m.cursor = i + return + } + } + for i := m.cursor; i >= 0; i-- { + if m.rows[i].selectable() { + m.cursor = i + return + } + } +} + +func (m *model) moveCursor(delta int) { + i := m.cursor + for { + i += delta + if i < 0 || i >= len(m.rows) { + return + } + if m.rows[i].selectable() { + m.cursor = i + m.ensureVisible() + return + } + } +} + +func (m *model) ensureVisible() { + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+maxVisibleRows { + m.offset = m.cursor - maxVisibleRows + 1 + } + if m.offset < 0 { + m.offset = 0 + } +} + +func (m *model) Init() tea.Cmd { return nil } + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "ctrl+c", "esc": + m.canceled = true + return m, tea.Quit + case "enter": + sel := m.rows[m.cursor] + if sel.isCreate() { + m.createNew = true + } else { + m.result = sel.project + } + return m, tea.Quit + case "up": + m.moveCursor(-1) + case "down": + m.moveCursor(1) + case "backspace": + if m.query != "" { + r := []rune(m.query) + m.query = string(r[:len(r)-1]) + m.recompute() + } + default: + // Treat printable runes and space as query input. + if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { + runes := key.Runes + // KeySpace is not guaranteed to populate key.Runes; substitute a + // literal space so multi-word fuzzy queries still work. + if key.Type == tea.KeySpace && len(runes) == 0 { + runes = []rune{' '} + } + m.query += string(runes) + m.recompute() + } + } + return m, nil +} + +func (m *model) View() string { + var b strings.Builder + fmt.Fprintf(&b, "> %s\n", m.query) + + end := min(m.offset+maxVisibleRows, len(m.rows)) + for i := m.offset; i < end; i++ { + b.WriteString(m.renderRow(i)) + b.WriteByte('\n') + } + + fmt.Fprintf(&b, "%d/%d ↑↓ move ⏎ pick esc cancel\n", m.cursor+1, len(m.rows)) + return b.String() +} + +func (m *model) renderRow(i int) string { + r := m.rows[i] + + marker := " " + if i == m.cursor { + marker = cursorMark + " " + } + + indent := strings.Repeat(" ", r.depth) + + var label string + switch { + case r.isCreate(): + label = "Create a new project" + case r.dimmed: + label = dimStyle.Render(r.project.Title + projectSuffix(r.project)) + default: + label = highlight(r.project.Title, r.matches) + dimStyle.Render(projectSuffix(r.project)) + } + + return marker + indent + label +} + +// projectSuffix is the dimmed metadata appended to a project row. Titles aren't +// unique in Vikunja, so the id (and identifier when set) keeps duplicate-titled +// projects distinguishable during init. +func projectSuffix(p *client.Project) string { + s := fmt.Sprintf(" #%d", p.ID) + if p.Identifier != "" { + s += " " + p.Identifier + } + return s +} + +// highlight bolds the matched runes of title. matches are rune indexes. +func highlight(title string, matches []int) string { + if len(matches) == 0 { + return title + } + matchSet := make(map[int]bool, len(matches)) + for _, idx := range matches { + matchSet[idx] = true + } + var b strings.Builder + for i, r := range []rune(title) { + if matchSet[i] { + b.WriteString(matchStyle.Render(string(r))) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/veans/internal/picker/picker.go b/veans/internal/picker/picker.go new file mode 100644 index 000000000..ee373e1a4 --- /dev/null +++ b/veans/internal/picker/picker.go @@ -0,0 +1,71 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "errors" + "fmt" + "os" + + "code.vikunja.io/veans/internal/client" + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" +) + +// Result is what the user chose: an existing project or the create-new action. +type Result struct { + Project *client.Project + CreateNew bool +} + +var ( + // ErrCanceled is returned when the user dismisses the picker (Esc / Ctrl-C). + ErrCanceled = errors.New("selection canceled") + // ErrNotATerminal is returned when stdin is not a TTY, so the interactive + // picker can't run — callers should fall back to `--project `. + ErrNotATerminal = errors.New("not a terminal") +) + +// Pick runs the interactive project picker over projects and returns the +// user's choice. Output is written to stderr (prompts go to stderr by +// convention) and the terminal is left in canonical mode on exit. +func Pick(projects []*client.Project) (Result, error) { + // The picker reads stdin and draws to stderr; both must be a TTY, else it + // would run invisibly (e.g. stderr redirected to a file) and look hung. + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { + return Result{}, ErrNotATerminal + } + + m := newModel(buildForest(projects)) + prog := tea.NewProgram(m, tea.WithInput(os.Stdin), tea.WithOutput(os.Stderr)) + final, err := prog.Run() + if err != nil { + return Result{}, fmt.Errorf("run project picker: %w", err) + } + + fm, ok := final.(*model) + if !ok { + return Result{}, fmt.Errorf("project picker returned unexpected model type %T", final) + } + if fm.canceled { + return Result{}, ErrCanceled + } + if fm.createNew { + return Result{CreateNew: true}, nil + } + return Result{Project: fm.result}, nil +} diff --git a/veans/internal/picker/tree.go b/veans/internal/picker/tree.go new file mode 100644 index 000000000..1835badea --- /dev/null +++ b/veans/internal/picker/tree.go @@ -0,0 +1,78 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package picker renders an interactive, hierarchical, fuzzy-searchable +// project picker for `veans init`. The pure tree/flatten logic is split from +// the bubbletea TUI so it stays unit-testable. +package picker + +import ( + "sort" + + "code.vikunja.io/veans/internal/client" +) + +type node struct { + project *client.Project + depth int + children []*node +} + +// buildForest turns a flat project slice into a depth-annotated forest. A +// project whose ParentProjectID is absent from the input becomes a root — +// this mirrors the frontend's effective-parent behavior so children of a +// hidden or archived parent don't vanish. Siblings are ordered by Position, +// tie-broken by Title. +func buildForest(projects []*client.Project) []*node { + byID := make(map[int64]*node, len(projects)) + for _, p := range projects { + if p == nil { + continue + } + byID[p.ID] = &node{project: p} + } + + var roots []*node + for _, p := range projects { + if p == nil { + continue + } + n := byID[p.ID] + parent, ok := byID[p.ParentProjectID] + if p.ParentProjectID == 0 || !ok { + roots = append(roots, n) + continue + } + parent.children = append(parent.children, n) + } + + sortAndAssignDepth(roots, 0) + return roots +} + +func sortAndAssignDepth(nodes []*node, depth int) { + sort.SliceStable(nodes, func(i, j int) bool { + a, b := nodes[i].project, nodes[j].project + if a.Position != b.Position { + return a.Position < b.Position + } + return a.Title < b.Title + }) + for _, n := range nodes { + n.depth = depth + sortAndAssignDepth(n.children, depth+1) + } +} diff --git a/veans/internal/picker/tree_test.go b/veans/internal/picker/tree_test.go new file mode 100644 index 000000000..d2874f949 --- /dev/null +++ b/veans/internal/picker/tree_test.go @@ -0,0 +1,129 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "reflect" + "strconv" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func proj(id, parent int64, pos float64, title string) *client.Project { + return &client.Project{ID: id, ParentProjectID: parent, Position: pos, Title: title} +} + +// titlesWithDepth flattens a forest depth-first into "title@depth" tokens. +func titlesWithDepth(forest []*node) []string { + var out []string + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + out = append(out, n.project.Title+"@"+strconv.Itoa(n.depth)) + walk(n.children) + } + } + walk(forest) + return out +} + +func TestBuildForest_SingleRoot(t *testing.T) { + forest := buildForest([]*client.Project{proj(1, 0, 1, "Root")}) + got := titlesWithDepth(forest) + want := []string{"Root@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_Nested(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "Root"), + proj(2, 1, 1, "Child"), + proj(3, 2, 1, "Grandchild"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "Child@1", "Grandchild@2"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_MultipleRoots(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 2, "Beta"), + proj(2, 0, 1, "Alpha"), + }) + got := titlesWithDepth(forest) + // Roots are sorted by position: Alpha (pos 1) before Beta (pos 2). + want := []string{"Alpha@0", "Beta@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_SiblingOrderPositionThenTitle(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 0, "Root"), + proj(2, 1, 2, "C"), + proj(3, 1, 1, "B"), + // same position as B — tie-break by title puts A before B. + proj(4, 1, 1, "A"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "A@1", "B@1", "C@1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_OrphanBecomesRoot(t *testing.T) { + // Parent 99 is not in the input set — child should surface as a root. + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "Root"), + proj(2, 99, 2, "Orphan"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "Orphan@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_DepthCorrectness(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "A"), + proj(2, 1, 1, "B"), + proj(3, 2, 1, "C"), + proj(4, 3, 1, "D"), + }) + depthOf := map[string]int{} + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + depthOf[n.project.Title] = n.depth + walk(n.children) + } + } + walk(forest) + for title, want := range map[string]int{"A": 0, "B": 1, "C": 2, "D": 3} { + if depthOf[title] != want { + t.Errorf("depth of %q = %d, want %d", title, depthOf[title], want) + } + } +}