Merge branch 'main' into bulk-task-editing
This commit is contained in:
commit
95ce078402
|
|
@ -26,6 +26,7 @@ docs/resources/
|
|||
pkg/static/templates_vfsdata.go
|
||||
files/
|
||||
!pkg/files/
|
||||
!pkg/web/files/
|
||||
vikunja-dump*
|
||||
vendor/
|
||||
os-packages/
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@ linters:
|
|||
- revive
|
||||
path: pkg/utils/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- revive
|
||||
path: pkg/routes/api/shared/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- revive
|
||||
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.10.2",
|
||||
"electron": "40.10.3",
|
||||
"electron-builder": "26.15.2",
|
||||
"unzipper": "0.12.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ importers:
|
|||
version: 5.2.1
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 40.10.2
|
||||
version: 40.10.2
|
||||
specifier: 40.10.3
|
||||
version: 40.10.3
|
||||
electron-builder:
|
||||
specifier: 26.15.2
|
||||
version: 26.15.2(electron-builder-squirrel-windows@24.13.3)
|
||||
|
|
@ -39,6 +39,10 @@ packages:
|
|||
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
|
||||
engines: {node: '>= 8.9.0'}
|
||||
|
||||
'@electron-internal/extract-zip@1.0.2':
|
||||
resolution: {integrity: sha512-VJuNETNPEhrmQEZezeTZO5TZMV+dobBRyJ7zHjGJWIhMS7m7W1UeClt69u4hkUxv9ZZVxuli/E9Yvc4gDNHGsg==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@electron/asar@3.4.1':
|
||||
resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==}
|
||||
engines: {node: '>=10.12.0'}
|
||||
|
|
@ -48,14 +52,14 @@ packages:
|
|||
resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==}
|
||||
hasBin: true
|
||||
|
||||
'@electron/get@2.0.3':
|
||||
resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@electron/get@3.1.0':
|
||||
resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@electron/get@5.0.0':
|
||||
resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@electron/notarize@2.2.1':
|
||||
resolution: {integrity: sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
|
@ -169,9 +173,6 @@ packages:
|
|||
'@types/responselike@1.0.3':
|
||||
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
'@xmldom/xmldom@0.8.13':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
|
@ -532,9 +533,9 @@ packages:
|
|||
electron-publish@26.15.1:
|
||||
resolution: {integrity: sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==}
|
||||
|
||||
electron@40.10.2:
|
||||
resolution: {integrity: sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
electron@40.10.3:
|
||||
resolution: {integrity: sha512-DdWRsHm4j5wH9TMcfnB2Dqx44G/6BgLKSG/oeRe9kS60pfqCUwzUkHk0ClwvZzBVXtJ1kcdkHVRrJsl1ooKp+g==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
|
|
@ -554,6 +555,10 @@ packages:
|
|||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
env-paths@3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
err-code@2.0.3:
|
||||
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
|
||||
|
||||
|
|
@ -598,11 +603,6 @@ packages:
|
|||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
extract-zip@2.0.1:
|
||||
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
|
||||
engines: {node: '>= 10.17.0'}
|
||||
hasBin: true
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
|
|
@ -612,9 +612,6 @@ packages:
|
|||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -1046,9 +1043,6 @@ packages:
|
|||
resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -1355,6 +1349,10 @@ packages:
|
|||
resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
undici@7.27.2:
|
||||
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
universalify@0.1.2:
|
||||
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
|
@ -1435,9 +1433,6 @@ packages:
|
|||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -1455,6 +1450,8 @@ snapshots:
|
|||
ajv: 6.14.0
|
||||
ajv-keywords: 3.5.2(ajv@6.14.0)
|
||||
|
||||
'@electron-internal/extract-zip@1.0.2': {}
|
||||
|
||||
'@electron/asar@3.4.1':
|
||||
dependencies:
|
||||
commander: 5.1.0
|
||||
|
|
@ -1467,7 +1464,7 @@ snapshots:
|
|||
fs-extra: 9.1.0
|
||||
minimist: 1.2.8
|
||||
|
||||
'@electron/get@2.0.3':
|
||||
'@electron/get@3.1.0':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
env-paths: 2.2.1
|
||||
|
|
@ -1481,17 +1478,16 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@electron/get@3.1.0':
|
||||
'@electron/get@5.0.0':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
env-paths: 2.2.1
|
||||
fs-extra: 8.1.0
|
||||
got: 11.8.6
|
||||
env-paths: 3.0.0
|
||||
graceful-fs: 4.2.11
|
||||
progress: 2.0.3
|
||||
semver: 6.3.1
|
||||
semver: 7.8.1
|
||||
sumchecker: 3.0.1
|
||||
optionalDependencies:
|
||||
global-agent: 3.0.0
|
||||
undici: 7.27.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -1666,11 +1662,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
optional: true
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
|
@ -2187,11 +2178,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@40.10.2:
|
||||
electron@40.10.3:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@electron-internal/extract-zip': 1.0.2
|
||||
'@electron/get': 5.0.0
|
||||
'@types/node': 24.10.9
|
||||
extract-zip: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -2207,6 +2198,8 @@ snapshots:
|
|||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
||||
err-code@2.0.3: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
|
@ -2271,26 +2264,12 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
'@types/yauzl': 2.10.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
|
@ -2728,8 +2707,6 @@ snapshots:
|
|||
|
||||
pe-library@0.4.1: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pkijs@3.4.0:
|
||||
|
|
@ -3082,6 +3059,9 @@ snapshots:
|
|||
|
||||
undici@6.26.0: {}
|
||||
|
||||
undici@7.27.2:
|
||||
optional: true
|
||||
|
||||
universalify@0.1.2: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
|
@ -3160,11 +3140,6 @@ snapshots:
|
|||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
fd-slicer: 1.1.0
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
|
|
|
|||
62
devenv.lock
62
devenv.lock
|
|
@ -16,62 +16,6 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772893680,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
|
|
@ -125,12 +69,8 @@
|
|||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -117,8 +117,8 @@
|
|||
"@types/node": "24.13.1",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/eslint-config-typescript": "14.8.0",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
|
|
|
|||
|
|
@ -212,17 +212,17 @@ importers:
|
|||
specifier: 8.18.1
|
||||
version: 8.18.1
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: 8.60.1
|
||||
version: 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)
|
||||
specifier: 8.61.0
|
||||
version: 8.61.0(@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))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: 8.60.1
|
||||
version: 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 8.61.0
|
||||
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))
|
||||
'@vue/eslint-config-typescript':
|
||||
specifier: 14.8.0
|
||||
version: 14.8.0(eslint-plugin-vue@10.9.2(@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))(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)
|
||||
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)
|
||||
'@vue/test-utils':
|
||||
specifier: 2.4.11
|
||||
version: 2.4.11(@vue/compiler-dom@3.5.27)(@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))
|
||||
|
|
@ -255,7 +255,7 @@ importers:
|
|||
version: 1.5.0(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint-plugin-vue:
|
||||
specifier: 10.9.2
|
||||
version: 10.9.2(@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))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)))
|
||||
version: 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)))
|
||||
happy-dom:
|
||||
specifier: 20.10.2
|
||||
version: 20.10.2
|
||||
|
|
@ -2931,6 +2931,14 @@ packages:
|
|||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.61.0':
|
||||
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.61.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/parser@8.60.1':
|
||||
resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -2938,6 +2946,13 @@ packages:
|
|||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/parser@8.61.0':
|
||||
resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.58.0':
|
||||
resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -2950,6 +2965,12 @@ packages:
|
|||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.61.0':
|
||||
resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.58.0':
|
||||
resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -2958,20 +2979,24 @@ packages:
|
|||
resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/scope-manager@8.61.0':
|
||||
resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.58.0':
|
||||
resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.60.0':
|
||||
resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==}
|
||||
'@typescript-eslint/tsconfig-utils@8.60.1':
|
||||
resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.60.1':
|
||||
resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==}
|
||||
'@typescript-eslint/tsconfig-utils@8.61.0':
|
||||
resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
|
@ -2983,6 +3008,13 @@ packages:
|
|||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.61.0':
|
||||
resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/types@8.58.0':
|
||||
resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -2991,6 +3023,10 @@ packages:
|
|||
resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/types@8.61.0':
|
||||
resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.58.0':
|
||||
resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -3003,6 +3039,12 @@ packages:
|
|||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.61.0':
|
||||
resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/utils@8.60.1':
|
||||
resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -3010,6 +3052,13 @@ packages:
|
|||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/utils@8.61.0':
|
||||
resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.58.0':
|
||||
resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -3018,6 +3067,10 @@ packages:
|
|||
resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.61.0':
|
||||
resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
|
@ -6042,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==}
|
||||
|
|
@ -9692,6 +9746,22 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.61.0(@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))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.60.1
|
||||
|
|
@ -9704,9 +9774,21 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.58.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
|
|
@ -9722,6 +9804,15 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.61.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.58.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
|
|
@ -9732,15 +9823,20 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
|
||||
'@typescript-eslint/scope-manager@8.61.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.9.3)':
|
||||
'@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)':
|
||||
'@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
|
|
@ -9756,10 +9852,24 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.58.0': {}
|
||||
|
||||
'@typescript-eslint/types@8.60.1': {}
|
||||
|
||||
'@typescript-eslint/types@8.61.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.58.0(typescript@5.9.3)
|
||||
|
|
@ -9790,6 +9900,21 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.4
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
|
|
@ -9801,6 +9926,17 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.58.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
|
|
@ -9811,6 +9947,11 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.60.1
|
||||
eslint-visitor-keys: 5.0.0
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.61.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
eslint-visitor-keys: 5.0.0
|
||||
|
||||
'@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))':
|
||||
|
|
@ -9967,11 +10108,11 @@ snapshots:
|
|||
|
||||
'@vue/devtools-shared@8.1.2': {}
|
||||
|
||||
'@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@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))(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)':
|
||||
'@vue/eslint-config-typescript@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)':
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
eslint-plugin-vue: 10.9.2(@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))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)))
|
||||
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)))
|
||||
fast-glob: 3.3.3
|
||||
typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1))
|
||||
|
|
@ -10948,7 +11089,7 @@ snapshots:
|
|||
module-replacements: 2.11.0
|
||||
semver: 7.7.3
|
||||
|
||||
eslint-plugin-vue@10.9.2(@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))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))):
|
||||
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))):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
|
@ -10959,7 +11100,7 @@ snapshots:
|
|||
vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1))
|
||||
xml-name-validator: 4.0.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
|
||||
eslint-scope@8.4.0:
|
||||
dependencies:
|
||||
|
|
@ -11894,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: {}
|
||||
|
||||
|
|
@ -13185,7 +13326,7 @@ snapshots:
|
|||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.8.1: {}
|
||||
shell-quote@1.8.4: {}
|
||||
|
||||
shiki@3.2.1:
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -44,4 +44,14 @@ describe('smartFillStart', () => {
|
|||
it('falls back to 09:00 when no default is configured', () => {
|
||||
expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00'))
|
||||
})
|
||||
|
||||
it('caps the default start at now when it would be in the future (before 09:00)', () => {
|
||||
const beforeNine = new Date('2026-06-07T07:30:00')
|
||||
expect(smartFillStart([], '09:00', beforeNine)).toEqual(beforeNine)
|
||||
})
|
||||
|
||||
it('caps a future last-entry end at now', () => {
|
||||
const entries = [entry(new Date('2026-06-07T16:00:00'), new Date('2026-06-07T17:00:00'))]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(now)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,16 +5,20 @@ import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
|||
// continue from, fall back to the user's configured default start (HH:MM) on
|
||||
// the given day.
|
||||
export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date {
|
||||
// The filled range ends at now, so a start after now would be inverted (and
|
||||
// rejected on save). Cap at now — e.g. the 09:00 fallback before 9am.
|
||||
const cap = (start: Date) => (start.getTime() > now.getTime() ? new Date(now) : start)
|
||||
|
||||
const lastEnd = recentEntries
|
||||
.map(entry => entry.endTime)
|
||||
.filter((end): end is Date => end !== null)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
if (lastEnd !== undefined) {
|
||||
return new Date(lastEnd)
|
||||
return cap(new Date(lastEnd))
|
||||
}
|
||||
|
||||
const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number)
|
||||
const start = new Date(now)
|
||||
start.setHours(hours || 0, minutes || 0, 0, 0)
|
||||
return start
|
||||
return cap(start)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,8 +284,7 @@
|
|||
"default": "افتراضي",
|
||||
"month": "شهر",
|
||||
"day": "يوم",
|
||||
"hour": "ساعة",
|
||||
"range": "نطاق التاريخ"
|
||||
"hour": "ساعة"
|
||||
},
|
||||
"table": {
|
||||
"title": "جدول",
|
||||
|
|
@ -294,7 +293,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "الحد: {limit}",
|
||||
"noLimit": "غير محدد",
|
||||
"doneBucket": "حافظة المهام المكتملة",
|
||||
"doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.",
|
||||
"doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",
|
||||
|
|
|
|||
|
|
@ -314,8 +314,7 @@
|
|||
"default": "По подразбиране",
|
||||
"month": "Месец",
|
||||
"day": "Ден",
|
||||
"hour": "Час",
|
||||
"range": "Времеви диапазон"
|
||||
"hour": "Час"
|
||||
},
|
||||
"table": {
|
||||
"title": "Таблица",
|
||||
|
|
@ -324,7 +323,6 @@
|
|||
"kanban": {
|
||||
"title": "Канбан",
|
||||
"limit": "Лимит: {limit}",
|
||||
"noLimit": "Не е зададен",
|
||||
"doneBucket": "Колона за завършени",
|
||||
"doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.",
|
||||
"doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",
|
||||
|
|
|
|||
|
|
@ -383,7 +383,6 @@
|
|||
"month": "Měsíc",
|
||||
"day": "Den",
|
||||
"hour": "Hodina",
|
||||
"range": "Časové období",
|
||||
"chartLabel": "Projektový Ganttův diagram",
|
||||
"taskBarsForRow": "Chlívky pro řádek {rowId}",
|
||||
"taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.",
|
||||
|
|
@ -412,7 +411,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nenastaveno",
|
||||
"doneBucket": "Sloupec \"Hotovo\"",
|
||||
"doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.",
|
||||
"doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||
},
|
||||
"timeFormat": "Zeitformat",
|
||||
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12 Stunden (AM/PM)",
|
||||
"24h": "24 Stunden (HH:mm)"
|
||||
|
|
@ -470,7 +471,6 @@
|
|||
"month": "Monat",
|
||||
"day": "Tag",
|
||||
"hour": "Stunde",
|
||||
"range": "Zeitraum",
|
||||
"chartLabel": "Projekt Gantt-Diagramm",
|
||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||
|
|
@ -499,7 +499,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nicht gesetzt",
|
||||
"doneBucket": "Erledigt Spalte",
|
||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||
|
|
@ -783,7 +782,10 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach"
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -993,6 +995,7 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1462,6 +1465,32 @@
|
|||
"frontendVersion": "Frontend-Version: {version}",
|
||||
"apiVersion": "API-Version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Zeiterfassung",
|
||||
"stop": "Timer stoppen",
|
||||
"logTime": "Zeit buchen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"form": {
|
||||
"task": "Aufgabe",
|
||||
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||
"save": "Speichern",
|
||||
"startTimer": "Timer starten",
|
||||
"update": "Eintrag aktualisieren",
|
||||
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||
"total": "Gesamt",
|
||||
"time": "Uhrzeit",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Bereich wählen",
|
||||
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "Sekunde|Sekunden",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||
},
|
||||
"timeFormat": "Zeitformat",
|
||||
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12 Stunden (AM/PM)",
|
||||
"24h": "24 Stunden (HH:mm)"
|
||||
|
|
@ -470,7 +471,6 @@
|
|||
"month": "Monat",
|
||||
"day": "Tag",
|
||||
"hour": "Stunde",
|
||||
"range": "Zeitraum",
|
||||
"chartLabel": "Projekt Gantt-Diagramm",
|
||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||
|
|
@ -499,7 +499,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nicht gesetzt",
|
||||
"doneBucket": "Erledigt Spalte",
|
||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||
|
|
@ -783,7 +782,10 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach"
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -993,6 +995,7 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1462,6 +1465,32 @@
|
|||
"frontendVersion": "Frontend-Version: {version}",
|
||||
"apiVersion": "API-Version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Zeiterfassung",
|
||||
"stop": "Timer stoppen",
|
||||
"logTime": "Zeit buchen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"form": {
|
||||
"task": "Aufgabe",
|
||||
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||
"save": "Speichern",
|
||||
"startTimer": "Timer starten",
|
||||
"update": "Eintrag aktualisieren",
|
||||
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||
"total": "Gesamt",
|
||||
"time": "Uhrzeit",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Bereich wählen",
|
||||
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "Sekunde|Sekunden",
|
||||
|
|
|
|||
|
|
@ -470,7 +470,6 @@
|
|||
"month": "Μήνας",
|
||||
"day": "Ημέρα",
|
||||
"hour": "Ώρα",
|
||||
"range": "Εύρος Ημερομηνιών",
|
||||
"chartLabel": "Γράφημα Gantt Έργου",
|
||||
"taskBarsForRow": "Μπάρες εργασίας για τη γραμμή {rowId}",
|
||||
"taskBarLabel": "Εργασία: {task}. Από {startDate} έως {endDate}. {dateType}. Κάντε κλικ για επεξεργασία, σύρετε για μετακίνηση.",
|
||||
|
|
@ -499,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Όριο: {limit}",
|
||||
"noLimit": "Δεν έχει οριστεί",
|
||||
"doneBucket": "Κάδος για ολοκληρωμένα",
|
||||
"doneBucketHint": "Όλες οι εργασίες που μετακινούνται σε αυτόν τον κάδο θα σημειώνονται αυτόματα ως ολοκληρωμένες.",
|
||||
"doneBucketHintExtended": "Όλες οι εργασίες που μετακινούνται στον κάδο των ολοκληρωμένων θα σημειώνονται αυτόματα ως ολοκληρωμένες. Όλες οι εργασίες που σημειώνονται ως ολοκληρωμένες από αλλού επίσης θα μετακινηθούν.",
|
||||
|
|
|
|||
|
|
@ -251,8 +251,7 @@
|
|||
"default": "Predeterminado",
|
||||
"month": "Mes",
|
||||
"day": "Día",
|
||||
"hour": "Hora",
|
||||
"range": "Rango de fechas"
|
||||
"hour": "Hora"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabla",
|
||||
|
|
@ -261,7 +260,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Límite: {limit}",
|
||||
"noLimit": "No Establecido",
|
||||
"doneBucket": "Contenedor completado",
|
||||
"doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.",
|
||||
"doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.",
|
||||
|
|
|
|||
|
|
@ -463,7 +463,6 @@
|
|||
"month": "ماه",
|
||||
"day": "روز",
|
||||
"hour": "ساعت",
|
||||
"range": "محدوده تاریخ",
|
||||
"chartLabel": "نمودار گانت پروژه",
|
||||
"taskBarsForRow": "نوارهای وظیفه برای ردیف {rowId}",
|
||||
"taskBarLabel": "وظیفه: {task}. از {startDate} تا {endDate}. {dateType}. برای ویرایش کلیک کنید، برای جابجایی بکشید.",
|
||||
|
|
@ -492,7 +491,6 @@
|
|||
"kanban": {
|
||||
"title": "کانبان",
|
||||
"limit": "محدودیت: {limit}",
|
||||
"noLimit": "تنظیم نشده",
|
||||
"doneBucket": "سطل انجام شده",
|
||||
"doneBucketHint": "تمام وظایفی که به این سطل منتقل شوند به طور خودکار به عنوان انجام شده علامتگذاری میشوند.",
|
||||
"doneBucketHintExtended": "تمام وظایفی که به سطل انجام شده منتقل شوند به طور خودکار علامتگذاری میشوند. همچنین تمام وظایفی که از جای دیگر به عنوان انجام شده علامتگذاری شوند نیز به اینجا منتقل خواهند شد.",
|
||||
|
|
|
|||
|
|
@ -347,8 +347,7 @@
|
|||
"default": "Oletus",
|
||||
"month": "Kuukausi",
|
||||
"day": "Päivä",
|
||||
"hour": "Tunti",
|
||||
"range": "Ajanjakso"
|
||||
"hour": "Tunti"
|
||||
},
|
||||
"table": {
|
||||
"title": "Taulukko",
|
||||
|
|
@ -357,7 +356,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Raja: {limit}",
|
||||
"noLimit": "Ei Asetettu",
|
||||
"doneBucket": "Valmiit sarake",
|
||||
"doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.",
|
||||
"doneBucketHintExtended": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi. Muualla valmiiksi merkityt tehtävät siirretään myös.",
|
||||
|
|
|
|||
|
|
@ -346,7 +346,6 @@
|
|||
"month": "Mois",
|
||||
"day": "Jour",
|
||||
"hour": "Heure",
|
||||
"range": "Intervalle",
|
||||
"chartLabel": "Diagramme de Gantt du projet",
|
||||
"taskBarsForRow": "Barres de tâches pour la ligne {rowId}",
|
||||
"taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.",
|
||||
|
|
@ -370,7 +369,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite : {limit}",
|
||||
"noLimit": "Non défini",
|
||||
"doneBucket": "Colonne des tâches terminées",
|
||||
"doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.",
|
||||
"doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.",
|
||||
|
|
|
|||
|
|
@ -318,8 +318,7 @@
|
|||
"default": "ברירת מחדל",
|
||||
"month": "חודש",
|
||||
"day": "יום",
|
||||
"hour": "שעה",
|
||||
"range": "טווח תאריכים"
|
||||
"hour": "שעה"
|
||||
},
|
||||
"table": {
|
||||
"title": "טבלה",
|
||||
|
|
@ -328,7 +327,6 @@
|
|||
"kanban": {
|
||||
"title": "קאנבאן",
|
||||
"limit": "הגבלה: {limit}",
|
||||
"noLimit": "לא נקבע",
|
||||
"doneBucket": "דלי גמורים",
|
||||
"doneBucketHint": "דלי גמורים נשמר בהצלחה.",
|
||||
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",
|
||||
|
|
|
|||
|
|
@ -289,16 +289,14 @@
|
|||
"default": "Zadano",
|
||||
"month": "Mjesec",
|
||||
"day": "Dan",
|
||||
"hour": "Sat",
|
||||
"range": "Raspon datuma"
|
||||
"hour": "Sat"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tablica",
|
||||
"columns": "Stupci"
|
||||
},
|
||||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"noLimit": "Nije postavljeno"
|
||||
"title": "Kanban"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
|
|||
|
|
@ -290,8 +290,7 @@
|
|||
"default": "Alapértelmezett",
|
||||
"month": "Hónap",
|
||||
"day": "Nap",
|
||||
"hour": "Óra",
|
||||
"range": "Időintervallum"
|
||||
"hour": "Óra"
|
||||
},
|
||||
"table": {
|
||||
"title": "Táblázat",
|
||||
|
|
@ -300,7 +299,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Korlát: {limit}",
|
||||
"noLimit": "Nincs beállítva",
|
||||
"doneBucket": "Kész vödör",
|
||||
"doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.",
|
||||
"doneBucketHintExtended": "A kész csoportba áthelyezett összes feladat automatikusan készként lesz megjelölve. A máshonnan elvégzettként megjelölt összes feladat is átkerül.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Mese",
|
||||
"day": "Giorno",
|
||||
"hour": "Ora",
|
||||
"range": "Intervallo di date",
|
||||
"chartLabel": "Progetto diagramma di Gantt",
|
||||
"taskBarsForRow": "Barre delle attività per riga {rowId}",
|
||||
"taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Non Impostato",
|
||||
"doneBucket": "Colonna attività completate",
|
||||
"doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
|
||||
"doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.",
|
||||
|
|
|
|||
|
|
@ -470,7 +470,6 @@
|
|||
"month": "月",
|
||||
"day": "日",
|
||||
"hour": "時間",
|
||||
"range": "期間",
|
||||
"chartLabel": "プロジェクトガントチャート",
|
||||
"taskBarsForRow": "行 {rowId} のタスクバー",
|
||||
"taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。",
|
||||
|
|
@ -499,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "カンバン",
|
||||
"limit": "上限: {limit}",
|
||||
"noLimit": "未設定",
|
||||
"doneBucket": "バケットを完了",
|
||||
"doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。",
|
||||
"doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",
|
||||
|
|
|
|||
|
|
@ -323,8 +323,7 @@
|
|||
"default": "기본값",
|
||||
"month": "월",
|
||||
"day": "일",
|
||||
"hour": "시",
|
||||
"range": "날짜 범위"
|
||||
"hour": "시"
|
||||
},
|
||||
"table": {
|
||||
"title": "테이블",
|
||||
|
|
@ -333,7 +332,6 @@
|
|||
"kanban": {
|
||||
"title": "칸반",
|
||||
"limit": "제한: {limit}",
|
||||
"noLimit": "설정 안함",
|
||||
"doneBucket": "완료 버킷",
|
||||
"doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.",
|
||||
"doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.",
|
||||
|
|
|
|||
|
|
@ -320,8 +320,7 @@
|
|||
"default": "Numatytasis",
|
||||
"month": "Mėnuo",
|
||||
"day": "Diena",
|
||||
"hour": "Valanda",
|
||||
"range": "Datos intervalas"
|
||||
"hour": "Valanda"
|
||||
},
|
||||
"table": {
|
||||
"title": "Lentelė",
|
||||
|
|
@ -330,7 +329,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanbanas",
|
||||
"limit": "Limitas: {limit}",
|
||||
"noLimit": "Nenustatytas",
|
||||
"doneBucket": "Atliktųjų telkinys",
|
||||
"doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.",
|
||||
"doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.",
|
||||
|
|
|
|||
|
|
@ -470,7 +470,6 @@
|
|||
"month": "Maand",
|
||||
"day": "Dag",
|
||||
"hour": "Uur",
|
||||
"range": "Datumbereik",
|
||||
"chartLabel": "Project Gantt-diagram",
|
||||
"taskBarsForRow": "Taakbalken voor rij {rowId}",
|
||||
"taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.",
|
||||
|
|
@ -499,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limiet: {limit}",
|
||||
"noLimit": "Niet ingesteld",
|
||||
"doneBucket": "Categorie 'voltooid'",
|
||||
"doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.",
|
||||
"doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.",
|
||||
|
|
|
|||
|
|
@ -353,7 +353,6 @@
|
|||
"month": "Måned",
|
||||
"day": "Dag",
|
||||
"hour": "Time",
|
||||
"range": "Datointervall",
|
||||
"chartLabel": "Gantt-kart for prosjekt",
|
||||
"taskBarsForRow": "Oppgavelinjer for rad {rowId}",
|
||||
"taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.",
|
||||
|
|
@ -377,7 +376,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Begrens: {limit}",
|
||||
"noLimit": "Ikke angitt",
|
||||
"doneBucket": "Ferdigkurv",
|
||||
"doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.",
|
||||
"doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.",
|
||||
|
|
|
|||
|
|
@ -300,8 +300,7 @@
|
|||
"default": "Domyślnie",
|
||||
"month": "Miesiąc",
|
||||
"day": "Dzień",
|
||||
"hour": "Godzina",
|
||||
"range": "Zakres dat"
|
||||
"hour": "Godzina"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -310,7 +309,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nie ustawiony",
|
||||
"doneBucket": "Zakończone zadania",
|
||||
"doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.",
|
||||
"doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.",
|
||||
|
|
|
|||
|
|
@ -286,8 +286,7 @@
|
|||
"default": "Padrão",
|
||||
"month": "Mês",
|
||||
"day": "Dia",
|
||||
"hour": "Hora",
|
||||
"range": "Período"
|
||||
"hour": "Hora"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -296,7 +295,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Não definido",
|
||||
"doneBucket": "Bucket concluído",
|
||||
"doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.",
|
||||
"doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Mês",
|
||||
"day": "Dia",
|
||||
"hour": "Hora",
|
||||
"range": "Intervalo de Datas",
|
||||
"chartLabel": "Gráfico de Gantt do projeto",
|
||||
"taskBarsForRow": "Barras de tarefas para a linha {rowId}",
|
||||
"taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Não Definido",
|
||||
"doneBucket": "Conjunto concluído",
|
||||
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.",
|
||||
"doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.",
|
||||
|
|
|
|||
|
|
@ -407,7 +407,6 @@
|
|||
"month": "Месяц",
|
||||
"day": "День",
|
||||
"hour": "Час",
|
||||
"range": "Диапазон",
|
||||
"chartLabel": "Диаграмма Ганта",
|
||||
"taskBarsForRow": "Задачи в строке {rowId}",
|
||||
"taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.",
|
||||
|
|
@ -435,7 +434,6 @@
|
|||
"kanban": {
|
||||
"title": "Канбан",
|
||||
"limit": "Лимит: {limit}",
|
||||
"noLimit": "не установлен",
|
||||
"doneBucket": "Колонка завершённых",
|
||||
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
|
||||
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
|
||||
|
|
|
|||
|
|
@ -314,8 +314,7 @@
|
|||
"default": "Privzeto",
|
||||
"month": "Mesec",
|
||||
"day": "Dan",
|
||||
"hour": "Ura",
|
||||
"range": "Datumski obseg"
|
||||
"hour": "Ura"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -324,7 +323,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Omejitev: {limit}",
|
||||
"noLimit": "Ni nastavljeno",
|
||||
"doneBucket": "Vedro končanih nalog",
|
||||
"doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.",
|
||||
"doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Månad",
|
||||
"day": "Dag",
|
||||
"hour": "Timme",
|
||||
"range": "Datumintervall",
|
||||
"chartLabel": "Projektets Gantt-schema",
|
||||
"taskBarsForRow": "Uppgiftsstaplar för rad {rowId}",
|
||||
"taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Gräns: {limit}",
|
||||
"noLimit": "Ej inställt",
|
||||
"doneBucket": "Färdigkolumn",
|
||||
"doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.",
|
||||
"doneBucketHintExtended": "Alla uppgifter som flyttas till färdigkolumnen markeras som färdiga automatiskt. Alla uppgifter som markerats som färdiga från andra håll kommer också att flyttas.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Ay",
|
||||
"day": "Gün",
|
||||
"hour": "Saat",
|
||||
"range": "Tarih Aralığı",
|
||||
"chartLabel": "Proje Gantt Şeması",
|
||||
"taskBarsForRow": "{rowId} satırı için görev çubukları",
|
||||
"taskBarLabel": "Görev: {task}. {startDate} tarihinden {endDate} tarihine. {dateType}. Düzenlemek için tıklayın, taşımak için sürükleyin.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Sınır: {limit}",
|
||||
"noLimit": "Belirlenmedi",
|
||||
"doneBucket": "Tamamlananlar kutusu",
|
||||
"doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.",
|
||||
"doneBucketHintExtended": "Tamamlananlar kutusuna taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir. Başka bir yerden tamamlandı olarak işaretlenen görevler de buraya taşınır.",
|
||||
|
|
|
|||
|
|
@ -470,7 +470,6 @@
|
|||
"month": "Місяць",
|
||||
"day": "День",
|
||||
"hour": "Година",
|
||||
"range": "Проміжок днів",
|
||||
"chartLabel": "Діаграма Ганта",
|
||||
"taskBarsForRow": "Смуги завдань для рядка {rowId}",
|
||||
"taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.",
|
||||
|
|
@ -499,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "Дошка",
|
||||
"limit": "Межа: {limit}",
|
||||
"noLimit": "Немає",
|
||||
"doneBucket": "Колонка «Виконано»",
|
||||
"doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.",
|
||||
"doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.",
|
||||
|
|
|
|||
|
|
@ -319,8 +319,7 @@
|
|||
"default": "Mặc định",
|
||||
"month": "Tháng",
|
||||
"day": "Ngày",
|
||||
"hour": "Giờ",
|
||||
"range": "Khoảng thời gian"
|
||||
"hour": "Giờ"
|
||||
},
|
||||
"table": {
|
||||
"title": "Bảng",
|
||||
|
|
@ -329,7 +328,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Giới hạn: {limit}",
|
||||
"noLimit": "Không giới hạn",
|
||||
"doneBucket": "Cột hoàn thành",
|
||||
"doneBucketHint": "Tất cả các tác vụ được chuyển đến cột này sẽ được tự động đánh dấu là hoàn thành.",
|
||||
"doneBucketHintExtended": "Tất cả các tác vụ được đưa đến cột hoàn thành sẽ được tự động đánh dấu là hoàn thành. Tất cả các tác vụ hoàn thành từ các phần khác cũng sẽ được chuyển tới đây.",
|
||||
|
|
|
|||
|
|
@ -338,7 +338,6 @@
|
|||
"month": "月",
|
||||
"day": "日",
|
||||
"hour": "时",
|
||||
"range": "日期范围",
|
||||
"chartLabel": "项目甘特图",
|
||||
"scheduledDates": "预定日期",
|
||||
"estimatedDates": "估计日期"
|
||||
|
|
@ -350,7 +349,6 @@
|
|||
"kanban": {
|
||||
"title": "看板",
|
||||
"limit": "限制: {limit}",
|
||||
"noLimit": "未设置",
|
||||
"doneBucket": "已完成的桶数",
|
||||
"doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。",
|
||||
"doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "月",
|
||||
"day": "日",
|
||||
"hour": "時",
|
||||
"range": "日期範圍",
|
||||
"chartLabel": "專案甘特圖",
|
||||
"taskBarsForRow": "第 {rowId} 列的任務列",
|
||||
"taskBarLabel": "任務:{task}。從 {startDate} 到 {endDate}。{dateType}。點擊編輯,拖曳移動。",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "看板",
|
||||
"limit": "限制: {limit}",
|
||||
"noLimit": "未設定",
|
||||
"doneBucket": "已完成類別",
|
||||
"doneBucketHint": "移入此類別的任務將自動標記為已完成。",
|
||||
"doneBucketHintExtended": "所有移入已完成類別的任務將自動標記為已完成。 其他位置標記為已完成的任務也將被移動。",
|
||||
|
|
|
|||
|
|
@ -43,3 +43,11 @@
|
|||
created_by_id: 1
|
||||
updated: 2018-12-02 15:13:12
|
||||
created: 2018-12-01 15:13:12
|
||||
# Covers the bot-owner branch: created by bot 23, whose owner is user 21.
|
||||
# User 21 should be able to read/update/delete it; user 22 (who owns bot 24)
|
||||
# should not.
|
||||
- id: 9
|
||||
title: 'Label #9 - created by bot 23 owned by user 21'
|
||||
created_by_id: 23
|
||||
updated: 2018-12-02 15:13:12
|
||||
created: 2018-12-01 15:13:12
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,12 +37,12 @@ import (
|
|||
|
||||
// File holds all information about a file
|
||||
type File struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
|
||||
Name string `xorm:"text not null" json:"name"`
|
||||
Mime string `xorm:"text null" json:"mime"`
|
||||
Size uint64 `xorm:"bigint not null" json:"size"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this file."`
|
||||
Name string `xorm:"text not null" json:"name" readOnly:"true" doc:"The original name of the uploaded file."`
|
||||
Mime string `xorm:"text null" json:"mime" readOnly:"true" doc:"The detected mime type of the file."`
|
||||
Size uint64 `xorm:"bigint not null" json:"size" readOnly:"true" doc:"The size of the file in bytes."`
|
||||
|
||||
Created time.Time `xorm:"created" json:"created"`
|
||||
Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this file was uploaded."`
|
||||
CreatedByID int64 `xorm:"bigint not null" json:"-"`
|
||||
|
||||
File io.ReadCloser `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ import (
|
|||
|
||||
// BulkTask represents a bulk task update payload.
|
||||
type BulkTask struct {
|
||||
TaskIDs []int64 `json:"task_ids"`
|
||||
Fields []string `json:"fields"`
|
||||
Values *Task `json:"values"`
|
||||
Tasks []*Task `json:"tasks,omitempty"`
|
||||
TaskIDs []int64 `json:"task_ids" doc:"The ids of the tasks to update. The user needs write access to every project these tasks belong to, or the whole request is rejected."`
|
||||
Fields []string `json:"fields" doc:"The names of the task fields to apply from values; only these fields are written, the rest of each task is left untouched."`
|
||||
Values *Task `json:"values" doc:"The task carrying the values to set. Only the fields named in fields are read from it and applied to every task."`
|
||||
Tasks []*Task `json:"tasks,omitempty" readOnly:"true" doc:"The updated tasks, returned in the response."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -30,16 +30,16 @@ import (
|
|||
// A task can only appear once per project view which is ensured by a
|
||||
// unique index on the combination of task_id and project_view_id.
|
||||
type TaskBucket struct {
|
||||
BucketID int64 `xorm:"bigint not null index" json:"bucket_id" param:"bucket"`
|
||||
Bucket *Bucket `xorm:"-" json:"bucket"`
|
||||
BucketID int64 `xorm:"bigint not null index" json:"bucket_id" param:"bucket" doc:"The bucket to move the task into. On /api/v2 this is taken from the URL; a value in the body is ignored."`
|
||||
Bucket *Bucket `xorm:"-" json:"bucket" readOnly:"true" doc:"The resolved target bucket, including its updated task count."`
|
||||
// The task which belongs to the bucket. Together with ProjectViewID
|
||||
// this field is part of a unique index to prevent duplicates.
|
||||
TaskID int64 `xorm:"bigint not null index unique(task_view)" json:"task_id"`
|
||||
TaskID int64 `xorm:"bigint not null index unique(task_view)" json:"task_id" doc:"The id of the task to place in the bucket."`
|
||||
// The view this bucket belongs to. Combined with TaskID this forms a
|
||||
// unique index.
|
||||
ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" json:"project_view_id" param:"view"`
|
||||
ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" json:"project_view_id" param:"view" doc:"The view the bucket belongs to. On /api/v2 this is taken from the URL; a value in the body is ignored."`
|
||||
ProjectID int64 `xorm:"-" json:"-" param:"project"`
|
||||
Task *Task `xorm:"-" json:"task"`
|
||||
Task *Task `xorm:"-" json:"task" readOnly:"true" doc:"The task as it stands after the move, reflecting any done-state change."`
|
||||
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
|
|
@ -57,7 +58,19 @@ func (l *Label) isLabelOwner(s *xorm.Session, a web.Auth) (bool, error) {
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return lorig.CreatedByID == a.GetID(), nil
|
||||
if lorig.CreatedByID == a.GetID() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// A bot owner inherits write/delete access to labels their bots created.
|
||||
creator, err := user.GetUserByID(s, lorig.CreatedByID)
|
||||
if err != nil {
|
||||
if user.IsErrUserDoesNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return creator.IsBot() && creator.BotOwnerID == a.GetID(), nil
|
||||
}
|
||||
|
||||
// hasAccessToLabel reports whether the caller can read a label and, if so,
|
||||
|
|
@ -91,7 +104,12 @@ func (l *Label) hasAccessToLabel(s *xorm.Session, a web.Auth) (has bool, maxPerm
|
|||
|
||||
accessBranches := []builder.Cond{labelAttachedToAccessibleTask}
|
||||
if !isLinkShare {
|
||||
accessBranches = append(accessBranches, builder.Eq{"labels.created_by_id": a.GetID()})
|
||||
accessBranches = append(accessBranches,
|
||||
builder.Eq{"labels.created_by_id": a.GetID()},
|
||||
builder.In("labels.created_by_id",
|
||||
builder.Select("id").From("users").Where(builder.Eq{"bot_owner_id": a.GetID()}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
cond := builder.And(
|
||||
|
|
|
|||
|
|
@ -212,7 +212,12 @@ func GetLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*Lab
|
|||
), cond)
|
||||
}
|
||||
if opts.GetUnusedLabels && !isLinkShareAuth {
|
||||
cond = builder.Or(cond, builder.Eq{"labels.created_by_id": opts.User.GetID()})
|
||||
cond = builder.Or(cond,
|
||||
builder.Eq{"labels.created_by_id": opts.User.GetID()},
|
||||
builder.In("labels.created_by_id",
|
||||
builder.Select("id").From("users").Where(builder.Eq{"bot_owner_id": opts.User.GetID()}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
ids := []int64{}
|
||||
|
|
@ -410,7 +415,7 @@ func (t *Task) UpdateTaskLabels(s *xorm.Session, creator web.Auth, labels []*Lab
|
|||
// LabelTaskBulk is a helper struct to update a bunch of labels at once
|
||||
type LabelTaskBulk struct {
|
||||
// All labels you want to update at once.
|
||||
Labels []*Label `json:"labels"`
|
||||
Labels []*Label `json:"labels" doc:"The complete set of labels the task should have after the call. Any label currently on the task that is not in this list is removed; any label in the list that is not yet on the task is added. You must be able to see every label you attach."`
|
||||
TaskID int64 `json:"-" param:"projecttask"`
|
||||
|
||||
web.CRUDable `json:"-"`
|
||||
|
|
|
|||
|
|
@ -336,6 +336,46 @@ func TestLabel_ReadOne(t *testing.T) {
|
|||
wantForbidden: true,
|
||||
auth: &user.User{ID: 1},
|
||||
},
|
||||
{
|
||||
// Label 9 was created by bot 23, whose owner is user 21. The
|
||||
// bot owner inherits admin-level access.
|
||||
name: "bot owner can read label created by their bot",
|
||||
fields: fields{
|
||||
ID: 9,
|
||||
},
|
||||
want: &Label{
|
||||
ID: 9,
|
||||
Title: "Label #9 - created by bot 23 owned by user 21",
|
||||
CreatedByID: 23,
|
||||
CreatedBy: &user.User{
|
||||
ID: 23,
|
||||
Name: "Owner A Assistant",
|
||||
Username: "bot-owner-a-assistant",
|
||||
Issuer: "local",
|
||||
BotOwnerID: 21,
|
||||
EmailRemindersEnabled: true,
|
||||
OverdueTasksRemindersEnabled: true,
|
||||
OverdueTasksRemindersTime: "09:00",
|
||||
Created: testCreatedTime,
|
||||
Updated: testUpdatedTime,
|
||||
},
|
||||
Created: testCreatedTime,
|
||||
Updated: testUpdatedTime,
|
||||
},
|
||||
auth: &user.User{ID: 21},
|
||||
assertMaxPermission: true,
|
||||
wantMaxPermission: int(PermissionAdmin),
|
||||
},
|
||||
{
|
||||
// User 22 owns a different bot and must not see another owner's
|
||||
// bot's label.
|
||||
name: "non-owner cannot read label created by someone else's bot",
|
||||
fields: fields{
|
||||
ID: 9,
|
||||
},
|
||||
wantForbidden: true,
|
||||
auth: &user.User{ID: 22},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -507,6 +547,27 @@ func TestLabel_Update(t *testing.T) {
|
|||
auth: &user.User{ID: 1},
|
||||
wantForbidden: true,
|
||||
},
|
||||
{
|
||||
// Label 9 was created by bot 23 (owned by user 21). The bot's
|
||||
// owner inherits update permission.
|
||||
name: "bot owner can update label created by their bot",
|
||||
fields: fields{
|
||||
ID: 9,
|
||||
Title: "new and better",
|
||||
},
|
||||
auth: &user.User{ID: 21},
|
||||
},
|
||||
{
|
||||
// User 22 owns a different bot and must not be able to update
|
||||
// another owner's bot's label.
|
||||
name: "non-owner cannot update label created by someone else's bot",
|
||||
fields: fields{
|
||||
ID: 9,
|
||||
Title: "new and better",
|
||||
},
|
||||
auth: &user.User{ID: 22},
|
||||
wantForbidden: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -594,6 +655,25 @@ func TestLabel_Delete(t *testing.T) {
|
|||
auth: &user.User{ID: 1},
|
||||
wantForbidden: true,
|
||||
},
|
||||
{
|
||||
// Label 9 was created by bot 23 (owned by user 21). The bot's
|
||||
// owner inherits delete permission.
|
||||
name: "bot owner can delete label created by their bot",
|
||||
fields: fields{
|
||||
ID: 9,
|
||||
},
|
||||
auth: &user.User{ID: 21},
|
||||
},
|
||||
{
|
||||
// User 22 owns a different bot and must not be able to delete
|
||||
// another owner's bot's label.
|
||||
name: "non-owner cannot delete label created by someone else's bot",
|
||||
fields: fields{
|
||||
ID: 9,
|
||||
},
|
||||
auth: &user.User{ID: 22},
|
||||
wantForbidden: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -448,6 +448,20 @@ func GetProjectSimpleByID(s *xorm.Session, projectID int64) (project *Project, e
|
|||
return
|
||||
}
|
||||
|
||||
// GetProjectSimpleByIdentifier gets a project by its textual identifier (e.g. "PROJ").
|
||||
// Identifiers are stored uppercase, so the lookup normalizes the input.
|
||||
func GetProjectSimpleByIdentifier(s *xorm.Session, identifier string) (project *Project, err error) {
|
||||
project, exists, err := getProjectSimple(s, builder.Eq{"identifier": strings.ToUpper(identifier)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, ErrProjectDoesNotExist{}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func getProjectSimple(s *xorm.Session, cond builder.Cond) (project *Project, exists bool, err error) {
|
||||
project = &Project{}
|
||||
exists, err = s.
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ type ProjectDuplicate struct {
|
|||
// The project id of the project to duplicate
|
||||
ProjectID int64 `json:"-" param:"projectid"`
|
||||
// The target parent project
|
||||
ParentProjectID int64 `json:"parent_project_id,omitempty"`
|
||||
ParentProjectID int64 `json:"parent_project_id,omitempty" doc:"The id of the project under which the duplicate should be created. Omit or 0 to place the copy at the top level; you need write access to the parent."`
|
||||
|
||||
// The copied project
|
||||
Project *Project `json:"duplicated_project,omitempty"`
|
||||
Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."`
|
||||
|
||||
web.Permissions `json:"-"`
|
||||
web.CRUDable `json:"-"`
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ type Reaction struct {
|
|||
ID int64 `xorm:"autoincr not null unique pk" json:"-" param:"reaction"`
|
||||
|
||||
// The user who reacted
|
||||
User *user.User `xorm:"-" json:"user" valid:"-"`
|
||||
User *user.User `xorm:"-" json:"user" valid:"-" readOnly:"true" doc:"The user who reacted. Set by the server from the authenticated user; ignored on write."`
|
||||
UserID int64 `xorm:"bigint not null INDEX" json:"-"`
|
||||
|
||||
// The id of the entity you're reacting to
|
||||
|
|
@ -48,10 +48,10 @@ type Reaction struct {
|
|||
EntityKindString string `xorm:"-" json:"-" param:"entitykind"`
|
||||
|
||||
// The actual reaction. This can be any valid utf character or text, up to a length of 20.
|
||||
Value string `xorm:"varchar(20) not null INDEX" json:"value" valid:"required"`
|
||||
Value string `xorm:"varchar(20) not null INDEX" json:"value" valid:"required" maxLength:"20" doc:"The reaction itself: any UTF text up to 20 characters, e.g. an emoji."`
|
||||
|
||||
// A timestamp when this reaction was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this reaction was created. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ func (la *TaskAssginee) ReadAll(s *xorm.Session, a web.Auth, search string, page
|
|||
// BulkAssignees is a helper struct used to update multiple assignees at once.
|
||||
type BulkAssignees struct {
|
||||
// A project with all assignees
|
||||
Assignees []*user.User `json:"assignees"`
|
||||
Assignees []*user.User `json:"assignees" doc:"The full set of users to assign to the task. This replaces the task's current assignees: users not in this list are unassigned. Pass an empty array to unassign everyone. Each user must have access to the task's project."`
|
||||
TaskID int64 `json:"-" param:"projecttask"`
|
||||
|
||||
web.CRUDable `json:"-"`
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"image/png"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
|
|
@ -38,16 +39,16 @@ import (
|
|||
|
||||
// TaskAttachment is the definition of a task attachment
|
||||
type TaskAttachment struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"attachment"`
|
||||
TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"attachment" readOnly:"true" doc:"The unique, numeric id of this attachment."`
|
||||
TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task" readOnly:"true" doc:"The id of the task this attachment belongs to. Taken from the URL, not the body."`
|
||||
FileID int64 `xorm:"bigint not null" json:"-"`
|
||||
|
||||
CreatedByID int64 `xorm:"bigint not null" json:"-"`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by"`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who uploaded this attachment."`
|
||||
|
||||
File *files.File `xorm:"-" json:"file"`
|
||||
File *files.File `xorm:"-" json:"file" readOnly:"true" doc:"Metadata of the uploaded file (name, mime type, size). The bytes are fetched from the download endpoint, not this field."`
|
||||
|
||||
Created time.Time `xorm:"created" json:"created"`
|
||||
Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this attachment was uploaded. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
@ -106,6 +107,74 @@ func (ta *TaskAttachment) NewAttachment(s *xorm.Session, f io.ReadSeeker, realna
|
|||
return nil
|
||||
}
|
||||
|
||||
// AttachmentToUpload is a transport-neutral file to attach, so the upload logic
|
||||
// can be shared by the multipart v1 handler and the Huma v2 handler.
|
||||
type AttachmentToUpload struct {
|
||||
Reader io.ReadSeeker
|
||||
Filename string
|
||||
Size uint64
|
||||
}
|
||||
|
||||
// UploadTaskAttachments checks create access to the task, then stores each file,
|
||||
// collecting per-file failures rather than aborting. The caller owns the session
|
||||
// and the commit. A returned err means the request as a whole failed (e.g.
|
||||
// forbidden); per-file failures come back in failures instead.
|
||||
func UploadTaskAttachments(s *xorm.Session, a web.Auth, taskID int64, uploads []*AttachmentToUpload) (success []*TaskAttachment, failures []error, err error) {
|
||||
ta := &TaskAttachment{TaskID: taskID}
|
||||
can, err := ta.CanCreate(s, a)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !can {
|
||||
return nil, nil, ErrGenericForbidden{}
|
||||
}
|
||||
|
||||
for _, upload := range uploads {
|
||||
attachment := &TaskAttachment{TaskID: taskID}
|
||||
if err := attachment.NewAttachment(s, upload.Reader, upload.Filename, upload.Size, a); err != nil {
|
||||
failures = append(failures, err)
|
||||
continue
|
||||
}
|
||||
success = append(success, attachment)
|
||||
}
|
||||
return success, failures, nil
|
||||
}
|
||||
|
||||
// LoadTaskAttachmentForDownload checks read access, loads the attachment with its
|
||||
// open file, and resolves a preview if previewSize is set and the file is an image.
|
||||
// It returns the loaded attachment and, when applicable, the preview bytes (the
|
||||
// caller serves those instead of the file). The caller owns the session, the
|
||||
// commit, and writing the response. Returns ErrGenericForbidden on denied access.
|
||||
func LoadTaskAttachmentForDownload(s *xorm.Session, a web.Auth, taskID, attachmentID int64, previewSize PreviewSize) (ta *TaskAttachment, preview []byte, err error) {
|
||||
ta = &TaskAttachment{ID: attachmentID, TaskID: taskID}
|
||||
can, _, err := ta.CanRead(s, a)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !can {
|
||||
return nil, nil, ErrGenericForbidden{}
|
||||
}
|
||||
|
||||
if err := ta.ReadOne(s, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := ta.File.LoadFileByID(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if previewSize != PreviewSizeUnknown && strings.HasPrefix(ta.File.Mime, "image") {
|
||||
preview = ta.GetPreview(previewSize)
|
||||
// GetPreview consumes the file reader; re-open it for the non-preview fallback.
|
||||
if preview == nil {
|
||||
if err := ta.File.LoadFileByID(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ta, preview, nil
|
||||
}
|
||||
|
||||
// ReadOne returns a task attachment
|
||||
func (ta *TaskAttachment) ReadOne(s *xorm.Session, _ web.Auth) (err error) {
|
||||
query := s.Where("id = ?", ta.ID).NoAutoCondition()
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ const MinPositionSpacing = 0.01
|
|||
|
||||
type TaskPosition struct {
|
||||
// The ID of the task this position is for
|
||||
TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task"`
|
||||
TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task" readOnly:"true" doc:"The numeric id of the task this position belongs to. Taken from the URL; ignored in the request body."`
|
||||
// The project view this task is related to
|
||||
ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"`
|
||||
ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id" doc:"The id of the project view this position applies to. Positions are stored per view, so the same task has an independent position in each of its project's views."`
|
||||
// The position of the task - any task project can be sorted as usual by this parameter.
|
||||
// When accessing tasks via kanban buckets, this is primarily used to sort them based on a range
|
||||
// We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).
|
||||
|
|
@ -44,7 +44,7 @@ type TaskPosition struct {
|
|||
// which also leaves a lot of room for rearranging and sorting later.
|
||||
// Positions are always saved per view. They will automatically be set if you request the tasks through a view
|
||||
// endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.
|
||||
Position float64 `xorm:"double not null" json:"position"`
|
||||
Position float64 `xorm:"double not null" json:"position" doc:"The task's sort position within the view, as a float so a task can be placed between any two others. To drop a task between two neighbours, set this to their midpoint. Values below the minimum spacing trigger a server-side recalculation of all positions in the view, so the stored value may differ from what you sent."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -81,18 +81,19 @@ type TaskRelation struct {
|
|||
// The unique, numeric id of this relation.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"`
|
||||
// The ID of the "base" task, the task which has a relation to another.
|
||||
TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task"`
|
||||
TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task" readOnly:"true" doc:"The id of the base task. Set from the URL path; ignored in the request body."`
|
||||
// The ID of the other task, the task which is being related.
|
||||
OtherTaskID int64 `xorm:"bigint not null" json:"other_task_id" param:"otherTask"`
|
||||
OtherTaskID int64 `xorm:"bigint not null" json:"other_task_id" param:"otherTask" doc:"The id of the other task this relation points to."`
|
||||
// The kind of the relation.
|
||||
RelationKind RelationKind `xorm:"varchar(50) not null" json:"relation_kind" param:"relationKind"`
|
||||
// The enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.
|
||||
RelationKind RelationKind `xorm:"varchar(50) not null" json:"relation_kind" param:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of relation, describing the direction from the base task to the other task (e.g. subtask, blocking, related). The inverse relation is created automatically."`
|
||||
|
||||
CreatedByID int64 `xorm:"bigint not null" json:"-"`
|
||||
// The user who created this relation
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by"`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this relation."`
|
||||
|
||||
// A timestamp when this label was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this relation was created. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -62,25 +62,25 @@ func validateRepeatAfter(repeatAfter int64) error {
|
|||
// Task represents a task in a project
|
||||
type Task struct {
|
||||
// The unique, numeric id of this task.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"projecttask"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"projecttask" readOnly:"true" doc:"The unique, numeric id of this task."`
|
||||
// The task text. This is what you'll see in the project.
|
||||
Title string `xorm:"TEXT not null" json:"title" valid:"minstringlength(1)" minLength:"1"`
|
||||
Title string `xorm:"TEXT not null" json:"title" valid:"minstringlength(1)" minLength:"1" doc:"The task title. This is what you'll see in the project."`
|
||||
// The task description.
|
||||
Description string `xorm:"longtext null" json:"description"`
|
||||
// Whether a task is done or not.
|
||||
Done bool `xorm:"INDEX null" json:"done"`
|
||||
// The time when a task was marked as done. This field is system-controlled and cannot be set via API.
|
||||
DoneAt time.Time `xorm:"INDEX null 'done_at'" json:"done_at"`
|
||||
DoneAt time.Time `xorm:"INDEX null 'done_at'" json:"done_at" readOnly:"true" doc:"When the task was marked as done. Set by the server; ignored on write."`
|
||||
// The time when the task is due.
|
||||
DueDate time.Time `xorm:"DATETIME INDEX null 'due_date'" json:"due_date"`
|
||||
// An array of reminders that are associated with this task.
|
||||
Reminders []*TaskReminder `xorm:"-" json:"reminders"`
|
||||
// The project this task belongs to.
|
||||
ProjectID int64 `xorm:"bigint INDEX not null unique(tasks_project_index)" json:"project_id" param:"project"`
|
||||
ProjectID int64 `xorm:"bigint INDEX not null unique(tasks_project_index)" json:"project_id" param:"project" doc:"The id of the project this task belongs to. On create it is taken from the URL; on update, setting it to a different project moves the task (requires write access to the target project)."`
|
||||
// An amount in seconds this task repeats itself. If this is set, when marking the task as done, it will mark itself as "undone" and then increase all remindes and the due date by its amount.
|
||||
RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after" valid:"range(0|9223372036854775807)"`
|
||||
RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after" valid:"range(0|9223372036854775807)" doc:"The interval in seconds this task repeats. When set, marking the task done re-opens it and bumps its reminders and due date by this amount."`
|
||||
// Can have three possible values which will trigger when the task is marked as done: 0 = repeats after the amount specified in repeat_after, 1 = repeats all dates each months (ignoring repeat_after), 3 = repeats from the current date rather than the last set date.
|
||||
RepeatMode TaskRepeatMode `xorm:"not null default 0" json:"repeat_mode"`
|
||||
RepeatMode TaskRepeatMode `xorm:"not null default 0" json:"repeat_mode" doc:"How the task repeats when marked done: 0 = after repeat_after seconds, 1 = monthly (ignores repeat_after), 2 = from the current date rather than the last set date."`
|
||||
// The task priority. Can be anything you want, it is possible to sort by this later.
|
||||
Priority int64 `xorm:"bigint null" json:"priority"`
|
||||
// When this task starts.
|
||||
|
|
@ -88,60 +88,60 @@ type Task struct {
|
|||
// When this task ends.
|
||||
EndDate time.Time `xorm:"DATETIME INDEX null 'end_date'" json:"end_date" query:"-"`
|
||||
// An array of users who are assigned to this task
|
||||
Assignees []*user.User `xorm:"-" json:"assignees"`
|
||||
Assignees []*user.User `xorm:"-" json:"assignees" readOnly:"true" doc:"The users assigned to this task. Read-only here; use the task-assignee endpoints to change assignments."`
|
||||
// An array of labels which are associated with this task. This property is read-only, you must use the separate endpoint to add labels to a task.
|
||||
Labels []*Label `xorm:"-" json:"labels"`
|
||||
Labels []*Label `xorm:"-" json:"labels" readOnly:"true" doc:"The labels on this task. Read-only here; use the label-task endpoints to add or remove labels."`
|
||||
// The task color in hex
|
||||
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"`
|
||||
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The task color as a hex string without the leading '#'."`
|
||||
// Determines how far a task is left from being done
|
||||
PercentDone float64 `xorm:"DOUBLE null" json:"percent_done"`
|
||||
PercentDone float64 `xorm:"DOUBLE null" json:"percent_done" doc:"How far the task is from done, between 0 and 1."`
|
||||
|
||||
// The task identifier, based on the project identifier and the task's index
|
||||
Identifier string `xorm:"-" json:"identifier"`
|
||||
Identifier string `xorm:"-" json:"identifier" readOnly:"true" doc:"The textual task identifier, derived from the project identifier and the task index (e.g. \"PROJ-12\")."`
|
||||
// The task index, calculated per project
|
||||
Index int64 `xorm:"bigint not null default 0 unique(tasks_project_index)" json:"index" param:"index"`
|
||||
Index int64 `xorm:"bigint not null default 0 unique(tasks_project_index)" json:"index" param:"index" readOnly:"true" doc:"The per-project task index, assigned by the server."`
|
||||
|
||||
// The UID is currently not used for anything other than CalDAV, which is why we don't expose it over json
|
||||
UID string `xorm:"varchar(250) null" json:"-"`
|
||||
|
||||
// All related tasks, grouped by their relation kind
|
||||
RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks"`
|
||||
RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks" readOnly:"true" doc:"Related tasks grouped by relation kind. Read-only here; use the task-relation endpoints to change relations."`
|
||||
|
||||
// All attachments this task has. This property is read-onlym, you must use the separate endpoint to add attachments to a task.
|
||||
Attachments []*TaskAttachment `xorm:"-" json:"attachments"`
|
||||
Attachments []*TaskAttachment `xorm:"-" json:"attachments" readOnly:"true" doc:"The task's attachments. Read-only here; use the attachment endpoints to add or remove them."`
|
||||
|
||||
// If this task has a cover image, the field will return the id of the attachment that is the cover image.
|
||||
CoverImageAttachmentID int64 `xorm:"bigint default 0" json:"cover_image_attachment_id"`
|
||||
CoverImageAttachmentID int64 `xorm:"bigint default 0" json:"cover_image_attachment_id" doc:"The id of the attachment used as this task's cover image, or 0 for none."`
|
||||
|
||||
// True if a task is a favorite task. Favorite tasks show up in a separate "Important" project. This value depends on the user making the call to the api.
|
||||
IsFavorite bool `xorm:"-" json:"is_favorite"`
|
||||
IsFavorite bool `xorm:"-" json:"is_favorite" doc:"Whether the requesting user has favorited this task. Per-user, so it differs between callers."`
|
||||
|
||||
IsUnread *bool `xorm:"-" json:"is_unread,omitempty"`
|
||||
IsUnread *bool `xorm:"-" json:"is_unread,omitempty" readOnly:"true" doc:"Whether the task is unread for the requesting user. Only present when requested via the is_unread expand option."`
|
||||
|
||||
// The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.
|
||||
// Will only returned when retrieving one task.
|
||||
Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
|
||||
Subscription *Subscription `xorm:"-" json:"subscription,omitempty" readOnly:"true" doc:"The requesting user's subscription to this task. Read-only here; use the subscription endpoints to change it. Only present when reading a single task."`
|
||||
|
||||
// A timestamp when this task was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"When this task was created. Set by the server; ignored on write."`
|
||||
// A timestamp when this task was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"When this task was last updated. Set by the server; ignored on write."`
|
||||
|
||||
// The bucket id. Will only be populated when the task is accessed via a view with buckets.
|
||||
// Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.
|
||||
BucketID int64 `xorm:"-" json:"bucket_id"`
|
||||
BucketID int64 `xorm:"-" json:"bucket_id" doc:"The bucket the task is in. Only populated when the task is accessed via a view with buckets. To move a task between buckets, the new bucket must be in the same view as the old one."`
|
||||
|
||||
// All buckets across all views this task is part of. Only present when fetching tasks with the `expand` parameter set to `buckets`.
|
||||
Buckets []*Bucket `xorm:"-" json:"buckets,omitempty"`
|
||||
Buckets []*Bucket `xorm:"-" json:"buckets,omitempty" readOnly:"true" doc:"The task's buckets across all views. Only present when requested via the buckets expand option."`
|
||||
|
||||
// All comments of this task. Only present when fetching tasks with the `expand` parameter set to `comments`.
|
||||
Comments []*TaskComment `xorm:"-" json:"comments,omitempty"`
|
||||
Comments []*TaskComment `xorm:"-" json:"comments,omitempty" readOnly:"true" doc:"The task's first 50 comments. Only present when requested via the comments expand option."`
|
||||
|
||||
// Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`.
|
||||
CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"`
|
||||
CommentCount *int64 `xorm:"-" json:"comment_count,omitempty" readOnly:"true" doc:"The number of comments on this task. Only present when requested via the comment_count expand option."`
|
||||
|
||||
// Time entry count of this task. Only present when fetching tasks with the `expand` parameter set to `time_entries_count`.
|
||||
TimeEntriesCount *int64 `xorm:"-" json:"time_entries_count,omitempty"`
|
||||
TimeEntriesCount *int64 `xorm:"-" json:"time_entries_count,omitempty" readOnly:"true" doc:"The number of time entries on this task. Only present when requested via the time_entries_count expand option."`
|
||||
|
||||
// Behaves exactly the same as with the TaskCollection.Expand parameter
|
||||
Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"`
|
||||
|
|
@ -150,13 +150,13 @@ type Task struct {
|
|||
// When accessing tasks via views with buckets, this is primarily used to sort them based on a range.
|
||||
// Positions are always saved per view. They will automatically be set if you request the tasks through a view
|
||||
// endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.
|
||||
Position float64 `xorm:"-" json:"position"`
|
||||
Position float64 `xorm:"-" json:"position" readOnly:"true" doc:"The task's position, saved per view. Only non-zero when the task is fetched through a view endpoint; use the task-position endpoint to change it."`
|
||||
|
||||
// Reactions on that task.
|
||||
Reactions ReactionMap `xorm:"-" json:"reactions"`
|
||||
Reactions ReactionMap `xorm:"-" json:"reactions" readOnly:"true" doc:"Reactions on this task. Only present when requested via the reactions expand option."`
|
||||
|
||||
// The user who initially created the task.
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-" readOnly:"true" doc:"The user who created this task. Set by the server."`
|
||||
CreatedByID int64 `xorm:"bigint not null" json:"-"` // ID of the user who put that task on the project
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/modules/avatar"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// UserGeneralSettings is the single user-settings wire struct shared by v1 and
|
||||
// v2 — both the update request body and the nested settings on GET /user. A
|
||||
// dedicated struct (not user.User) is required: user.User's settings fields are
|
||||
// json:"-" so they don't leak when it is embedded in other responses
|
||||
// (assignees, created_by, members …).
|
||||
type UserGeneralSettings struct {
|
||||
Name string `json:"name" doc:"The full name of the user."`
|
||||
EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"If enabled, sends email reminders of tasks to the user."`
|
||||
DiscoverableByName bool `json:"discoverable_by_name" doc:"If true, this user can be found by their name or parts of it when searching."`
|
||||
DiscoverableByEmail bool `json:"discoverable_by_email" doc:"If true, the user can be found when searching for their exact email."`
|
||||
OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled" doc:"If enabled, the user gets an email for their overdue tasks each morning."`
|
||||
OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required" doc:"The time the daily overdue-tasks summary is sent, as HH:MM."`
|
||||
DefaultProjectID int64 `json:"default_project_id" doc:"Project a task is filed under when created without an explicit project."`
|
||||
WeekStart int `json:"week_start" valid:"range(0|6)" minimum:"0" maximum:"6" doc:"The day the week starts on: 0=sunday, 1=monday, … 6=saturday."`
|
||||
Language string `json:"language" doc:"The user's language."`
|
||||
Timezone string `json:"timezone" doc:"The user's time zone, used to send task reminders in their local time."`
|
||||
FrontendSettings any `json:"frontend_settings" doc:"Arbitrary settings used only by the frontend. Any JSON value; stored and returned verbatim."`
|
||||
// Server/OpenID-provided; populated on read, ignored on write.
|
||||
ExtraSettingsLinks map[string]any `json:"extra_settings_links" readOnly:"true" doc:"Additional settings links provided by the OpenID provider. Server-controlled."`
|
||||
}
|
||||
|
||||
// NewUserGeneralSettings projects a user's stored settings into the shared wire
|
||||
// struct for GET /user. Used by both the v1 and v2 user-show handlers.
|
||||
func NewUserGeneralSettings(u *user.User) *UserGeneralSettings {
|
||||
return &UserGeneralSettings{
|
||||
Name: u.Name,
|
||||
EmailRemindersEnabled: u.EmailRemindersEnabled,
|
||||
DiscoverableByName: u.DiscoverableByName,
|
||||
DiscoverableByEmail: u.DiscoverableByEmail,
|
||||
OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled,
|
||||
OverdueTasksRemindersTime: u.OverdueTasksRemindersTime,
|
||||
DefaultProjectID: u.DefaultProjectID,
|
||||
WeekStart: u.WeekStart,
|
||||
Language: u.Language,
|
||||
Timezone: u.Timezone,
|
||||
FrontendSettings: u.FrontendSettings,
|
||||
ExtraSettingsLinks: u.ExtraSettingsLinks,
|
||||
}
|
||||
}
|
||||
|
||||
// ChangeUserPassword verifies the old password, sets the new one, and
|
||||
// invalidates all of the user's sessions. Lives here (not in pkg/user) because
|
||||
// it needs DeleteAllUserSessions, which pkg/user cannot import.
|
||||
func ChangeUserPassword(s *xorm.Session, u *user.User, oldPassword, newPassword string) error {
|
||||
if oldPassword == "" {
|
||||
return user.ErrEmptyOldPassword{}
|
||||
}
|
||||
|
||||
if _, err := user.CheckUserCredentials(s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := user.UpdateUserPassword(s, u, newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DeleteAllUserSessions(s, u.ID)
|
||||
}
|
||||
|
||||
// UpdateUserGeneralSettings copies the general settings onto the user, persists
|
||||
// them, and flushes the avatar cache when an initials avatar's name changed.
|
||||
// Lives here (not in pkg/user) because the avatar flush needs pkg/modules/avatar,
|
||||
// which pkg/user cannot import.
|
||||
func UpdateUserGeneralSettings(s *xorm.Session, u *user.User, settings *UserGeneralSettings) error {
|
||||
invalidateAvatar := u.AvatarProvider == "initials" && u.Name != settings.Name
|
||||
|
||||
u.Name = settings.Name
|
||||
u.EmailRemindersEnabled = settings.EmailRemindersEnabled
|
||||
u.DiscoverableByEmail = settings.DiscoverableByEmail
|
||||
u.DiscoverableByName = settings.DiscoverableByName
|
||||
u.OverdueTasksRemindersEnabled = settings.OverdueTasksRemindersEnabled
|
||||
u.DefaultProjectID = settings.DefaultProjectID
|
||||
u.WeekStart = settings.WeekStart
|
||||
u.Language = settings.Language
|
||||
u.Timezone = settings.Timezone
|
||||
u.OverdueTasksRemindersTime = settings.OverdueTasksRemindersTime
|
||||
u.FrontendSettings = settings.FrontendSettings
|
||||
|
||||
if _, err := user.UpdateUser(s, u, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if invalidateAvatar {
|
||||
avatar.FlushAllCaches(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserAvatarProvider sets the user's avatar provider, persists it, and
|
||||
// flushes the avatar cache when the provider changes (or is set to initials).
|
||||
func UpdateUserAvatarProvider(s *xorm.Session, u *user.User, provider string) error {
|
||||
oldProvider := u.AvatarProvider
|
||||
u.AvatarProvider = provider
|
||||
|
||||
if _, err := user.UpdateUser(s, u, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if u.AvatarProvider == "initials" || oldProvider != u.AvatarProvider {
|
||||
avatar.FlushAllCaches(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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) {
|
||||
// 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, _, err := p.CanRead(s, a)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -711,3 +711,55 @@ func TestConversationalMail(t *testing.T) {
|
|||
assert.Contains(t, headerLine1, "(Project > Task) #1")
|
||||
})
|
||||
}
|
||||
|
||||
// Regression test for GHSA-2vr2-r3qw-rjvq: a user-controlled task title (or comment
|
||||
// or description) must not be able to smuggle a remote image into a notification
|
||||
// email, where it would act as a tracking pixel. Inline data-URI avatars and normal
|
||||
// links must keep working.
|
||||
func TestNotificationEmailStripsRemoteImages(t *testing.T) {
|
||||
const remoteSrc = "https://attacker.example/track.png?u=victim"
|
||||
|
||||
t.Run("remote image injected via task title in header is stripped", func(t *testing.T) {
|
||||
payloadTitle := `</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,")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -18,43 +18,16 @@ package v1
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
auth2 "code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
webfiles "code.vikunja.io/api/pkg/web/files"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// attachmentUploadError represents a structured error for attachment upload failures
|
||||
type attachmentUploadError struct {
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// toAttachmentUploadError converts an error to a structured attachmentUploadError
|
||||
func toAttachmentUploadError(err error) attachmentUploadError {
|
||||
// Try to get structured error info from HTTPErrorProcessor
|
||||
if httpErr, ok := err.(web.HTTPErrorProcessor); ok {
|
||||
errDetails := httpErr.HTTPError()
|
||||
return attachmentUploadError{
|
||||
Code: errDetails.Code,
|
||||
Message: errDetails.Message,
|
||||
}
|
||||
}
|
||||
// Fall back to just the error message
|
||||
return attachmentUploadError{
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// UploadTaskAttachment handles everything needed for the upload of a task attachment
|
||||
// @Summary Upload a task attachment
|
||||
// @Description Upload a task attachment. You can pass multiple files with the files form param.
|
||||
|
|
@ -76,7 +49,6 @@ func UploadTaskAttachment(c *echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err)
|
||||
}
|
||||
|
||||
// Permissions check
|
||||
auth, err := auth2.GetAuthFromClaims(c)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -85,15 +57,6 @@ func UploadTaskAttachment(c *echo.Context) error {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
can, err := taskAttachment.CanCreate(s, auth)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
if !can {
|
||||
return echo.ErrForbidden
|
||||
}
|
||||
|
||||
// Multipart form
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
|
|
@ -104,31 +67,23 @@ func UploadTaskAttachment(c *echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
type result struct {
|
||||
Errors []attachmentUploadError `json:"errors"`
|
||||
Success []*models.TaskAttachment `json:"success"`
|
||||
}
|
||||
r := &result{}
|
||||
fileHeaders := form.File["files"]
|
||||
uploads := make([]*models.AttachmentToUpload, 0, len(fileHeaders))
|
||||
var openErrors []error
|
||||
for _, file := range fileHeaders {
|
||||
// We create a new attachment object here to have a clean start
|
||||
ta := &models.TaskAttachment{
|
||||
TaskID: taskAttachment.TaskID,
|
||||
}
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
r.Errors = append(r.Errors, toAttachmentUploadError(err))
|
||||
openErrors = append(openErrors, err)
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = ta.NewAttachment(s, f, file.Filename, uint64(file.Size), auth)
|
||||
if err != nil {
|
||||
r.Errors = append(r.Errors, toAttachmentUploadError(err))
|
||||
continue
|
||||
uploads = append(uploads, &models.AttachmentToUpload{Reader: f, Filename: file.Filename, Size: uint64(file.Size)})
|
||||
}
|
||||
r.Success = append(r.Success, ta)
|
||||
|
||||
success, failures, err := models.UploadTaskAttachments(s, auth, taskAttachment.TaskID, uploads)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
|
|
@ -136,7 +91,7 @@ func UploadTaskAttachment(c *echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, r)
|
||||
return c.JSON(http.StatusOK, webfiles.BuildUploadResult(success, append(openErrors, failures...)))
|
||||
}
|
||||
|
||||
// GetTaskAttachment returns a task attachment to download for the user
|
||||
|
|
@ -160,7 +115,6 @@ func GetTaskAttachment(c *echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err)
|
||||
}
|
||||
|
||||
// Permissions check
|
||||
auth, err := auth2.GetAuthFromClaims(c)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -169,36 +123,11 @@ func GetTaskAttachment(c *echo.Context) error {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
can, _, err := taskAttachment.CanRead(s, auth)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
if !can {
|
||||
return echo.ErrForbidden
|
||||
}
|
||||
|
||||
// Get the attachment incl file
|
||||
err = taskAttachment.ReadOne(s, auth)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the file so its content is available for preview generation and download
|
||||
err = taskAttachment.File.LoadFileByID()
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// If the preview query parameter is set, get the preview (cached or generate)
|
||||
previewSize := models.GetPreviewSizeFromString(c.QueryParam("preview_size"))
|
||||
if previewSize != models.PreviewSizeUnknown && strings.HasPrefix(taskAttachment.File.Mime, "image") {
|
||||
previewFileBytes := taskAttachment.GetPreview(previewSize)
|
||||
if previewFileBytes != nil {
|
||||
return c.Blob(http.StatusOK, "image/png", previewFileBytes)
|
||||
}
|
||||
attachment, preview, err := models.LoadTaskAttachmentForDownload(s, auth, taskAttachment.TaskID, taskAttachment.ID, previewSize)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
|
|
@ -206,36 +135,6 @@ func GetTaskAttachment(c *echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
mimeToReturn := taskAttachment.File.Mime
|
||||
if mimeToReturn == "" {
|
||||
mimeToReturn = "application/octet-stream"
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
|
||||
"filename": taskAttachment.File.Name,
|
||||
}))
|
||||
c.Response().Header().Set("Content-Type", mimeToReturn)
|
||||
c.Response().Header().Set("Content-Length", strconv.FormatUint(taskAttachment.File.Size, 10))
|
||||
c.Response().Header().Set("Last-Modified", taskAttachment.File.Created.UTC().Format(http.TimeFormat))
|
||||
// Override the global no-store directive so browsers can cache attachments.
|
||||
// no-cache allows caching but requires revalidation via If-Modified-Since.
|
||||
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
if config.FilesType.GetString() == "s3" {
|
||||
// Check If-Modified-Since and return 304 if the file hasn't changed.
|
||||
// http.ServeContent handles this automatically for local files.
|
||||
if ifModSince := c.Request().Header.Get("If-Modified-Since"); ifModSince != "" {
|
||||
if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !taskAttachment.File.Created.UTC().After(t) {
|
||||
return c.NoContent(http.StatusNotModified)
|
||||
}
|
||||
}
|
||||
|
||||
// s3 files cannot use http.ServeContent as it requires a Seekable file
|
||||
// so we stream the file content directly to the response
|
||||
_, err = io.Copy(c.Response(), taskAttachment.File.File)
|
||||
return err
|
||||
}
|
||||
|
||||
http.ServeContent(c.Response(), c.Request(), taskAttachment.File.Name, taskAttachment.File.Created, taskAttachment.File.File.(io.ReadSeeker))
|
||||
webfiles.WriteAttachmentDownload(c.Response(), c.Request(), attachment, preview)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +34,7 @@ import (
|
|||
|
||||
type UserWithSettings struct {
|
||||
user.User
|
||||
Settings *UserSettings `json:"settings"`
|
||||
Settings *models.UserGeneralSettings `json:"settings"`
|
||||
DeletionScheduledAt time.Time `json:"deletion_scheduled_at"`
|
||||
IsLocalUser bool `json:"is_local_user"`
|
||||
AuthProvider string `json:"auth_provider"`
|
||||
|
|
@ -68,56 +68,16 @@ 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,
|
||||
},
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,17 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
emailUpdate.User, err = user.CheckUserCredentials(s, &user.Login{
|
||||
Username: emailUpdate.User.Username,
|
||||
Password: emailUpdate.Password,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = user.UpdateEmail(s, emailUpdate)
|
||||
if err != nil {
|
||||
if err := user.ChangeUserEmail(s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,26 +63,10 @@ func UserChangePassword(c *echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if newPW.OldPassword == "" {
|
||||
return user.ErrEmptyOldPassword{}
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Check the current password
|
||||
if _, err = user.CheckUserCredentials(s, &user.Login{Username: doer.Username, Password: newPW.OldPassword}); err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the password
|
||||
if err = user.UpdateUserPassword(s, doer, newPW.NewPassword); err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := models.DeleteAllUserSessions(s, doer.ID); err != nil {
|
||||
if err := models.ChangeUserPassword(s, doer, newPW.OldPassword, newPW.NewPassword); err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterBulkTaskRoutes wires the bulk task update action onto the Huma API.
|
||||
//
|
||||
// BulkTask is a CRUDable Update, so the handler reuses handler.DoUpdate; its
|
||||
// CanUpdate fans the write check out across every project the involved tasks
|
||||
// belong to, so a single project the user can't write to rejects the request.
|
||||
func RegisterBulkTaskRoutes(api huma.API) {
|
||||
tags := []string{"tasks"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-bulk-update",
|
||||
Summary: "Bulk update tasks",
|
||||
Description: "Applies the fields named in `fields` from `values` to every task in `task_ids`. The user needs write access to every project the involved tasks belong to; if write is missing on even one, the whole request is rejected and nothing is changed. Returns the updated tasks.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/tasks/bulk",
|
||||
Tags: tags,
|
||||
}, tasksBulkUpdate)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) }
|
||||
|
||||
func tasksBulkUpdate(ctx context.Context, in *struct {
|
||||
Body models.BulkTask
|
||||
}) (*singleBody[models.BulkTask], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bt := &in.Body
|
||||
if err := handler.DoUpdate(ctx, bt, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.BulkTask]{Body: bt}, nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterLabelTaskBulkRoutes wires the bulk label-replacement action onto the
|
||||
// Huma API. The model op is a CRUDable Create (handler.DoCreate, whose
|
||||
// CanCreate enforces write access to the task), but the verb is PUT because the
|
||||
// operation replaces the task's whole label set — the idempotent PUT semantics
|
||||
// describe it more honestly than POST.
|
||||
func RegisterLabelTaskBulkRoutes(api huma.API) {
|
||||
tags := []string{"labels"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "task-labels-bulk-replace",
|
||||
Summary: "Replace all labels on a task",
|
||||
Description: "Sets the task's labels to exactly the provided list: labels not in the list are removed, missing ones are added, unchanged ones are left alone. Requires write access to the task, and you must be able to see every label you attach. Returns the resulting label set.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/tasks/{projecttask}/labels/bulk",
|
||||
Tags: tags,
|
||||
}, labelTasksBulkReplace)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterLabelTaskBulkRoutes) }
|
||||
|
||||
func labelTasksBulkReplace(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"projecttask" doc:"The numeric id of the task whose labels to replace."`
|
||||
Body models.LabelTaskBulk
|
||||
}) (*singleBody[models.LabelTaskBulk], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Body.TaskID = in.TaskID // parent from the path, not the body
|
||||
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.LabelTaskBulk]{Body: &in.Body}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterProjectDuplicateRoutes wires the project-duplicate action onto the Huma API.
|
||||
//
|
||||
// ProjectDuplicate is a CRUDable Create, so the handler reuses handler.DoCreate
|
||||
// (its CanCreate enforces access); the only custom part is taking ProjectID from
|
||||
// the path rather than the request body.
|
||||
func RegisterProjectDuplicateRoutes(api huma.API) {
|
||||
tags := []string{"projects"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "projects-duplicate",
|
||||
Summary: "Duplicate a project",
|
||||
Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds and user/team/link shares — into a new project owned by the authenticated user. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/projects/{projectid}/duplicate",
|
||||
Tags: tags,
|
||||
}, projectsDuplicate)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterProjectDuplicateRoutes) }
|
||||
|
||||
func projectsDuplicate(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"projectid" doc:"The numeric id of the project to duplicate."`
|
||||
Body models.ProjectDuplicate
|
||||
}) (*singleBody[models.ProjectDuplicate], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pd := &in.Body
|
||||
pd.ProjectID = in.ProjectID
|
||||
if err := handler.DoCreate(ctx, pd, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.ProjectDuplicate]{Body: pd}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <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"
|
||||
)
|
||||
|
||||
// {entitykind} stays a string: the model derives the numeric EntityKind from
|
||||
// it and rejects unknown kinds. The enum tag (repeated on the create/delete
|
||||
// inputs) makes Huma reject anything else with a 422 before the handler runs;
|
||||
// keep the values in sync with models.Reaction.setEntityKindFromString.
|
||||
type reactionPathParams struct {
|
||||
EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."`
|
||||
EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."`
|
||||
}
|
||||
|
||||
// Reactions list as a map keyed by reaction value, not a slice, so it does not
|
||||
// fit the Paginated envelope.
|
||||
type reactionListBody struct {
|
||||
Body models.ReactionMap
|
||||
}
|
||||
|
||||
func RegisterReactionRoutes(api huma.API) {
|
||||
tags := []string{"reactions"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "reactions-list",
|
||||
Summary: "List reactions for an entity",
|
||||
Description: "Returns every reaction on the entity, grouped as a map keyed by reaction value; each value maps to the users who reacted with it. Requires read access to the entity. Not paginated.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/{entitykind}/{entityid}/reactions",
|
||||
Tags: tags,
|
||||
}, reactionsList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "reactions-create",
|
||||
Summary: "React to an entity",
|
||||
Description: "Adds the authenticated user's reaction to the entity. Requires write access. No-op if the same reaction already exists.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/{entitykind}/{entityid}/reactions",
|
||||
Tags: tags,
|
||||
}, reactionsCreate)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "reactions-delete",
|
||||
Summary: "Remove a reaction from an entity",
|
||||
Description: "Removes the authenticated user's own reaction from the entity. The reaction to remove is named in the body (there is no per-reaction id), so this is a POST with a body rather than a DELETE. Requires write access.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/{entitykind}/{entityid}/reactions/delete",
|
||||
Tags: tags,
|
||||
DefaultStatus: http.StatusOK,
|
||||
}, reactionsDelete)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterReactionRoutes) }
|
||||
|
||||
func reactionsList(ctx context.Context, in *reactionPathParams) (*reactionListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &models.Reaction{EntityID: in.EntityID, EntityKindString: in.EntityKind}
|
||||
result, _, _, err := handler.DoReadAll(ctx, r, a, "", 1, -1)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
reactions, ok := result.(models.ReactionMap)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("reactions.ReadAll returned unexpected type %T (expected models.ReactionMap)", result)
|
||||
}
|
||||
if reactions == nil {
|
||||
reactions = models.ReactionMap{}
|
||||
}
|
||||
return &reactionListBody{Body: reactions}, nil
|
||||
}
|
||||
|
||||
// Path params are flattened (not via the embedded reactionPathParams) because
|
||||
// Huma fails to bind an embedded path-param struct when the input also has a Body.
|
||||
func reactionsCreate(ctx context.Context, in *struct {
|
||||
EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."`
|
||||
EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."`
|
||||
Body models.Reaction
|
||||
}) (*singleBody[models.Reaction], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &in.Body
|
||||
r.EntityID = in.EntityID
|
||||
r.EntityKindString = in.EntityKind
|
||||
if err := handler.DoCreate(ctx, r, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.Reaction]{Body: r}, nil
|
||||
}
|
||||
|
||||
func reactionsDelete(ctx context.Context, in *struct {
|
||||
EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."`
|
||||
EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."`
|
||||
Body models.Reaction
|
||||
}) (*emptyBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &in.Body
|
||||
r.EntityID = in.EntityID
|
||||
r.EntityKindString = in.EntityKind
|
||||
if err := handler.DoDelete(ctx, r, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterTaskAssigneeBulkRoutes wires the bulk assignee replacement onto the
|
||||
// Huma API. PUT is the honest verb — the operation replaces the task's whole
|
||||
// assignee set idempotently — even though the model implements it as a Create.
|
||||
func RegisterTaskAssigneeBulkRoutes(api huma.API) {
|
||||
tags := []string{"assignees"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "task-assignees-bulk",
|
||||
Summary: "Replace all assignees of a task",
|
||||
Description: "Replaces the task's full assignee set with the users in the body: users not in the list are unassigned, new ones are added. Pass an empty array to unassign everyone. Each assignee must have access to the task's project, and the caller needs write access to the task.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/tasks/{projecttask}/assignees/bulk",
|
||||
Tags: tags,
|
||||
}, taskAssigneesBulk)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskAssigneeBulkRoutes) }
|
||||
|
||||
func taskAssigneesBulk(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"projecttask"`
|
||||
Body models.BulkAssignees
|
||||
}) (*singleBody[models.BulkAssignees], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Body.TaskID = in.TaskID // URL wins over body
|
||||
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.BulkAssignees]{Body: &in.Body}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/humaecho5"
|
||||
webfiles "code.vikunja.io/api/pkg/web/files"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// models.TaskAttachment.ReadAll returns []*models.TaskAttachment.
|
||||
type taskAttachmentListBody struct {
|
||||
Body Paginated[*models.TaskAttachment]
|
||||
}
|
||||
|
||||
type taskAttachmentUploadInput struct {
|
||||
TaskID int64 `path:"task" doc:"The id of the task to attach the files to."`
|
||||
// Accept any upload; the byte-level mime detection happens in files.CreateWithSession,
|
||||
// so there is no part content-type allow-list to enforce here (unlike the avatar endpoint).
|
||||
RawBody huma.MultipartFormFiles[struct {
|
||||
Files []huma.FormFile `form:"files" required:"true" doc:"One or more files to upload as task attachments. Send multiple parts under the same \"files\" field to upload several at once."`
|
||||
}]
|
||||
}
|
||||
|
||||
type taskAttachmentUploadBody struct {
|
||||
Body *webfiles.AttachmentUploadResult
|
||||
}
|
||||
|
||||
// RegisterTaskAttachmentRoutes wires task-attachment list/upload/download/delete onto
|
||||
// the Huma API. The whole resource is gated by the service.enabletaskattachments config
|
||||
// flag; the check runs here (not at init()) because RegisterAll fires after config loads.
|
||||
func RegisterTaskAttachmentRoutes(api huma.API) {
|
||||
if !config.ServiceEnableTaskAttachments.GetBool() {
|
||||
return
|
||||
}
|
||||
|
||||
tags := []string{"task"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "task-attachments-list",
|
||||
Summary: "List a task's attachments",
|
||||
Description: "Returns the attachment metadata for one task, paginated. Requires read access to the task. The file bytes are not included; fetch them from the download endpoint.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/tasks/{task}/attachments",
|
||||
Tags: tags,
|
||||
}, taskAttachmentsList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "task-attachments-upload",
|
||||
Summary: "Upload task attachments",
|
||||
Description: "Uploads one or more files as attachments to a task via multipart/form-data under the \"files\" field. Requires write access to the task. Each file is processed independently: a file that fails (for example, exceeding the configured size limit) is reported in the errors list while the others still succeed, so the request returns 201 even on a partial upload. The max size per file is the server's configured file size limit.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/tasks/{task}/attachments",
|
||||
Tags: tags,
|
||||
// +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes.
|
||||
// #nosec G115 - configured value won't exceed int64 max in practice.
|
||||
MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024,
|
||||
}, taskAttachmentsUpload)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "task-attachments-download",
|
||||
Summary: "Download a task attachment",
|
||||
Description: "Returns the raw bytes of one attachment. Requires read access to the task. Pass preview_size to get a downscaled PNG preview instead — only for image attachments; for non-images or an unknown size the original file is returned. The Content-Type header carries the file's real mime type.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/tasks/{task}/attachments/{attachment}",
|
||||
Tags: tags,
|
||||
// Spell out the binary response; a bare []byte Body would otherwise be
|
||||
// modeled as a base64 JSON string instead of binary file data.
|
||||
Responses: map[string]*huma.Response{
|
||||
"200": {
|
||||
Description: "The attachment file bytes. The Content-Type header carries the file's mime type.",
|
||||
Content: map[string]*huma.MediaType{
|
||||
"application/octet-stream": {
|
||||
Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, taskAttachmentsDownload)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "task-attachments-delete",
|
||||
Summary: "Delete a task attachment",
|
||||
Description: "Deletes one attachment and its underlying file. Requires write access to the task. The attachment must belong to the task in the path.",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/tasks/{task}/attachments/{attachment}",
|
||||
Tags: tags,
|
||||
}, taskAttachmentsDelete)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskAttachmentRoutes) }
|
||||
|
||||
func taskAttachmentsList(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task" doc:"The id of the task whose attachments to list."`
|
||||
ListParams
|
||||
}) (*taskAttachmentListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, total, err := handler.DoReadAll(ctx, &models.TaskAttachment{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
items, ok := result.([]*models.TaskAttachment)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("taskAttachments.ReadAll returned unexpected type %T (expected []*models.TaskAttachment)", result)
|
||||
}
|
||||
return &taskAttachmentListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
|
||||
}
|
||||
|
||||
// taskAttachmentsUpload owns auth, the session and the permission check because
|
||||
// there is no handler.Do* for multipart uploads (see the api-v2-routes skill's
|
||||
// "Non-CRUDable / custom routes" section).
|
||||
func taskAttachmentsUpload(ctx context.Context, in *taskAttachmentUploadInput) (*taskAttachmentUploadBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
formFiles := in.RawBody.Data().Files
|
||||
uploads := make([]*models.AttachmentToUpload, 0, len(formFiles))
|
||||
for _, file := range formFiles {
|
||||
uploads = append(uploads, &models.AttachmentToUpload{Reader: file, Filename: file.Filename, Size: uint64(file.Size)})
|
||||
}
|
||||
|
||||
success, failures, err := models.UploadTaskAttachments(s, a, in.TaskID, uploads)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return &taskAttachmentUploadBody{Body: webfiles.BuildUploadResult(success, failures)}, nil
|
||||
}
|
||||
|
||||
// taskAttachmentsDownload owns auth, the session and the permission check; there is
|
||||
// no handler.Do* for a file body. It loads the attachment, then streams the bytes
|
||||
// from the StreamResponse callback (no buffering — attachments can be large).
|
||||
func taskAttachmentsDownload(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."`
|
||||
AttachmentID int64 `path:"attachment" doc:"The id of the attachment to download."`
|
||||
PreviewSize string `query:"preview_size" enum:"sm,md,lg,xl" doc:"If set and the attachment is an image, return a downscaled PNG preview instead of the original: sm=100px, md=200px, lg=400px, xl=800px. Ignored for non-image attachments."`
|
||||
}) (*huma.StreamResponse, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
previewSize := models.GetPreviewSizeFromString(in.PreviewSize)
|
||||
ta, preview, err := models.LoadTaskAttachmentForDownload(s, a, in.TaskID, in.AttachmentID, previewSize)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
// The file reader comes from object storage, not the DB session, so it stays
|
||||
// valid after the commit; the StreamResponse callback runs after this returns.
|
||||
if err := s.Commit(); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return &huma.StreamResponse{Body: func(hctx huma.Context) {
|
||||
c := humaecho5.Unwrap(hctx)
|
||||
webfiles.WriteAttachmentDownload((*c).Response(), (*c).Request(), ta, preview)
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func taskAttachmentsDelete(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."`
|
||||
AttachmentID int64 `path:"attachment" doc:"The id of the attachment to delete."`
|
||||
}) (*emptyBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.DoDelete(ctx, &models.TaskAttachment{ID: in.AttachmentID, TaskID: in.TaskID}, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterTaskBucketRoutes wires the kanban task-bucket move onto the Huma API.
|
||||
//
|
||||
// TaskBucket exposes only Update, so the handler reuses handler.DoUpdate (its
|
||||
// CanUpdate enforces write access on the bucket's project). The bucket and view
|
||||
// come from the path; only the task id is read from the body.
|
||||
func RegisterTaskBucketRoutes(api huma.API) {
|
||||
tags := []string{"projects"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "task-bucket-update",
|
||||
Summary: "Place a task in a kanban bucket",
|
||||
Description: "Moves a task into the given bucket of a project's kanban view. Requires write access to the project. " +
|
||||
"Idempotent: re-sending the same bucket is a no-op. Side effects: moving a task into the view's done bucket marks it done (and out of it un-marks it); a repeating task moved into the done bucket is reopened and routed back to the default bucket instead. " +
|
||||
"Moving a task into a bucket that is already at its task limit is rejected with 412. A bucket that does not resolve under the project and view in the path is rejected with 404.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/projects/{project}/views/{view}/buckets/{bucket}/tasks",
|
||||
Tags: tags,
|
||||
}, taskBucketUpdate)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskBucketRoutes) }
|
||||
|
||||
func taskBucketUpdate(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"project"`
|
||||
ViewID int64 `path:"view"`
|
||||
BucketID int64 `path:"bucket"`
|
||||
Body models.TaskBucket
|
||||
}) (*singleBody[models.TaskBucket], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tb := &in.Body
|
||||
tb.ProjectID = in.ProjectID // URL wins over body
|
||||
tb.ProjectViewID = in.ViewID // URL wins over body
|
||||
tb.BucketID = in.BucketID // URL wins over body
|
||||
if err := handler.DoUpdate(ctx, tb, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.TaskBucket]{Body: tb}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterTaskPositionRoutes wires the task-position update onto the Huma API.
|
||||
//
|
||||
// Setting a position is a plain CRUDable Update, so the handler reuses
|
||||
// handler.DoUpdate (its CanUpdate delegates to the task's CanUpdate); the only
|
||||
// custom part is taking TaskID from the path rather than the request body.
|
||||
func RegisterTaskPositionRoutes(api huma.API) {
|
||||
tags := []string{"tasks"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-position-update",
|
||||
Summary: "Set a task's position in a view",
|
||||
Description: "Sets where a task sorts within one of its project's views. The position is per view, so this only affects the view named by project_view_id. Requires write access to the task. Positions below the minimum spacing make the server recalculate every position in the view, so the returned value may differ from the one sent.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/tasks/{task}/position",
|
||||
Tags: tags,
|
||||
}, tasksPositionUpdate)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskPositionRoutes) }
|
||||
|
||||
func tasksPositionUpdate(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task" doc:"The numeric id of the task whose position to set."`
|
||||
Body models.TaskPosition
|
||||
}) (*singleBody[models.TaskPosition], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tp := &in.Body
|
||||
tp.TaskID = in.TaskID // URL wins over body
|
||||
if err := handler.DoUpdate(ctx, tp, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.TaskPosition]{Body: tp}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterTaskRelationRoutes wires task-relation create/delete onto the Huma API.
|
||||
//
|
||||
// Both operations reuse handler.DoCreate/DoDelete; CanCreate enforces write on
|
||||
// the base task + read on the other task and rejects invalid kinds, CanDelete
|
||||
// enforces write on the base task. The only custom part is mapping the path
|
||||
// segments onto the model.
|
||||
func RegisterTaskRelationRoutes(api huma.API) {
|
||||
tags := []string{"tasks"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-relations-create",
|
||||
Summary: "Create a task relation",
|
||||
Description: "Relates two tasks. The authenticated user needs write access to the base task (in the path) and at least read access to the other task; the two tasks need not share a project. The inverse relation is created automatically (e.g. a subtask relation also stores the parenttask relation on the other task). Subtask/parenttask chains that would form a cycle are rejected.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/tasks/{task}/relations",
|
||||
Tags: tags,
|
||||
}, tasksRelationsCreate)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-relations-delete",
|
||||
Summary: "Delete a task relation",
|
||||
Description: "Removes the relation identified by the base task, relation kind and other task. The automatically created inverse relation is removed as well. The authenticated user needs write access to the base task.",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/tasks/{task}/relations/{relationKind}/{otherTask}",
|
||||
Tags: tags,
|
||||
}, tasksRelationsDelete)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskRelationRoutes) }
|
||||
|
||||
func tasksRelationsCreate(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task" doc:"The numeric id of the base task to relate from."`
|
||||
Body models.TaskRelation
|
||||
}) (*singleBody[models.TaskRelation], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := &in.Body
|
||||
rel.TaskID = in.TaskID // URL wins over body
|
||||
if err := handler.DoCreate(ctx, rel, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.TaskRelation]{Body: rel}, nil
|
||||
}
|
||||
|
||||
// The relationKind enum mirrors models.TaskRelation.RelationKind's tag (see the sync note there).
|
||||
func tasksRelationsDelete(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task" doc:"The numeric id of the base task."`
|
||||
RelationKind models.RelationKind `path:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of the relation to remove."`
|
||||
OtherTaskID int64 `path:"otherTask" doc:"The numeric id of the other task in the relation."`
|
||||
}) (*emptyBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := &models.TaskRelation{
|
||||
TaskID: in.TaskID,
|
||||
RelationKind: in.RelationKind,
|
||||
OtherTaskID: in.OtherTaskID,
|
||||
}
|
||||
if err := handler.DoDelete(ctx, rel, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// taskReadBody confirms the mark-read action: the underlying model carries no
|
||||
// JSON-exposed fields, so it returns a status message rather than a resource.
|
||||
type taskReadBody struct {
|
||||
Body struct {
|
||||
Message string `json:"message" readOnly:"true" doc:"A confirmation message."`
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterTaskUnreadStatusRoutes wires the mark-task-as-read action onto the Huma API.
|
||||
//
|
||||
// Marking a task read clears the caller's unread entry for it, which is what
|
||||
// drives the per-task "unread" dot shown for mentions and other notifications.
|
||||
// The model's Update deletes that entry, so the action is idempotent — PUT, not
|
||||
// POST. It is also unconditional: there is no read entry to clear for a task the
|
||||
// caller cannot see, so it succeeds as a no-op rather than refusing.
|
||||
func RegisterTaskUnreadStatusRoutes(api huma.API) {
|
||||
tags := []string{"tasks"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-mark-read",
|
||||
Summary: "Mark a task as read",
|
||||
Description: "Clears the authenticated user's unread status for a task, dismissing the unread indicator raised by mentions and other task notifications. Idempotent: marking an already-read or inaccessible task succeeds as a no-op.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/tasks/{projecttask}/read",
|
||||
Tags: tags,
|
||||
}, tasksMarkRead)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskUnreadStatusRoutes) }
|
||||
|
||||
func tasksMarkRead(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"projecttask" doc:"The numeric id of the task to mark as read."`
|
||||
}) (*taskReadBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &models.TaskUnreadStatus{TaskID: in.TaskID}
|
||||
if err := handler.DoUpdate(ctx, t, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
out := &taskReadBody{}
|
||||
out.Body.Message = "success"
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/conditional"
|
||||
)
|
||||
|
||||
// expandDoc lists the accepted expand values; shared between the by-id and
|
||||
// by-index operations so the docs stay in sync.
|
||||
const expandDoc = "Embed extra, more expensive data in each task. Repeatable. One of: subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread. Expanding can return more tasks than the page limit (subtasks) and inflate the response."
|
||||
|
||||
// parseTaskExpand turns the raw `expand` query values into validated
|
||||
// TaskCollectionExpandable entries. Kept package-level for the TaskCollection
|
||||
// list endpoint, which accepts the same option. An invalid value returns the
|
||||
// model's own validation error, which translateDomainError maps to 422.
|
||||
func parseTaskExpand(raw []string) ([]models.TaskCollectionExpandable, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
expand := make([]models.TaskCollectionExpandable, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
v := models.TaskCollectionExpandable(e)
|
||||
if err := v.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expand = append(expand, v)
|
||||
}
|
||||
return expand, nil
|
||||
}
|
||||
|
||||
// RegisterTaskRoutes wires Task CRUD onto the Huma API. The list lives on
|
||||
// TaskCollection, not here.
|
||||
func RegisterTaskRoutes(api huma.API) {
|
||||
tags := []string{"tasks"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-read",
|
||||
Summary: "Get a task",
|
||||
Description: "Returns a single task by its numeric id. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified. " + expandDoc,
|
||||
Method: "GET",
|
||||
Path: "/tasks/{projecttask}",
|
||||
Tags: tags,
|
||||
}, tasksRead)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-read-by-index",
|
||||
Summary: "Get a task by its project index",
|
||||
Description: "Returns a single task addressed by its per-project index. The {project} segment accepts either a numeric project id or a textual project identifier (e.g. \"PROJ\"); a value made solely of digits is always treated as an id. " + expandDoc,
|
||||
Method: "GET",
|
||||
Path: "/projects/{project}/tasks/by-index/{index}",
|
||||
Tags: tags,
|
||||
}, tasksReadByIndex)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-create",
|
||||
Summary: "Create a task",
|
||||
Description: "Creates a task in the project from the URL. The authenticated user needs write access to that project and becomes the task's creator.",
|
||||
Method: "POST",
|
||||
Path: "/projects/{project}/tasks",
|
||||
Tags: tags,
|
||||
}, tasksCreate)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-update",
|
||||
Summary: "Update a task",
|
||||
Description: "Replaces all of a task's fields; requires write access. Setting project_id to a different project moves the task and also requires write access to the target project. Use PATCH for a partial update.",
|
||||
Method: "PUT",
|
||||
Path: "/tasks/{projecttask}",
|
||||
Tags: tags,
|
||||
}, tasksUpdate)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-delete",
|
||||
Summary: "Delete a task",
|
||||
Description: "Deletes a task. Requires write access to its project.",
|
||||
Method: "DELETE",
|
||||
Path: "/tasks/{projecttask}",
|
||||
Tags: tags,
|
||||
}, tasksDelete)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskRoutes) }
|
||||
|
||||
type taskReadOneBody struct {
|
||||
models.Task
|
||||
MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this task (0=read, 1=read/write, 2=admin)."`
|
||||
}
|
||||
|
||||
func tasksRead(ctx context.Context, in *struct {
|
||||
ID int64 `path:"projecttask" doc:"The numeric id of the task."`
|
||||
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."`
|
||||
conditional.Params
|
||||
}) (*singleReadBody[taskReadOneBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expand, err := parseTaskExpand(in.Expand)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
task := &models.Task{ID: in.ID, Expand: expand}
|
||||
maxPermission, err := handler.DoReadOne(ctx, task, a)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
|
||||
return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
|
||||
}
|
||||
|
||||
func tasksReadByIndex(ctx context.Context, in *struct {
|
||||
Project string `path:"project" doc:"A numeric project id or a textual project identifier (e.g. \"PROJ\")."`
|
||||
Index int64 `path:"index" doc:"The per-project task index."`
|
||||
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."`
|
||||
conditional.Params
|
||||
}) (*singleReadBody[taskReadOneBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expand, err := parseTaskExpand(in.Expand)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
projectID, err := resolveProjectIdentifier(in.Project)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ID 0 + ProjectID + Index makes the model resolve the id from the
|
||||
// (project, index) pair in both CanRead and ReadOne.
|
||||
task := &models.Task{ProjectID: projectID, Index: in.Index, Expand: expand}
|
||||
maxPermission, err := handler.DoReadOne(ctx, task, a)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
|
||||
return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
|
||||
}
|
||||
|
||||
func tasksCreate(ctx context.Context, in *struct {
|
||||
Project int64 `path:"project" doc:"The numeric id of the project to create the task in."`
|
||||
Body models.Task
|
||||
}) (*singleBody[models.Task], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &in.Body
|
||||
task.ProjectID = in.Project // URL wins over body
|
||||
if err := handler.DoCreate(ctx, task, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.Task]{Body: task}, nil
|
||||
}
|
||||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func tasksUpdate(ctx context.Context, in *struct {
|
||||
ID int64 `path:"projecttask"`
|
||||
Body taskReadOneBody
|
||||
}) (*singleBody[models.Task], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &in.Body.Task
|
||||
task.ID = in.ID // URL wins over body
|
||||
if err := handler.DoUpdate(ctx, task, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.Task]{Body: task}, nil
|
||||
}
|
||||
|
||||
func tasksDelete(ctx context.Context, in *struct {
|
||||
ID int64 `path:"projecttask"`
|
||||
}) (*emptyBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.DoDelete(ctx, &models.Task{ID: in.ID}, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
||||
// resolveProjectIdentifier turns the {project} path segment into a numeric
|
||||
// project id. A pure-digit value is always an id (mirroring v1's
|
||||
// ResolveProjectIdentifier middleware); anything else is looked up as a
|
||||
// case-insensitive identifier and 404s if unknown.
|
||||
func resolveProjectIdentifier(raw string) (int64, error) {
|
||||
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
project, err := models.GetProjectSimpleByIdentifier(s, raw)
|
||||
if err != nil {
|
||||
return 0, translateDomainError(err)
|
||||
}
|
||||
return project.ID, nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The password was updated successfully."}}, nil
|
||||
}
|
||||
|
||||
func userUpdateEmail(ctx context.Context, in *struct {
|
||||
Body struct {
|
||||
NewEmail string `json:"new_email" valid:"email,length(0|250),required" maxLength:"250" doc:"The new email address."`
|
||||
Password string `json:"password" doc:"The current password, for confirmation."`
|
||||
}
|
||||
}) (*singleBody[userActionMessageBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doer, err := user.GetFromAuth(a)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
if err := user.ChangeUserEmail(s, doer, in.Body.Password, in.Body.NewEmail); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "We sent you email with a link to confirm your email address."}}, nil
|
||||
}
|
||||
|
||||
func userUpdateSettings(ctx context.Context, in *struct {
|
||||
Body models.UserGeneralSettings
|
||||
}) (*singleBody[userActionMessageBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doer, err := user.GetFromAuth(a)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID})
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := models.UpdateUserGeneralSettings(s, u, &in.Body); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The settings were updated successfully."}}, nil
|
||||
}
|
||||
|
||||
func userGetAvatarProvider(ctx context.Context, _ *struct{}) (*singleBody[userAvatarProviderBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doer, err := user.GetFromAuth(a)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID})
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil
|
||||
}
|
||||
|
||||
func userSetAvatarProvider(ctx context.Context, in *struct {
|
||||
Body userAvatarProviderBody
|
||||
}) (*singleBody[userAvatarProviderBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doer, err := user.GetFromAuth(a)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID})
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := models.UpdateUserAvatarProvider(s, u, in.Body.AvatarProvider); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil
|
||||
}
|
||||
|
||||
type timezonesBody struct {
|
||||
Body []string
|
||||
}
|
||||
|
||||
func userTimezones(ctx context.Context, _ *struct{}) (*timezonesBody, error) {
|
||||
if _, err := authFromCtx(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timezoneMap := make(map[string]bool) // de-dupe across the per-abbreviation groups
|
||||
for _, group := range timezone.New().Timezones() {
|
||||
for _, t := range group {
|
||||
timezoneMap[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
ts := make([]string, 0, len(timezoneMap))
|
||||
for t := range timezoneMap {
|
||||
ts = append(ts, t)
|
||||
}
|
||||
|
||||
return &timezonesBody{Body: ts}, nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -7848,7 +7848,7 @@ const docTemplate = `{
|
|||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.UserSettings"
|
||||
"$ref": "#/definitions/models.UserGeneralSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
@ -9876,7 +9876,8 @@ const docTemplate = `{
|
|||
},
|
||||
"value": {
|
||||
"description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -10373,7 +10374,7 @@ const docTemplate = `{
|
|||
"type": "integer"
|
||||
},
|
||||
"relation_kind": {
|
||||
"description": "The kind of the relation.",
|
||||
"description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.RelationKind"
|
||||
|
|
@ -10629,6 +10630,49 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"models.UserGeneralSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default_project_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"discoverable_by_email": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"discoverable_by_name": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"email_reminders_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"extra_settings_links": {
|
||||
"description": "Server/OpenID-provided; populated on read, ignored on write.",
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"frontend_settings": {},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"overdue_tasks_reminders_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"overdue_tasks_reminders_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"week_start": {
|
||||
"type": "integer",
|
||||
"maximum": 6,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.UserWithPermission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -11026,59 +11070,6 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"v1.UserSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default_project_id": {
|
||||
"description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.",
|
||||
"type": "integer"
|
||||
},
|
||||
"discoverable_by_email": {
|
||||
"description": "If true, the user can be found when searching for their exact email.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"discoverable_by_name": {
|
||||
"description": "If true, this user can be found by their name or parts of it when searching for it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"email_reminders_enabled": {
|
||||
"description": "If enabled, sends email reminders of tasks to the user.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"extra_settings_links": {
|
||||
"description": "Additional settings links as provided by openid",
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"frontend_settings": {
|
||||
"description": "Additional settings only used by the frontend"
|
||||
},
|
||||
"language": {
|
||||
"description": "The user's language",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The new name of the current user.",
|
||||
"type": "string"
|
||||
},
|
||||
"overdue_tasks_reminders_enabled": {
|
||||
"description": "If enabled, the user will get an email for their overdue tasks each morning.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"overdue_tasks_reminders_time": {
|
||||
"description": "The time when the daily summary of overdue tasks will be sent via email.",
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "The user's time zone. Used to send task reminders in the time zone of the user.",
|
||||
"type": "string"
|
||||
},
|
||||
"week_start": {
|
||||
"description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.UserWithSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -11116,7 +11107,7 @@ const docTemplate = `{
|
|||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/definitions/v1.UserSettings"
|
||||
"$ref": "#/definitions/models.UserGeneralSettings"
|
||||
},
|
||||
"updated": {
|
||||
"description": "A timestamp when this task was last updated. You cannot change this value.",
|
||||
|
|
|
|||
|
|
@ -7840,7 +7840,7 @@
|
|||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.UserSettings"
|
||||
"$ref": "#/definitions/models.UserGeneralSettings"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
@ -9868,7 +9868,8 @@
|
|||
},
|
||||
"value": {
|
||||
"description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"maxLength": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -10365,7 +10366,7 @@
|
|||
"type": "integer"
|
||||
},
|
||||
"relation_kind": {
|
||||
"description": "The kind of the relation.",
|
||||
"description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.RelationKind"
|
||||
|
|
@ -10621,6 +10622,49 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"models.UserGeneralSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default_project_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"discoverable_by_email": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"discoverable_by_name": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"email_reminders_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"extra_settings_links": {
|
||||
"description": "Server/OpenID-provided; populated on read, ignored on write.",
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"frontend_settings": {},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"overdue_tasks_reminders_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"overdue_tasks_reminders_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"week_start": {
|
||||
"type": "integer",
|
||||
"maximum": 6,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.UserWithPermission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -11018,59 +11062,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"v1.UserSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default_project_id": {
|
||||
"description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.",
|
||||
"type": "integer"
|
||||
},
|
||||
"discoverable_by_email": {
|
||||
"description": "If true, the user can be found when searching for their exact email.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"discoverable_by_name": {
|
||||
"description": "If true, this user can be found by their name or parts of it when searching for it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"email_reminders_enabled": {
|
||||
"description": "If enabled, sends email reminders of tasks to the user.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"extra_settings_links": {
|
||||
"description": "Additional settings links as provided by openid",
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"frontend_settings": {
|
||||
"description": "Additional settings only used by the frontend"
|
||||
},
|
||||
"language": {
|
||||
"description": "The user's language",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The new name of the current user.",
|
||||
"type": "string"
|
||||
},
|
||||
"overdue_tasks_reminders_enabled": {
|
||||
"description": "If enabled, the user will get an email for their overdue tasks each morning.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"overdue_tasks_reminders_time": {
|
||||
"description": "The time when the daily summary of overdue tasks will be sent via email.",
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "The user's time zone. Used to send task reminders in the time zone of the user.",
|
||||
"type": "string"
|
||||
},
|
||||
"week_start": {
|
||||
"description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.UserWithSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -11108,7 +11099,7 @@
|
|||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/definitions/v1.UserSettings"
|
||||
"$ref": "#/definitions/models.UserGeneralSettings"
|
||||
},
|
||||
"updated": {
|
||||
"description": "A timestamp when this task was last updated. You cannot change this value.",
|
||||
|
|
|
|||
|
|
@ -741,6 +741,7 @@ definitions:
|
|||
value:
|
||||
description: The actual reaction. This can be any valid utf character or text,
|
||||
up to a length of 20.
|
||||
maxLength: 20
|
||||
type: string
|
||||
type: object
|
||||
models.ReactionMap:
|
||||
|
|
@ -1134,7 +1135,9 @@ definitions:
|
|||
relation_kind:
|
||||
allOf:
|
||||
- $ref: '#/definitions/models.RelationKind'
|
||||
description: The kind of the relation.
|
||||
description: |-
|
||||
The kind of the relation.
|
||||
The enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.
|
||||
task_id:
|
||||
description: The ID of the "base" task, the task which has a relation to another.
|
||||
type: integer
|
||||
|
|
@ -1330,6 +1333,36 @@ definitions:
|
|||
this value.
|
||||
type: string
|
||||
type: object
|
||||
models.UserGeneralSettings:
|
||||
properties:
|
||||
default_project_id:
|
||||
type: integer
|
||||
discoverable_by_email:
|
||||
type: boolean
|
||||
discoverable_by_name:
|
||||
type: boolean
|
||||
email_reminders_enabled:
|
||||
type: boolean
|
||||
extra_settings_links:
|
||||
additionalProperties: {}
|
||||
description: Server/OpenID-provided; populated on read, ignored on write.
|
||||
type: object
|
||||
frontend_settings: {}
|
||||
language:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
overdue_tasks_reminders_enabled:
|
||||
type: boolean
|
||||
overdue_tasks_reminders_time:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
week_start:
|
||||
maximum: 6
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
models.UserWithPermission:
|
||||
properties:
|
||||
bot_owner_id:
|
||||
|
|
@ -1637,53 +1670,6 @@ definitions:
|
|||
minLength: 3
|
||||
type: string
|
||||
type: object
|
||||
v1.UserSettings:
|
||||
properties:
|
||||
default_project_id:
|
||||
description: |-
|
||||
If a task is created without a specified project this value should be used. Applies
|
||||
to tasks made directly in API and from clients.
|
||||
type: integer
|
||||
discoverable_by_email:
|
||||
description: If true, the user can be found when searching for their exact
|
||||
email.
|
||||
type: boolean
|
||||
discoverable_by_name:
|
||||
description: If true, this user can be found by their name or parts of it
|
||||
when searching for it.
|
||||
type: boolean
|
||||
email_reminders_enabled:
|
||||
description: If enabled, sends email reminders of tasks to the user.
|
||||
type: boolean
|
||||
extra_settings_links:
|
||||
additionalProperties: {}
|
||||
description: Additional settings links as provided by openid
|
||||
type: object
|
||||
frontend_settings:
|
||||
description: Additional settings only used by the frontend
|
||||
language:
|
||||
description: The user's language
|
||||
type: string
|
||||
name:
|
||||
description: The new name of the current user.
|
||||
type: string
|
||||
overdue_tasks_reminders_enabled:
|
||||
description: If enabled, the user will get an email for their overdue tasks
|
||||
each morning.
|
||||
type: boolean
|
||||
overdue_tasks_reminders_time:
|
||||
description: The time when the daily summary of overdue tasks will be sent
|
||||
via email.
|
||||
type: string
|
||||
timezone:
|
||||
description: The user's time zone. Used to send task reminders in the time
|
||||
zone of the user.
|
||||
type: string
|
||||
week_start:
|
||||
description: The day when the week starts for this user. 0 = sunday, 1 = monday,
|
||||
etc.
|
||||
type: integer
|
||||
type: object
|
||||
v1.UserWithSettings:
|
||||
properties:
|
||||
auth_provider:
|
||||
|
|
@ -1714,7 +1700,7 @@ definitions:
|
|||
description: The full name of the user.
|
||||
type: string
|
||||
settings:
|
||||
$ref: '#/definitions/v1.UserSettings'
|
||||
$ref: '#/definitions/models.UserGeneralSettings'
|
||||
updated:
|
||||
description: A timestamp when this task was last updated. You cannot change
|
||||
this value.
|
||||
|
|
@ -7196,7 +7182,7 @@ paths:
|
|||
name: avatar
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/v1.UserSettings'
|
||||
$ref: '#/definitions/models.UserGeneralSettings'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
|
|
|||
|
|
@ -41,12 +41,12 @@ const (
|
|||
|
||||
// Token is a token a user can use to do things like verify their email or resetting their password
|
||||
type Token struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this token."`
|
||||
UserID int64 `xorm:"not null" json:"-"`
|
||||
Token string `xorm:"varchar(450) not null index" json:"-"`
|
||||
ClearTextToken string `xorm:"-" json:"token"`
|
||||
ClearTextToken string `xorm:"-" json:"token" readOnly:"true" doc:"The token in clear text. Only returned once when the token is created; never on subsequent reads."`
|
||||
Kind TokenKind `xorm:"not null" json:"-"`
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this token was created. You cannot change this value."`
|
||||
}
|
||||
|
||||
// TableName returns the real table name for user tokens
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ import (
|
|||
type TOTP struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"`
|
||||
UserID int64 `xorm:"bigint not null" json:"-"`
|
||||
Secret string `xorm:"text not null" json:"secret"`
|
||||
Secret string `xorm:"text not null" json:"secret" readOnly:"true" doc:"The shared secret used to generate passcodes, generated by the server on enrollment."`
|
||||
// The totp entry will only be enabled after the user verified they have a working totp setup.
|
||||
Enabled bool `xorm:"null" json:"enabled"`
|
||||
Enabled bool `xorm:"null" json:"enabled" readOnly:"true" doc:"Whether totp is fully activated. Set to true only after the user confirms a passcode."`
|
||||
// The totp url used to be able to enroll the user later
|
||||
URL string `xorm:"text null" json:"url"`
|
||||
URL string `xorm:"text null" json:"url" readOnly:"true" doc:"The otpauth:// url, generated by the server, used to enroll the user in an authenticator app."`
|
||||
}
|
||||
|
||||
// TableName holds the table name for totp secrets
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@ type EmailUpdate struct {
|
|||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// ChangeUserEmail verifies the user's password, then sets a new email address
|
||||
// (kicking off confirmation when the mailer is enabled). Shared by the v1 and
|
||||
// v2 email-update handlers; only HTTP input binding stays in the handlers.
|
||||
func ChangeUserEmail(s *xorm.Session, u *User, password, newEmail string) error {
|
||||
verified, err := CheckUserCredentials(s, &Login{Username: u.Username, Password: password})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return UpdateEmail(s, &EmailUpdate{User: verified, NewEmail: newEmail})
|
||||
}
|
||||
|
||||
// UpdateEmail lets a user update their email address
|
||||
func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
// 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 files holds the HTTP-layer glue for serving task attachments —
|
||||
// the upload-result DTOs and the download response writer — shared by the
|
||||
// v1 and v2 handlers. The domain logic stays in pkg/models; this package
|
||||
// only translates it to and from the wire.
|
||||
package files
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
)
|
||||
|
||||
// AttachmentUploadError is a per-file upload failure.
|
||||
type AttachmentUploadError struct {
|
||||
Code int `json:"code,omitempty" doc:"Vikunja numeric error code, when the failure carries one."`
|
||||
Message string `json:"message" doc:"A human-readable description of why this file failed."`
|
||||
}
|
||||
|
||||
// AttachmentUploadResult is the outcome of an attachment upload: files are
|
||||
// processed independently, so a per-file failure lands in Errors while the
|
||||
// rest still succeed.
|
||||
type AttachmentUploadResult struct {
|
||||
Errors []AttachmentUploadError `json:"errors" doc:"Per-file failures. A file that fails here does not fail the whole request; the others still upload."`
|
||||
Success []*models.TaskAttachment `json:"success" doc:"The attachments that were created successfully."`
|
||||
}
|
||||
|
||||
// BuildUploadResult turns the domain function's plain return values into the
|
||||
// wire DTO, mapping each failure to its numeric code when it carries one.
|
||||
func BuildUploadResult(success []*models.TaskAttachment, failures []error) *AttachmentUploadResult {
|
||||
r := &AttachmentUploadResult{Success: success}
|
||||
for _, err := range failures {
|
||||
r.Errors = append(r.Errors, toAttachmentUploadError(err))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func toAttachmentUploadError(err error) AttachmentUploadError {
|
||||
if httpErr, ok := err.(web.HTTPErrorProcessor); ok {
|
||||
details := httpErr.HTTPError()
|
||||
return AttachmentUploadError{Code: details.Code, Message: details.Message}
|
||||
}
|
||||
return AttachmentUploadError{Message: err.Error()}
|
||||
}
|
||||
|
||||
// WriteAttachmentDownload streams the attachment (or its preview) to the response:
|
||||
// http.ServeContent for seekable local files (Range + If-Modified-Since for free),
|
||||
// a manual 304 + io.Copy otherwise. It closes the file reader.
|
||||
func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) {
|
||||
defer func() { _ = ta.File.File.Close() }()
|
||||
|
||||
if preview != nil {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(preview)))
|
||||
_, _ = w.Write(preview)
|
||||
return
|
||||
}
|
||||
|
||||
mimeToReturn := ta.File.Mime
|
||||
if mimeToReturn == "" {
|
||||
mimeToReturn = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": ta.File.Name}))
|
||||
w.Header().Set("Content-Type", mimeToReturn)
|
||||
w.Header().Set("Content-Length", strconv.FormatUint(ta.File.Size, 10))
|
||||
w.Header().Set("Last-Modified", ta.File.Created.UTC().Format(http.TimeFormat))
|
||||
// Override the global no-store directive so browsers can cache attachments.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
// Local files are *os.File (seekable), so ServeContent gives Range +
|
||||
// If-Modified-Since for free; s3 (and the in-memory test storage) return a
|
||||
// non-seekable reader, so check If-Modified-Since manually and io.Copy.
|
||||
if seeker, ok := ta.File.File.(io.ReadSeeker); ok {
|
||||
http.ServeContent(w, r, ta.File.Name, ta.File.Created, seeker)
|
||||
return
|
||||
}
|
||||
|
||||
if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" {
|
||||
if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !ta.File.Created.UTC().After(t) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = io.Copy(w, ta.File.File)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildUploadResult(t *testing.T) {
|
||||
t.Run("maps a domain error to its numeric code", func(t *testing.T) {
|
||||
// ErrTaskAttachmentIsTooLarge is an HTTPErrorProcessor, so its Code must surface.
|
||||
r := BuildUploadResult(nil, []error{models.ErrTaskAttachmentIsTooLarge{Size: 99}})
|
||||
assert.Empty(t, r.Success)
|
||||
if assert.Len(t, r.Errors, 1) {
|
||||
assert.Equal(t, models.ErrCodeTaskAttachmentIsTooLarge, r.Errors[0].Code)
|
||||
assert.NotEmpty(t, r.Errors[0].Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain error has no code, just the message", func(t *testing.T) {
|
||||
r := BuildUploadResult(nil, []error{errors.New("boom")})
|
||||
if assert.Len(t, r.Errors, 1) {
|
||||
assert.Zero(t, r.Errors[0].Code)
|
||||
assert.Equal(t, "boom", r.Errors[0].Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves success and failure order", func(t *testing.T) {
|
||||
success := []*models.TaskAttachment{{ID: 1}, {ID: 2}}
|
||||
r := BuildUploadResult(success, []error{errors.New("first"), errors.New("second")})
|
||||
assert.Equal(t, success, r.Success)
|
||||
if assert.Len(t, r.Errors, 2) {
|
||||
assert.Equal(t, "first", r.Errors[0].Message)
|
||||
assert.Equal(t, "second", r.Errors[1].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBulkTaskV2 covers PUT /tasks/bulk. It drives the Echo+Huma stack directly
|
||||
// (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL only models
|
||||
// base[/{id}] paths, not action sub-paths.
|
||||
func TestBulkTaskV2(t *testing.T) {
|
||||
t.Run("updates multiple tasks the user can write", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Tasks 1 and 2 both live in project 1, which testuser1 owns.
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk",
|
||||
`{"task_ids":[1,2],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
db.AssertExists(t, "tasks", map[string]interface{}{"id": 1, "title": "bulkupdated"}, false)
|
||||
db.AssertExists(t, "tasks", map[string]interface{}{"id": 2, "title": "bulkupdated"}, false)
|
||||
})
|
||||
|
||||
t.Run("forbidden when missing write on one involved project", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Task 1 is in project 1 (owned), task 32 in project 3 (read-only share).
|
||||
// CanUpdate fans the write check across both projects, so the whole
|
||||
// request is rejected and neither task changes.
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk",
|
||||
`{"task_ids":[1,32],"fields":["title"],"values":{"title":"shouldnothappen"}}`, token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
db.AssertMissing(t, "tasks", map[string]interface{}{"title": "shouldnothappen"})
|
||||
})
|
||||
|
||||
t.Run("empty task_ids is rejected", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk",
|
||||
`{"task_ids":[],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "")
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBulkTasksNeedAtLeastOne), "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestHumaCalDAVToken covers the v2 CalDAV token lifecycle. All calls share one
|
||||
// echo env because setupTestEnv rotates the JWT signing key per call, which would
|
||||
// 401 a token minted against an earlier env.
|
||||
//
|
||||
// Fixture (pkg/db/fixtures/user_tokens.yml): token id 6, kind 4 (CalDAV),
|
||||
// belongs to user10. user1 starts with no CalDAV tokens.
|
||||
func TestHumaCalDAVToken(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
user1Token := humaTokenFor(t, &testuser1)
|
||||
user10Token := humaTokenFor(t, &testuser10)
|
||||
|
||||
t.Run("Create returns the clear-text token", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", user1Token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created), "body: %s", rec.Body.String())
|
||||
assert.NotZero(t, created.ID)
|
||||
assert.NotEmpty(t, created.Token, "the clear-text token must be returned on create")
|
||||
})
|
||||
|
||||
t.Run("List omits the token value", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
ids := caldavTokenIDsFromList(t, rec.Body.Bytes())
|
||||
assert.NotEmpty(t, ids, "the token created above must show up in the list")
|
||||
assert.Empty(t, caldavTokenValuesFromList(t, rec.Body.Bytes()),
|
||||
"the clear-text token must never appear in the list; body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("List is scoped to the current user", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6),
|
||||
"user10's fixture token #6 must be listed; body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Delete removes the token", func(t *testing.T) {
|
||||
listRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "")
|
||||
require.Equal(t, http.StatusOK, listRec.Code, "body: %s", listRec.Body.String())
|
||||
ids := caldavTokenIDsFromList(t, listRec.Body.Bytes())
|
||||
require.NotEmpty(t, ids)
|
||||
|
||||
del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/"+strconv.FormatInt(ids[0], 10), "", user1Token, "")
|
||||
require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String())
|
||||
|
||||
afterRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "")
|
||||
require.Equal(t, http.StatusOK, afterRec.Code, "body: %s", afterRec.Body.String())
|
||||
assert.NotContains(t, caldavTokenIDsFromList(t, afterRec.Body.Bytes()), ids[0],
|
||||
"the deleted token must be gone; body: %s", afterRec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Delete is scoped to the current user", func(t *testing.T) {
|
||||
// Token #6 belongs to user10; user1 deleting it is a no-op (204), not an error.
|
||||
del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", user1Token, "")
|
||||
require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String())
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6),
|
||||
"user10's token #6 must survive a delete attempt by another user; body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestHumaCalDAVToken_LinkShareForbidden ports v1's implicit guard: a link share
|
||||
// is not a user, so create / list / delete all refuse it (403).
|
||||
func TestHumaCalDAVToken_LinkShareForbidden(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{
|
||||
ID: 1,
|
||||
Hash: "test",
|
||||
ProjectID: 1,
|
||||
Permission: models.PermissionRead,
|
||||
SharingType: models.SharingTypeWithoutPassword,
|
||||
SharedByID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", token, "")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
t.Run("list", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", token, "")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", token, "")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func caldavTokenIDsFromList(t *testing.T, body []byte) []int64 {
|
||||
t.Helper()
|
||||
items := caldavTokenItemsFromList(t, body)
|
||||
ids := make([]int64, 0, len(items))
|
||||
for _, it := range items {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func caldavTokenValuesFromList(t *testing.T, body []byte) []string {
|
||||
t.Helper()
|
||||
values := []string{}
|
||||
for _, it := range caldavTokenItemsFromList(t, body) {
|
||||
if it.Token != "" {
|
||||
values = append(values, it.Token)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func caldavTokenItemsFromList(t *testing.T, body []byte) []struct {
|
||||
ID int64 `json:"id"`
|
||||
Token string `json:"token"`
|
||||
} {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Token string `json:"token"`
|
||||
} `json:"items"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &resp), "list body must be a paginated envelope: %s", string(body))
|
||||
return resp.Items
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLabelTaskBulk_V2 ports the v1 bulk-replace matrix
|
||||
// (pkg/webtests/label_task_test.go) onto PUT /api/v2/tasks/{projecttask}/labels/bulk.
|
||||
// The body is the full target label set; the call adds missing labels and
|
||||
// removes any not listed.
|
||||
//
|
||||
// Permission topology for testuser1 (see pkg/db/fixtures):
|
||||
// - task 1 (project 1): owned by user1 → write. Has label #4 attached.
|
||||
// - task 15 (project 6): shared via team 2 read-only → no write.
|
||||
// - task 16 (project 7): shared via team 3 with write.
|
||||
// - task 34 (project 20): private to user13 → no access.
|
||||
//
|
||||
// Labels: #1 own; #3 (user2, attached to no visible task) is invisible to user1.
|
||||
func TestLabelTaskBulk_V2(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
put := func(taskID, body string) (*v2ProblemJSON, []int64, int) {
|
||||
t.Helper()
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/"+taskID+"/labels/bulk", body, token, "")
|
||||
if rec.Code >= 400 {
|
||||
var p v2ProblemJSON
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p), "error body: %s", rec.Body.String())
|
||||
return &p, nil, rec.Code
|
||||
}
|
||||
var resp struct {
|
||||
Labels []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"labels"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "body: %s", rec.Body.String())
|
||||
ids := make([]int64, 0, len(resp.Labels))
|
||||
for _, l := range resp.Labels {
|
||||
ids = append(ids, l.ID)
|
||||
}
|
||||
return nil, ids, rec.Code
|
||||
}
|
||||
|
||||
t.Run("Replace adds and removes", func(t *testing.T) {
|
||||
// task 1 starts with label #4; replacing with [#1] must add #1 and drop #4.
|
||||
p, ids, code := put("1", `{"labels":[{"id":1}]}`)
|
||||
require.Nil(t, p)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.ElementsMatch(t, []int64{1}, ids,
|
||||
"task 1's labels must be exactly {1} after replace")
|
||||
})
|
||||
t.Run("Empty list clears all labels", func(t *testing.T) {
|
||||
// task 16 (write-shared) gets a label, then an empty replace removes it.
|
||||
_, ids, code := put("16", `{"labels":[{"id":1}]}`)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.ElementsMatch(t, []int64{1}, ids)
|
||||
|
||||
p, ids, code := put("16", `{"labels":[]}`)
|
||||
require.Nil(t, p)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Empty(t, ids, "empty replace must remove every label")
|
||||
})
|
||||
t.Run("Write share can replace", func(t *testing.T) {
|
||||
_, ids, code := put("16", `{"labels":[{"id":1}]}`)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.ElementsMatch(t, []int64{1}, ids)
|
||||
})
|
||||
t.Run("Read-only share is forbidden", func(t *testing.T) {
|
||||
p, _, code := put("15", `{"labels":[{"id":1}]}`)
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
require.NotNil(t, p)
|
||||
})
|
||||
t.Run("Forbidden task", func(t *testing.T) {
|
||||
// task 34 is private to user13.
|
||||
p, _, code := put("34", `{"labels":[{"id":1}]}`)
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
require.NotNil(t, p)
|
||||
})
|
||||
t.Run("Nonexisting task", func(t *testing.T) {
|
||||
p, _, code := put("9999", `{"labels":[{"id":1}]}`)
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
require.NotNil(t, p)
|
||||
assert.Equal(t, models.ErrCodeTaskDoesNotExist, p.Code)
|
||||
})
|
||||
t.Run("Label the user cannot see is rejected", func(t *testing.T) {
|
||||
// label #3 (user2's, attached to no task user1 can see) is invisible to
|
||||
// user1; attaching it to a writable task must be refused.
|
||||
p, _, code := put("1", `{"labels":[{"id":3}]}`)
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
require.NotNil(t, p)
|
||||
assert.Equal(t, models.ErrCodeUserHasNoAccessToLabel, p.Code)
|
||||
})
|
||||
}
|
||||
|
|
@ -24,10 +24,17 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testuser22 is the second bot owner from pkg/db/fixtures/users.yml; user22
|
||||
// owns bot 24. Paired with testuser21 to assert bot-owner isolation: each
|
||||
// owner sees and acts on their own bots' resources, never the other's.
|
||||
var testuser22 = user.User{ID: 22, Username: "user_bot_owner_b", Issuer: "local"}
|
||||
|
||||
// TestHumaLabel mirrors v1's TestProject shape so v2 contract parity is
|
||||
// readable side-by-side. Labels has no v1 webtest; coverage is ported 1:1
|
||||
// from the model-level matrix in pkg/models/label_test.go so the v2 HTTP
|
||||
|
|
@ -228,6 +235,65 @@ func TestHumaLabel(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestHumaLabel_BotOwner asserts that bot owners can read, update, and delete
|
||||
// labels that were created by bots they own. Fixture label #9 is owned by
|
||||
// bot 23, whose owner is user 21 (testuser21); user 22 owns a different bot
|
||||
// and must not see or touch it.
|
||||
func TestHumaLabel_BotOwner(t *testing.T) {
|
||||
botOwner := webHandlerTestV2{
|
||||
user: &testuser21,
|
||||
basePath: "/api/v2/labels",
|
||||
idParam: "label",
|
||||
t: t,
|
||||
}
|
||||
require.NoError(t, botOwner.ensureEnv())
|
||||
otherOwner := webHandlerTestV2{
|
||||
user: &testuser22,
|
||||
basePath: "/api/v2/labels",
|
||||
idParam: "label",
|
||||
t: t,
|
||||
e: botOwner.e,
|
||||
}
|
||||
|
||||
t.Run("ReadOne - bot owner can read label created by their bot", func(t *testing.T) {
|
||||
rec, err := botOwner.testReadOneWithUser(nil, map[string]string{"label": "9"})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"Label #9 - created by bot 23 owned by user 21"`)
|
||||
})
|
||||
t.Run("ReadOne - non-owner cannot read another owner's bot's label", func(t *testing.T) {
|
||||
_, err := otherOwner.testReadOneWithUser(nil, map[string]string{"label": "9"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
t.Run("ReadAll - bot owner's listing surfaces their bot's labels", func(t *testing.T) {
|
||||
rec, err := botOwner.testReadAllWithUser(nil, nil)
|
||||
require.NoError(t, err)
|
||||
ids := labelIDsFromReadAll(t, rec.Body.Bytes())
|
||||
assert.Contains(t, ids, int64(9), "label #9 (created by user 21's bot) must be listed")
|
||||
})
|
||||
t.Run("Update - bot owner can update label created by their bot", func(t *testing.T) {
|
||||
rec, err := botOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"renamed by owner"}`)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"renamed by owner"`)
|
||||
})
|
||||
t.Run("Update - non-owner cannot update another owner's bot's label", func(t *testing.T) {
|
||||
_, err := otherOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"hijack"}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
t.Run("Delete - non-owner cannot delete another owner's bot's label", func(t *testing.T) {
|
||||
_, err := otherOwner.testDeleteWithUser(nil, map[string]string{"label": "9"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
t.Run("Delete - bot owner can delete label created by their bot", func(t *testing.T) {
|
||||
// Run last so the earlier subtests still have label #9 to operate on.
|
||||
rec, err := botOwner.testDeleteWithUser(nil, map[string]string{"label": "9"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// labelIDsFromReadAll extracts the label IDs from a v2 paginated list body so
|
||||
// the visible set can be asserted exactly rather than via substring matching.
|
||||
func labelIDsFromReadAll(t *testing.T, body []byte) []int64 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
// 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 webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestProjectDuplicateV2 covers POST /projects/{projectid}/duplicate. It drives
|
||||
// the Echo+Huma stack directly (humaRequest/humaTokenFor) because
|
||||
// webHandlerTestV2's buildURL only models base[/{id}] paths, not action sub-paths.
|
||||
func TestProjectDuplicateV2(t *testing.T) {
|
||||
t.Run("duplicates an accessible project to the top level", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
// Duplicating copies the source project's task attachments, so the
|
||||
// referenced fixture file must exist in the (memory) file store.
|
||||
files.InitTestFileFixtures(t)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Project 1 is owned by testuser1.
|
||||
const sourceProjectID int64 = 1
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `"duplicated_project"`)
|
||||
|
||||
var resp struct {
|
||||
DuplicatedProject struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ParentProjectID int64 `json:"parent_project_id"`
|
||||
} `json:"duplicated_project"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.NotZero(t, resp.DuplicatedProject.ID, "duplicated project should have an id")
|
||||
assert.NotEqual(t, sourceProjectID, resp.DuplicatedProject.ID, "duplicated project must have a new id, not the source project's")
|
||||
assert.Contains(t, resp.DuplicatedProject.Title, "duplicate")
|
||||
assert.Zero(t, resp.DuplicatedProject.ParentProjectID, "top-level duplicate must have no parent")
|
||||
})
|
||||
|
||||
t.Run("places the duplicate under parent_project_id from the body", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
files.InitTestFileFixtures(t)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// testuser1 owns project 1, so it may both read the source and create
|
||||
// the copy underneath it.
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{"parent_project_id":1}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var resp struct {
|
||||
DuplicatedProject struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentProjectID int64 `json:"parent_project_id"`
|
||||
} `json:"duplicated_project"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.NotZero(t, resp.DuplicatedProject.ID)
|
||||
assert.Equal(t, int64(1), resp.DuplicatedProject.ParentProjectID, "duplicate must land under the requested parent")
|
||||
})
|
||||
|
||||
t.Run("nonexistent source project", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/99999/duplicate", `{}`, token, "")
|
||||
// CanCreate loads the source via CanRead, which surfaces
|
||||
// ErrProjectDoesNotExist (404) for a missing project rather than a 403.
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist), "body must surface ErrCodeProjectDoesNotExist; body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("no read on source project is forbidden", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
// testuser15 cannot read project 1 (owned by testuser1, no share).
|
||||
token := humaTokenFor(t, &testuser15)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// reactionMapFromBody decodes the v2 reactions list body — a map keyed by
|
||||
// reaction value, each value the list of users who reacted with it.
|
||||
func reactionMapFromBody(t *testing.T, body []byte) map[string][]struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} {
|
||||
t.Helper()
|
||||
var m map[string][]struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &m), "list body must be a reaction map: %s", string(body))
|
||||
return m
|
||||
}
|
||||
|
||||
// TestHumaReaction exercises the v2 reaction surface, mirroring the v1
|
||||
// model-level matrix in pkg/models/reaction_test.go. Fixture reactions.yml
|
||||
// seeds reaction #1: user1 reacted "👋" on task #1.
|
||||
func TestHumaReaction(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
t.Run("List returns the map with the reacting user", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
m := reactionMapFromBody(t, rec.Body.Bytes())
|
||||
require.Len(t, m["👋"], 1, "fixture reaction must be present; body: %s", rec.Body.String())
|
||||
assert.Equal(t, int64(1), m["👋"][0].ID, "the reacting user is user1")
|
||||
})
|
||||
|
||||
t.Run("Create then list reflects the new reaction", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions", `{"value":"🦙"}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `"value":"🦙"`)
|
||||
|
||||
rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
m := reactionMapFromBody(t, rec.Body.Bytes())
|
||||
require.Len(t, m["🦙"], 1, "created reaction must appear in the list; body: %s", rec.Body.String())
|
||||
assert.Equal(t, int64(1), m["🦙"][0].ID)
|
||||
})
|
||||
|
||||
t.Run("Delete removes the reaction", func(t *testing.T) {
|
||||
// Remove the fixture reaction (user1's "👋" on task #1) and confirm via a follow-up list.
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions/delete", `{"value":"👋"}`, token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "delete is POST-with-body returning 200; body: %s", rec.Body.String())
|
||||
|
||||
rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
m := reactionMapFromBody(t, rec.Body.Bytes())
|
||||
assert.NotContains(t, m, "👋", "deleted reaction must be gone; body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Invalid entitykind is rejected", func(t *testing.T) {
|
||||
// The enum tag on the path param makes Huma reject unknown kinds before the handler runs.
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/loremipsum/1/reactions", "", token, "")
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Forbidden - no access to the entity", func(t *testing.T) {
|
||||
// Task #34 lives in a private project user1 cannot see.
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/reactions", "", token, "")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Nonexistent entity", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/9999999/reactions", "", token, "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Create forbidden - no access to the entity", func(t *testing.T) {
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/34/reactions", `{"value":"🦙"}`, token, "")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestHumaTaskAssigneeBulk proves the v2 bulk-assignee replace contract:
|
||||
// PUT /tasks/{projecttask}/assignees/bulk swaps the task's full assignee set
|
||||
// for the posted list. Like the single-assignee test it gates on write access
|
||||
// to the task's project (CanCreate → canDoTaskAssingee → project.CanUpdate).
|
||||
//
|
||||
// Fixture topology (pkg/db/fixtures/task_assignees.yml, tasks.yml, projects.yml,
|
||||
// users_projects.yml):
|
||||
// - task 30 (project 1, owned by user1): assignees user1 (#1) and user2 (#2).
|
||||
// user2 is a fixture row only; user2 has NO access to project 1, so it can
|
||||
// be removed but never freshly added — replace cases here only remove it.
|
||||
// - tasks 16/19 (shared to user1 with write): user1 has project access, so
|
||||
// it is a valid assignee there — used for the add-from-empty case.
|
||||
// - tasks 15/18: shared read-only — write is forbidden.
|
||||
// - task 34 (project 20, user13): user1 has no access at all.
|
||||
func TestHumaTaskAssigneeBulk(t *testing.T) {
|
||||
// One Echo env shared across users; setupTestEnv rotates the JWT secret per
|
||||
// call, so a second env would 401 tokens minted against the first.
|
||||
base := &webHandlerTestV2{user: &testuser1, t: t}
|
||||
require.NoError(t, base.ensureEnv())
|
||||
|
||||
bulkPut := func(taskID string, u *user.User, payload string) (ids []int64, err error) {
|
||||
h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees/bulk", t: t, e: base.e}
|
||||
rec, err := h.serve(http.MethodPut, h.basePath, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// PUT defaults to 200 from the Register wrapper for a non-create verb.
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
return assigneeIDsFromReadAll(t, rec.Body.Bytes()), nil
|
||||
}
|
||||
// readAssignees fetches the current assignee set so a replace is verified
|
||||
// against persisted state, not just the response echo.
|
||||
readAssignees := func(taskID string, u *user.User) []int64 {
|
||||
h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees", idParam: "user", t: t, e: base.e}
|
||||
rec, err := h.testReadAllWithUser(nil, nil)
|
||||
require.NoError(t, err)
|
||||
return assigneeIDsFromReadAll(t, rec.Body.Bytes())
|
||||
}
|
||||
|
||||
t.Run("Replace removes assignees not in the list", func(t *testing.T) {
|
||||
// task 30 starts as {1,2}; replacing with {1} must drop user2.
|
||||
require.ElementsMatch(t, []int64{1, 2}, readAssignees("30", &testuser1))
|
||||
_, err := bulkPut("30", &testuser1, `{"assignees":[{"id":1}]}`)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []int64{1}, readAssignees("30", &testuser1),
|
||||
"user2 must be unassigned after the replace")
|
||||
})
|
||||
|
||||
t.Run("Empty list unassigns everyone", func(t *testing.T) {
|
||||
// task 30 now holds {1}; an empty array clears it entirely.
|
||||
_, err := bulkPut("30", &testuser1, `{"assignees":[]}`)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, readAssignees("30", &testuser1),
|
||||
"an empty assignees array must remove all assignees")
|
||||
})
|
||||
|
||||
t.Run("Replace adds new assignees", func(t *testing.T) {
|
||||
// task 16 is shared to user1 with write access and starts with no
|
||||
// assignees; user1 has project access, so it is a valid new assignee.
|
||||
require.Empty(t, readAssignees("16", &testuser1))
|
||||
_, err := bulkPut("16", &testuser1, `{"assignees":[{"id":1}]}`)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []int64{1}, readAssignees("16", &testuser1),
|
||||
"user1 must be assigned after the replace")
|
||||
})
|
||||
|
||||
t.Run("Forbidden - read-only share", func(t *testing.T) {
|
||||
// task 18 is shared to user1 read-only; bulk replace needs write.
|
||||
_, err := bulkPut("18", &testuser1, `{"assignees":[{"id":1}]}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
|
||||
t.Run("Forbidden - no access at all", func(t *testing.T) {
|
||||
// task 34 belongs to user13's private project 20.
|
||||
_, err := bulkPut("34", &testuser1, `{"assignees":[{"id":1}]}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
|
||||
t.Run("Forbidden - user without project access", func(t *testing.T) {
|
||||
// user6 has no access to project 1, so it cannot write task 1.
|
||||
_, err := bulkPut("1", &testuser6, `{"assignees":[{"id":6}]}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
|
||||
t.Run("Nonexisting task", func(t *testing.T) {
|
||||
// The write check resolves the project from the task, so a missing task
|
||||
// surfaces project-does-not-exist as a 404.
|
||||
_, err := bulkPut("99999", &testuser1, `{"assignees":[{"id":1}]}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/routes"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// multipartFilesBody builds a multipart/form-data body with one or more files
|
||||
// under the "files" field, matching the v2 upload handler's form schema.
|
||||
func multipartFilesBody(t *testing.T, files map[string][]byte) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(buf)
|
||||
for filename, content := range files {
|
||||
fw, err := w.CreateFormFile("files", filename)
|
||||
require.NoError(t, err)
|
||||
_, err = fw.Write(content)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, w.Close())
|
||||
return buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func uploadAttachmentRequest(t *testing.T, e *echo.Echo, taskID string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/tasks/"+taskID+"/attachments", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// uploadOneAttachment uploads a single file to task 1 and returns the created
|
||||
// attachment id, so download/delete tests have a real file in storage to act on
|
||||
// (setupTestEnv resets the mem storage, so fixture files have no bytes).
|
||||
func uploadOneAttachment(t *testing.T, e *echo.Echo, token, filename string, content []byte) int64 {
|
||||
t.Helper()
|
||||
body, contentType := multipartFilesBody(t, map[string][]byte{filename: content})
|
||||
rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var resp struct {
|
||||
Body struct {
|
||||
Success []*models.TaskAttachment `json:"success"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp.Body))
|
||||
require.Empty(t, resp.Body.Errors, "upload reported per-file errors: %+v", resp.Body.Errors)
|
||||
require.Len(t, resp.Body.Success, 1)
|
||||
require.NotZero(t, resp.Body.Success[0].ID)
|
||||
return resp.Body.Success[0].ID
|
||||
}
|
||||
|
||||
func TestTaskAttachmentsV2(t *testing.T) {
|
||||
t.Run("Upload single file", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
body, contentType := multipartFilesBody(t, map[string][]byte{"hello.txt": []byte("hello world")})
|
||||
rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "hello.txt")
|
||||
assert.Contains(t, rec.Body.String(), `"success"`)
|
||||
})
|
||||
|
||||
t.Run("Upload multiple files", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
body, contentType := multipartFilesBody(t, map[string][]byte{
|
||||
"one.txt": []byte("first file"),
|
||||
"two.txt": []byte("second file"),
|
||||
})
|
||||
rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var resp struct {
|
||||
Success []*models.TaskAttachment `json:"success"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Success, 2)
|
||||
})
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Upload first so there is at least one attachment with a real file row.
|
||||
uploadOneAttachment(t, e, token, "listed.txt", []byte("listed content"))
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var resp struct {
|
||||
Items []*models.TaskAttachment `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.NotEmpty(t, resp.Items)
|
||||
assert.Positive(t, resp.Total)
|
||||
})
|
||||
|
||||
t.Run("Download returns bytes and content type", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
content := []byte("downloadable content")
|
||||
id := uploadOneAttachment(t, e, token, "download.txt", content)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Equal(t, content, rec.Body.Bytes(), "the streamed file bytes must match the original")
|
||||
assert.NotEmpty(t, rec.Header().Get("Content-Type"))
|
||||
assert.Contains(t, rec.Header().Get("Content-Disposition"), "download.txt")
|
||||
// Caching headers mirror v1: a concrete length and a cacheable directive.
|
||||
assert.Equal(t, strconv.Itoa(len(content)), rec.Header().Get("Content-Length"))
|
||||
assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
|
||||
assert.NotEmpty(t, rec.Header().Get("Last-Modified"))
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
id := uploadOneAttachment(t, e, token, "todelete.txt", []byte("bye"))
|
||||
|
||||
rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
// The download must now 404.
|
||||
rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Upload forbidden on inaccessible task", func(t *testing.T) {
|
||||
// Task 34 is owned by user 13 and inaccessible to testuser1 (see the v1 IDOR test).
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
body, contentType := multipartFilesBody(t, map[string][]byte{"nope.txt": []byte("nope")})
|
||||
rec := uploadAttachmentRequest(t, e, "34", body, contentType, token)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("List forbidden on inaccessible task", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/attachments", "", token, "")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Download nonexistent attachment", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/99999", "", token, "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Cannot download attachment that does not belong to the task in the path", func(t *testing.T) {
|
||||
// Mirrors the v1 IDOR test: attachment 4 belongs to task 34, not task 1.
|
||||
// Requesting it under task 1 (accessible) must 404, not leak the file.
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/4", "", token, "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("Unauthenticated upload is rejected", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
body, contentType := multipartFilesBody(t, map[string][]byte{"x.txt": []byte("x")})
|
||||
rec := uploadAttachmentRequest(t, e, "1", body, contentType, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestTaskAttachmentsV2_PreviewSize covers the preview_size query param: a non-image
|
||||
// attachment ignores it and returns the original bytes (the v1 behaviour).
|
||||
func TestTaskAttachmentsV2_PreviewSize(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
content := []byte("not an image, just text")
|
||||
id := uploadOneAttachment(t, e, token, "notimage.txt", content)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10)+"?preview_size=md", "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Equal(t, content, rec.Body.Bytes(), "preview_size on a non-image must return the original file")
|
||||
}
|
||||
|
||||
// TestTaskAttachmentsV2_Disabled proves the resource is absent when the
|
||||
// service.enabletaskattachments config flag is off.
|
||||
func TestTaskAttachmentsV2_Disabled(t *testing.T) {
|
||||
_, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
oldValue := config.ServiceEnableTaskAttachments.GetBool()
|
||||
config.ServiceEnableTaskAttachments.Set(false)
|
||||
defer config.ServiceEnableTaskAttachments.Set(oldValue)
|
||||
|
||||
// Rebuild the router so RegisterAll re-evaluates the (now disabled) flag.
|
||||
e := routes.NewEcho()
|
||||
routes.RegisterRoutes(e)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"attachment routes must not be registered when the flag is off; body: %s", rec.Body.String())
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTaskBucketV2 covers PUT /projects/{project}/views/{view}/buckets/{bucket}/tasks.
|
||||
// It drives the Echo+Huma stack directly (humaRequest/humaTokenFor) because the
|
||||
// route is an action sub-path webHandlerTestV2's buildURL doesn't model. Fixtures
|
||||
// (project 1, view 4): bucket 1 default, bucket 2 "Doing" limit 3 (full), bucket 3 done.
|
||||
func TestTaskBucketV2(t *testing.T) {
|
||||
const path = "/api/v2/projects/1/views/4/buckets/%d/tasks"
|
||||
|
||||
t.Run("moves a task into a bucket", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Task 3 starts in bucket 2; move it into bucket 1 (neither full nor done).
|
||||
rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":3}`, token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `"task_id":3`)
|
||||
|
||||
db.AssertExists(t, "task_buckets", map[string]interface{}{
|
||||
"task_id": 3,
|
||||
"bucket_id": 1,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("moving a task into the done bucket marks it done", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Bucket 3 is the done bucket on view 4; task 1 is not yet done.
|
||||
rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 3), `{"task_id":1}`, token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `"done":true`)
|
||||
|
||||
db.AssertExists(t, "tasks", map[string]interface{}{
|
||||
"id": 1,
|
||||
"done": true,
|
||||
}, false)
|
||||
db.AssertExists(t, "task_buckets", map[string]interface{}{
|
||||
"task_id": 1,
|
||||
"bucket_id": 3,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("moving a task out of the done bucket un-marks it done", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Task 2 starts in bucket 3 (done) and is done; move it to bucket 1.
|
||||
rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":2}`, token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `"done":false`)
|
||||
|
||||
db.AssertExists(t, "tasks", map[string]interface{}{
|
||||
"id": 2,
|
||||
"done": false,
|
||||
}, false)
|
||||
db.AssertExists(t, "task_buckets", map[string]interface{}{
|
||||
"task_id": 2,
|
||||
"bucket_id": 1,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("full bucket is rejected", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Bucket 2 already holds 3 tasks and has a limit of 3.
|
||||
rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 2), `{"task_id":1}`, token, "")
|
||||
require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBucketLimitExceeded))
|
||||
})
|
||||
|
||||
t.Run("bucket on another view is rejected", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Bucket 4 lives on view 8 (project 2), so under view 4 / project 1 the
|
||||
// permission check resolves the bucket's own view scoped by the path
|
||||
// project and finds none → 404 before the move's own 400 can fire.
|
||||
rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 4), `{"task_id":1}`, token, "")
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectViewDoesNotExist))
|
||||
})
|
||||
|
||||
t.Run("no write access is forbidden", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
// testuser15 has no access to project 1.
|
||||
token := humaTokenFor(t, &testuser15)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":1}`, token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTaskPositionV2 covers PUT /tasks/{task}/position. It drives the Echo+Huma
|
||||
// stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL
|
||||
// only models base[/{id}] paths, not action sub-paths.
|
||||
func TestTaskPositionV2(t *testing.T) {
|
||||
t.Run("updates the position of a writable task", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Task 1 lives in project 1, which testuser1 owns; view 1 belongs to project 1.
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":256}`, token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var resp models.TaskPosition
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, int64(1), resp.TaskID, "task id is taken from the URL")
|
||||
assert.Equal(t, int64(1), resp.ProjectViewID)
|
||||
assert.InDelta(t, 256.0, resp.Position, 0)
|
||||
})
|
||||
|
||||
t.Run("path task id wins over the body", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Body names task 2, URL names task 1; the URL must win.
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"task_id":2,"project_view_id":1,"position":300}`, token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var resp models.TaskPosition
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, int64(1), resp.TaskID)
|
||||
})
|
||||
|
||||
t.Run("nonexistent task", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/position", `{"project_view_id":1,"position":1}`, token, "")
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist), "body must surface ErrCodeTaskDoesNotExist; body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("no access to the task is forbidden", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
// testuser15 cannot access task 1 (project 1, owned by testuser1).
|
||||
token := humaTokenFor(t, &testuser15)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":1}`, token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("read but no write on the task is forbidden", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
// Task 32 lives in project 3, on which testuser1 has read-only access.
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/32/position", `{"project_view_id":1,"position":1}`, token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTaskRelationV2 covers POST /tasks/{task}/relations and
|
||||
// DELETE /tasks/{task}/relations/{relationKind}/{otherTask}. It drives the
|
||||
// Echo+Huma stack directly (humaRequest/humaTokenFor) because the action
|
||||
// sub-paths aren't modelled by webHandlerTestV2's buildURL. Coverage mirrors
|
||||
// the v1 model matrix in pkg/models/task_relation_test.go.
|
||||
func TestTaskRelationV2(t *testing.T) {
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Run("creates forward and inverse rows", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
|
||||
`{"other_task_id":2,"relation_kind":"subtask"}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `"relation_kind":"subtask"`)
|
||||
assert.Contains(t, rec.Body.String(), `"task_id":1`)
|
||||
assert.Contains(t, rec.Body.String(), `"other_task_id":2`)
|
||||
|
||||
// Create must store both directions: the forward subtask and the
|
||||
// automatically derived inverse parenttask.
|
||||
db.AssertExists(t, "task_relations", map[string]interface{}{
|
||||
"task_id": 1,
|
||||
"other_task_id": 2,
|
||||
"relation_kind": models.RelationKindSubtask,
|
||||
"created_by_id": 1,
|
||||
}, false)
|
||||
db.AssertExists(t, "task_relations", map[string]interface{}{
|
||||
"task_id": 2,
|
||||
"other_task_id": 1,
|
||||
"relation_kind": models.RelationKindParenttask,
|
||||
"created_by_id": 1,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("path task id wins over body", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// task_id in the body is ignored; the row is created for the path task.
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
|
||||
`{"task_id":999,"other_task_id":2,"relation_kind":"related"}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
db.AssertExists(t, "task_relations", map[string]interface{}{
|
||||
"task_id": 1,
|
||||
"other_task_id": 2,
|
||||
"relation_kind": models.RelationKindRelated,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("cycle is rejected", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// task 29 is already a subtask of task 1 (fixture); making task 1 a
|
||||
// subtask of task 29 would close the loop.
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/29/relations",
|
||||
`{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
|
||||
require.Equal(t, http.StatusConflict, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskRelationCycle))
|
||||
})
|
||||
|
||||
t.Run("same task is rejected", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
|
||||
`{"other_task_id":1,"relation_kind":"related"}`, token, "")
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationTasksCannotBeTheSame))
|
||||
})
|
||||
|
||||
t.Run("invalid relation kind in body is rejected by the enum", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// relation_kind carries an enum constraint, so Huma rejects an unknown
|
||||
// kind with 422 before the handler runs (consistent with the delete path).
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
|
||||
`{"other_task_id":2,"relation_kind":"bogus"}`, token, "")
|
||||
require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("nonexistent base task", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/999999/relations",
|
||||
`{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist))
|
||||
})
|
||||
|
||||
t.Run("forbidden - no write on base task", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// task 15 is read-only for user1, so CanCreate (needs write on base) denies.
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/15/relations",
|
||||
`{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
t.Run("removes forward and inverse rows", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Fixture relation 1: task 1 -subtask-> task 29, with the inverse
|
||||
// parenttask row (task 29 -> task 1).
|
||||
rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/29", "", token, "")
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Empty(t, rec.Body.String())
|
||||
|
||||
db.AssertMissing(t, "task_relations", map[string]interface{}{
|
||||
"task_id": 1,
|
||||
"other_task_id": 29,
|
||||
"relation_kind": models.RelationKindSubtask,
|
||||
})
|
||||
db.AssertMissing(t, "task_relations", map[string]interface{}{
|
||||
"task_id": 29,
|
||||
"other_task_id": 1,
|
||||
"relation_kind": models.RelationKindParenttask,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("invalid relation kind in path is rejected by the enum", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// The path param carries an enum constraint, so Huma rejects an unknown
|
||||
// kind with 422 before the handler runs.
|
||||
rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/bogus/29", "", token, "")
|
||||
require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("nonexistent relation", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/2", "", token, "")
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationDoesNotExist))
|
||||
})
|
||||
|
||||
t.Run("forbidden - no write on base task", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Fixture relation 7: task 41 -subtask-> task 43, owned by user15 in
|
||||
// project 36, which user1 cannot access — CanDelete denies.
|
||||
rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/41/relations/subtask/43", "", token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue