Merge branch 'main' into feat/blocked-tasks-blocking

This commit is contained in:
Harsh Patel 2026-06-14 21:24:17 +05:30 committed by GitHub
commit 8708892970
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
140 changed files with 10207 additions and 1902 deletions

View File

@ -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'

View File

@ -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": [

View File

@ -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",

View File

@ -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:

View File

@ -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;

View File

@ -2,6 +2,7 @@
<Modal
:enabled="active"
:overflow="isNewTaskCommand"
variant="top"
@close="closeQuickActions"
>
<div
@ -704,15 +705,16 @@ function reset() {
<style lang="scss" scoped>
.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;

View File

@ -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"

View File

@ -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])

View File

@ -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": "секунда|секунд(и)",

View File

@ -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'),

View File

@ -4,6 +4,10 @@
}
}
.is-pulled-right {
.is-pulled-end {
float: right !important;
}
[dir="rtl"] .is-pulled-end {
float: left !important;
}

View File

@ -5,7 +5,7 @@
>
<XButton
:to="{name:'labels.create'}"
class="is-pulled-right"
class="is-pulled-end"
icon="plus"
>
{{ $t('label.create.header') }}

View File

@ -5,7 +5,7 @@
>
<XButton
:to="{name:'teams.create'}"
class="is-pulled-right"
class="is-pulled-end"
icon="plus"
>
{{ $t('team.create.title') }}

254
pkg/audit/audit_test.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}

154
pkg/audit/entry.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
// 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} }

76
pkg/audit/listener.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}
}
}

211
pkg/audit/writer.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}
}
}

View File

@ -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 <log.path>/audit.log, resolved at init
AuditRotationMaxSizeMB.setDefault(100)
AuditRotationMaxAge.setDefault(30)
// Outgoing Requests
OutgoingRequestsAllowNonRoutableIPs.setDefault(false)
OutgoingRequestsTimeoutSeconds.setDefault(30)

View File

@ -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

View File

@ -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)
}
}

View File

@ -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"))

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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()

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View File

@ -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.

View File

@ -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"
}

View File

@ -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},
}
})
}
//////

View File

@ -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{}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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{}{

View File

@ -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)

View File

@ -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
}

View File

@ -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{})
})
}

View File

@ -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"`

130
pkg/models/user_settings.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 {

View File

@ -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

View File

@ -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.

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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{}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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 <p> 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)

View File

@ -711,3 +711,55 @@ func TestConversationalMail(t *testing.T) {
assert.Contains(t, headerLine1, "(Project &gt; 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 := `</a><img src="` + remoteSrc + `" style="position:absolute;width:100%;height:100%"><a>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(`<p>hi</p><img src="`+remoteSrc+`">`).
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,")
})
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
// 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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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 := &microsofttodo.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
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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
}

View File

@ -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 := &microsofttodo.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())
}

View File

@ -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)
}

View File

@ -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."})
}

View File

@ -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
}

View File

@ -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."})
}

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}

View File

@ -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."})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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 &registerUserBody{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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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()

51
pkg/routes/api/v2/info.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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 &microsofttodo.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
}

112
pkg/routes/api/v2/oauth.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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()
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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
}

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More