Merge branch 'main' into feat/list-tree-collapse

This commit is contained in:
oneclawbot-prog 2026-06-22 13:35:47 +01:00 committed by GitHub
commit 2745487987
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
166 changed files with 10129 additions and 2750 deletions

View File

@ -1 +0,0 @@
AGENTS.md

View File

@ -997,6 +997,37 @@
}
]
},
{
"key": "audit",
"comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
"children": [
{
"key": "enabled",
"default_value": "false",
"comment": "Whether to enable audit logging."
},
{
"key": "logfile",
"default_value": "",
"comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
},
{
"key": "rotation",
"children": [
{
"key": "maxsizemb",
"default_value": "100",
"comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
},
{
"key": "maxage",
"default_value": "30",
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
}
]
}
]
},
{
"key": "outgoingrequests",
"children": [

View File

@ -61,8 +61,8 @@
}
},
"devDependencies": {
"electron": "40.10.3",
"electron-builder": "26.15.2",
"electron": "40.10.4",
"electron-builder": "26.15.3",
"unzipper": "0.12.3"
},
"dependencies": {
@ -74,11 +74,13 @@
],
"overrides": {
"minimatch": "^10.2.3",
"tar": "^7.5.11",
"tar": ">=7.5.16",
"@tootallnate/once": "^3.0.1",
"picomatch": ">=4.0.4",
"tmp": ">=0.2.6",
"ip-address": ">=10.1.1"
"tmp": ">=0.2.7",
"ip-address": ">=10.1.1",
"form-data": ">=4.0.6",
"js-yaml": ">=4.2.0"
}
}
}

View File

@ -6,11 +6,13 @@ settings:
overrides:
minimatch: ^10.2.3
tar: ^7.5.11
tar: '>=7.5.16'
'@tootallnate/once': ^3.0.1
picomatch: '>=4.0.4'
tmp: '>=0.2.6'
tmp: '>=0.2.7'
ip-address: '>=10.1.1'
form-data: '>=4.0.6'
js-yaml: '>=4.2.0'
importers:
@ -21,11 +23,11 @@ importers:
version: 5.2.1
devDependencies:
electron:
specifier: 40.10.3
version: 40.10.3
specifier: 40.10.4
version: 40.10.4
electron-builder:
specifier: 26.15.2
version: 26.15.2(electron-builder-squirrel-windows@24.13.3)
specifier: 26.15.3
version: 26.15.3(electron-builder-squirrel-windows@24.13.3)
unzipper:
specifier: 0.12.3
version: 0.12.3
@ -234,12 +236,12 @@ packages:
dmg-builder: 24.13.3
electron-builder-squirrel-windows: 24.13.3
app-builder-lib@26.15.2:
resolution: {integrity: sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ==}
app-builder-lib@26.15.3:
resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==}
engines: {node: '>=14.0.0'}
peerDependencies:
dmg-builder: 26.15.2
electron-builder-squirrel-windows: 26.15.2
dmg-builder: 26.15.3
electron-builder-squirrel-windows: 26.15.3
archiver-utils@2.1.0:
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
@ -329,8 +331,8 @@ packages:
builder-util@24.13.1:
resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==}
builder-util@26.15.0:
resolution: {integrity: sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA==}
builder-util@26.15.3:
resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==}
engines: {node: '>=14.0.0'}
bytes@3.1.2:
@ -483,8 +485,8 @@ packages:
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
dmg-builder@26.15.2:
resolution: {integrity: sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==}
dmg-builder@26.15.3:
resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==}
dotenv-expand@11.0.6:
resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==}
@ -522,19 +524,19 @@ packages:
electron-builder-squirrel-windows@24.13.3:
resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==}
electron-builder@26.15.2:
resolution: {integrity: sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A==}
electron-builder@26.15.3:
resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==}
engines: {node: '>=14.0.0'}
hasBin: true
electron-publish@24.13.1:
resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==}
electron-publish@26.15.1:
resolution: {integrity: sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==}
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron@40.10.3:
resolution: {integrity: sha512-DdWRsHm4j5wH9TMcfnB2Dqx44G/6BgLKSG/oeRe9kS60pfqCUwzUkHk0ClwvZzBVXtJ1kcdkHVRrJsl1ooKp+g==}
electron@40.10.4:
resolution: {integrity: sha512-ouNZrXXmdPL/wiTQ+xzXpb7B/BHg+j7XARig0SE7azFO3bjbYUd6lFjIAAiDQ02Pl/Oj7MUk+4C0hdf9yFtA1A==}
engines: {node: '>= 22.12.0'}
hasBin: true
@ -632,8 +634,8 @@ packages:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
forwarded@0.2.0:
@ -736,6 +738,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hosted-git-info@4.1.0:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
@ -830,8 +836,8 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
js-yaml@4.2.0:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true
json-buffer@3.0.1:
@ -1298,8 +1304,8 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.5.15:
resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
tar@7.5.16:
resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==}
engines: {node: '>=18'}
temp-file@3.4.0:
@ -1315,8 +1321,8 @@ packages:
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
tmp@0.2.6:
resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==}
tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
engines: {node: '>=14.14'}
toidentifier@1.0.1:
@ -1711,7 +1717,7 @@ snapshots:
app-builder-bin@4.0.0: {}
app-builder-lib@24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3):
app-builder-lib@24.13.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3):
dependencies:
'@develar/schema-utils': 2.6.5
'@electron/notarize': 2.2.1
@ -1725,27 +1731,27 @@ snapshots:
builder-util-runtime: 9.2.4
chromium-pickle-js: 0.2.0
debug: 4.4.3
dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3)
ejs: 3.1.10
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2)
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.3)
electron-publish: 24.13.1
form-data: 4.0.5
form-data: 4.0.6
fs-extra: 10.1.0
hosted-git-info: 4.1.0
is-ci: 3.0.1
isbinaryfile: 5.0.7
js-yaml: 4.1.1
js-yaml: 4.2.0
lazy-val: 1.0.5
minimatch: 10.2.5
read-config-file: 6.3.2
sanitize-filename: 1.6.4
semver: 7.8.1
tar: 7.5.15
tar: 7.5.16
temp-file: 3.4.0
transitivePeerDependencies:
- supports-color
app-builder-lib@26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3):
app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3):
dependencies:
'@electron/asar': 3.4.1
'@electron/fuses': 1.8.0
@ -1761,22 +1767,22 @@ snapshots:
ajv: 8.20.0
asn1js: 3.0.10
async-exit-hook: 2.0.1
builder-util: 26.15.0
builder-util: 26.15.3
builder-util-runtime: 9.7.0
chromium-pickle-js: 0.2.0
ci-info: 4.3.1
debug: 4.4.3
dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3)
dotenv: 16.4.5
dotenv-expand: 11.0.6
ejs: 3.1.10
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2)
electron-publish: 26.15.1
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.3)
electron-publish: 26.15.3
fs-extra: 10.1.0
hosted-git-info: 4.1.0
isbinaryfile: 5.0.7
jiti: 2.6.1
js-yaml: 4.1.1
js-yaml: 4.2.0
json5: 2.2.3
lazy-val: 1.0.5
minimatch: 10.2.5
@ -1785,7 +1791,7 @@ snapshots:
proper-lockfile: 4.1.2
resedit: 1.7.2
semver: 7.7.4
tar: 7.5.15
tar: 7.5.16
temp-file: 3.4.0
tiny-async-pool: 1.3.0
unzipper: 0.12.3
@ -1923,14 +1929,14 @@ snapshots:
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
is-ci: 3.0.1
js-yaml: 4.1.1
js-yaml: 4.2.0
source-map-support: 0.5.21
stat-mode: 1.0.0
temp-file: 3.4.0
transitivePeerDependencies:
- supports-color
builder-util@26.15.0:
builder-util@26.15.3:
dependencies:
'@types/debug': 4.1.13
builder-util-runtime: 9.7.0
@ -1940,7 +1946,7 @@ snapshots:
fs-extra: 10.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
js-yaml: 4.1.1
js-yaml: 4.2.0
sanitize-filename: 1.6.4
source-map-support: 0.5.21
stat-mode: 1.0.0
@ -2088,12 +2094,12 @@ snapshots:
minimatch: 10.2.5
p-limit: 3.1.0
dmg-builder@26.15.2(electron-builder-squirrel-windows@24.13.3):
dmg-builder@26.15.3(electron-builder-squirrel-windows@24.13.3):
dependencies:
app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.0
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.3
fs-extra: 10.1.0
js-yaml: 4.1.1
js-yaml: 4.2.0
transitivePeerDependencies:
- electron-builder-squirrel-windows
- supports-color
@ -2126,9 +2132,9 @@ snapshots:
dependencies:
jake: 10.8.7
electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.2):
electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.3):
dependencies:
app-builder-lib: 24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3)
app-builder-lib: 24.13.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
archiver: 5.3.2
builder-util: 24.13.1
fs-extra: 10.1.0
@ -2136,14 +2142,14 @@ snapshots:
- dmg-builder
- supports-color
electron-builder@26.15.2(electron-builder-squirrel-windows@24.13.3):
electron-builder@26.15.3(electron-builder-squirrel-windows@24.13.3):
dependencies:
app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.0
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.3
builder-util-runtime: 9.7.0
chalk: 4.1.2
ci-info: 4.3.1
dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3)
fs-extra: 10.1.0
lazy-val: 1.0.5
simple-update-notifier: 2.0.0
@ -2164,21 +2170,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-publish@26.15.1:
electron-publish@26.15.3:
dependencies:
'@types/fs-extra': 9.0.13
aws4: 1.13.2
builder-util: 26.15.0
builder-util: 26.15.3
builder-util-runtime: 9.7.0
chalk: 4.1.2
form-data: 4.0.5
form-data: 4.0.6
fs-extra: 10.1.0
lazy-val: 1.0.5
mime: 2.6.0
transitivePeerDependencies:
- supports-color
electron@40.10.3:
electron@40.10.4:
dependencies:
'@electron-internal/extract-zip': 1.0.2
'@electron/get': 5.0.0
@ -2215,7 +2221,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
hasown: 2.0.4
es6-error@4.1.1:
optional: true
@ -2294,12 +2300,12 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.5:
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
hasown: 2.0.4
mime-types: 2.1.35
forwarded@0.2.0: {}
@ -2436,6 +2442,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
@ -2534,7 +2544,7 @@ snapshots:
jiti@2.6.1: {}
js-yaml@4.1.1:
js-yaml@4.2.0:
dependencies:
argparse: 2.0.1
@ -2656,7 +2666,7 @@ snapshots:
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.1
tar: 7.5.15
tar: 7.5.16
tinyglobby: 0.2.15
undici: 6.26.0
which: 6.0.1
@ -2791,7 +2801,7 @@ snapshots:
config-file-ts: 0.2.6
dotenv: 9.0.2
dotenv-expand: 5.1.0
js-yaml: 4.1.1
js-yaml: 4.2.0
json5: 2.2.3
lazy-val: 1.0.5
@ -3008,7 +3018,7 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
tar@7.5.15:
tar@7.5.16:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
@ -3032,9 +3042,9 @@ snapshots:
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.6
tmp: 0.2.7
tmp@0.2.6: {}
tmp@0.2.7: {}
toidentifier@1.0.1: {}

View File

@ -82,7 +82,7 @@
"bulma-css-variables": "0.9.33",
"change-case": "5.4.4",
"dayjs": "1.11.19",
"dompurify": "3.4.0",
"dompurify": "3.4.11",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"floating-vue": "5.2.2",
@ -105,20 +105,20 @@
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@faker-js/faker": "10.4.0",
"@faker-js/faker": "10.5.0",
"@histoire/plugin-screenshot": "1.0.0-beta.1",
"@histoire/plugin-vue": "1.0.0-beta.1",
"@playwright/test": "1.58.2",
"@sentry/vite-plugin": "3.6.1",
"@tailwindcss/vite": "4.3.0",
"@tailwindcss/vite": "4.3.1",
"@tsconfig/node24": "24.0.4",
"@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3",
"@types/node": "24.13.2",
"@types/sortablejs": "1.15.9",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@vitejs/plugin-vue": "6.0.7",
"@vue/eslint-config-typescript": "14.8.0",
"@vue/test-utils": "2.4.11",
@ -128,18 +128,18 @@
"browserslist": "4.28.2",
"caniuse-lite": "1.0.30001799",
"csstype": "3.2.3",
"esbuild": "0.28.0",
"esbuild": "0.28.1",
"eslint": "9.39.4",
"eslint-plugin-depend": "1.5.0",
"eslint-plugin-vue": "10.9.2",
"happy-dom": "20.10.2",
"happy-dom": "20.10.6",
"histoire": "1.0.0-beta.1",
"otplib": "12.0.1",
"postcss": "8.5.15",
"postcss-easing-gradients": "3.0.1",
"postcss-html": "1.8.1",
"postcss-preset-env": "11.3.0",
"rollup": "4.61.1",
"postcss-preset-env": "11.3.1",
"rollup": "4.62.2",
"rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.100.0",
"stylelint": "17.13.0",
@ -147,15 +147,15 @@
"stylelint-config-recommended-vue": "1.6.1",
"stylelint-config-standard-scss": "17.0.0",
"stylelint-use-logical": "2.1.3",
"tailwindcss": "4.3.0",
"tailwindcss": "4.3.1",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.3.5",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-vue-devtools": "8.1.2",
"vite-plugin-vue-devtools": "8.1.3",
"vite-svg-loader": "5.1.1",
"vitest": "4.1.8",
"vue-tsc": "3.3.4",
"vitest": "4.1.9",
"vue-tsc": "3.3.5",
"wait-on": "9.0.10",
"workbox-cli": "7.4.1",
"ws": "8.21.0"
@ -176,7 +176,13 @@
"flatted": "^3.4.1",
"ip-address": ">=10.1.1",
"postcss": ">=8.5.10",
"tmp": ">=0.2.6"
"tmp": ">=0.2.7",
"esbuild": ">=0.28.1",
"form-data": ">=4.0.6",
"markdown-it": ">=14.2.0",
"launch-editor": ">=2.14.1",
"@babel/core": ">=7.29.6",
"js-yaml@4": ">=4.2.0"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -61,6 +61,7 @@ import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
import {useBodyClass} from '@/composables/useBodyClass'
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
@ -107,6 +108,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
useColorScheme()
useTimeTrackingFavicon()
</script>
<style src="@/styles/tailwind.css" />

View File

@ -730,7 +730,7 @@ function focusTaskBar(rowId: string) {
setTimeout(() => {
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
if (taskBarElement) {
taskBarElement.focus()
taskBarElement.focus({preventScroll: true})
}
}, 0)
}

View File

@ -722,7 +722,7 @@ async function addImage(event: Event) {
return
}
const url = await inputPrompt(event.target.getBoundingClientRect())
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
if (url) {
editor.value?.chain().focus().setImage({src: url}).run()

View File

@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
import EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
import {getPopupContainer} from '../popupContainer'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)
getPopupContainer(props.editor).appendChild(popupElement)
const rect = props.clientRect()
if (!rect) {
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
cleanupFloating = null
}
if (popupElement) {
document.body.removeChild(popupElement)
popupElement.remove()
popupElement = null
}
component?.destroy()

View File

@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
const previousUrl = editor?.getAttributes('link').href || ''
const url = await inputPrompt(pos, previousUrl)
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
// empty
if (url === '') {

View File

@ -68,7 +68,7 @@ const props = withDefaults(defineProps<{
enabled?: boolean,
overflow?: boolean,
wide?: boolean,
variant?: 'default' | 'hint-modal' | 'scrolling',
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
}>(), {
enabled: true,
overflow: false,
@ -211,7 +211,13 @@ $modal-width: 1024px;
// Reset UA dialog styles
padding: 0;
border: none;
background: transparent;
// The scrim lives on the dialog element, not on ::backdrop: Chromium
// intermittently stops painting a styled ::backdrop (e.g. after the
// dialog's subtree re-renders, or while display is transitioned) even
// though getComputedStyle still reports the color. The dialog fills the
// viewport anyway, and its opacity transition fades the scrim with it
// same as the old div-based .modal-mask.
background: rgba(0, 0, 0, .8);
color: #ffffff;
// Fill viewport
position: fixed;
@ -221,10 +227,12 @@ $modal-width: 1024px;
max-inline-size: 100%;
max-block-size: 100%;
// Transitions
// Transitions. No display/allow-discrete transition needed: the close
// fade runs while the dialog is still [open] (data-closing + timer in
// closeDialog), and transitioning display triggers the Chromium paint
// bug above.
opacity: 0;
transition: opacity 150ms ease,
display 150ms ease allow-discrete;
transition: opacity 150ms ease;
&[open]:not([data-closing]) {
opacity: 1;
@ -236,16 +244,11 @@ $modal-width: 1024px;
&::backdrop {
background-color: rgba(0, 0, 0, 0);
transition: background-color 150ms ease,
display 150ms ease allow-discrete;
}
&[open]:not([data-closing])::backdrop {
background-color: rgba(0, 0, 0, .8);
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
// in quick-add mode the Electron window itself is the overlay no scrim
&:has(.is-quick-add-mode) {
background: transparent;
}
}
@ -261,13 +264,20 @@ $modal-width: 1024px;
}
.default .modal-content,
.hint-modal .modal-content {
.hint-modal .modal-content,
.top .modal-content {
text-align: center;
position: absolute;
// fine to use top/left since we're only using this to position it centered
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
// Cap centered content to the viewport and scroll inside it. Without this a
// taller-than-viewport modal centres its top edge above the viewport, where
// the container's overflow can't scroll to it (the .top variant overrides
// both values below).
max-block-size: calc(100dvh - 2rem);
overflow: auto;
[dir="rtl"] & {
transform: translate(50%, -50%);
@ -277,6 +287,9 @@ $modal-width: 1024px;
margin: 0;
position: static;
transform: none;
// the fullscreen mobile layout flows and scrolls in .modal-container
max-block-size: none;
overflow: visible;
}
.modal-header {
@ -289,11 +302,31 @@ $modal-width: 1024px;
}
}
// anchored below the top edge instead of centered, used for QuickActions
.top .modal-content {
inset-block-start: 3rem;
transform: translate(-50%, 0);
max-block-size: calc(100dvh - 6rem);
overflow: auto;
[dir="rtl"] & {
transform: translate(50%, 0);
}
// the fullscreen mobile layout flows and scrolls in .modal-container
@media screen and (max-width: $tablet) {
transform: none;
max-block-size: none;
overflow: visible;
}
}
// Default width for centered modals. Scoped with :not(.is-wide) so the
// `wide` prop can still expand the modal (the .is-wide rule below would
// otherwise be outranked by .default .modal-content's specificity).
.default .modal-content:not(.is-wide),
.hint-modal .modal-content:not(.is-wide) {
.hint-modal .modal-content:not(.is-wide),
.top .modal-content:not(.is-wide) {
inline-size: calc(100% - 2rem);
max-inline-size: 640px;
@ -403,6 +436,7 @@ $modal-width: 1024px;
block-size: auto;
max-inline-size: none;
max-block-size: none;
background: transparent;
&::backdrop {
display: none;

View File

@ -2,6 +2,7 @@
<Modal
:enabled="active"
:overflow="isNewTaskCommand"
variant="top"
@close="closeQuickActions"
>
<div
@ -704,15 +705,16 @@ function reset() {
<style lang="scss" scoped>
.quick-actions {
// global Bulma .card styles are gone (ported into Card.vue, scoped),
// so this bare .card div needs its own card visuals
background-color: var(--white);
border-radius: $radius;
border: 1px solid var(--card-border-color);
box-shadow: var(--shadow-sm);
color: var(--text);
overflow: hidden;
justify-content: flex-start !important;
// FIXME: changed position should be an option of the modal
:deep(.modal-content) {
inset-block-start: 3rem;
transform: translate(-50%, 0);
}
&.is-quick-add-mode {
padding: 0;
margin: 0;

View File

@ -25,6 +25,7 @@
rows="1"
@keydown="resetEmptyTitleError"
@keydown.enter="handleEnter"
@keydown.esc="blurTaskInput"
/>
<QuickAddMagic
:highlight-hint-icon="taskAddHovered"
@ -282,6 +283,10 @@ function focusTaskInput() {
newTaskInput.value?.focus()
}
function blurTaskInput() {
newTaskInput.value?.blur()
}
defineExpose({
focusTaskInput,
})

View File

@ -123,7 +123,7 @@
</XButton>
<!-- Dropzone -->
<Teleport to="body">
<Teleport :to="dropzoneTeleportTarget">
<div
v-if="editEnabled"
:class="{hidden: !showDropzone}"
@ -185,7 +185,7 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, watch} from 'vue'
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/User.vue'
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
)
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
// the browser's top layer, so the full-screen dropzone overlay teleported to
// <body> would paint behind it regardless of z-index. Teleport it into the
// topmost open dialog instead, mirroring Notification.vue.
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
let dialogObserver: MutationObserver | null = null
function syncDropzoneTeleportTarget() {
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
}
onMounted(() => {
syncDropzoneTeleportTarget()
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
dialogObserver.observe(document.body, {
attributes: true,
attributeFilter: ['open'],
childList: true,
subtree: true,
})
})
onBeforeUnmount(() => {
dialogObserver?.disconnect()
dialogObserver = null
})
watch(() => props.editEnabled, enabled => {
if (!enabled) {
resetDragState()
@ -478,7 +506,7 @@ defineExpose({
inset-inline-start: 0;
inset-block-end: 0;
inset-inline-end: 0;
z-index: 4001; // modal z-index is 4000
z-index: 4001; // above app chrome when teleported to body (no modal open)
text-align: center;
&.hidden {

View File

@ -1,4 +1,4 @@
import { ref } from 'vue'
import { getCurrentInstance, ref } from 'vue'
import { createGlobalState, useIntervalFn } from '@vueuse/core'
import { onBeforeRouteUpdate } from 'vue-router'
@ -18,10 +18,14 @@ export const useGlobalNow = createGlobalState(() => {
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
// ensure the now value is refreshed when the route changes
onBeforeRouteUpdate(() => {
update()
})
// Now that this state can be initialised from a plain helper (formatDateSince), the
// first caller is not guaranteed to be a component — guard the route hook accordingly.
if (getCurrentInstance()) {
// ensure the now value is refreshed when the route changes
onBeforeRouteUpdate(() => {
update()
})
}
return {
now,

View File

@ -0,0 +1,34 @@
import {describe, it, expect} from 'vitest'
import {buildStoredQuery} from './useTaskList'
describe('buildStoredQuery', () => {
it('includes sort when set', () => {
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'due_date:asc'})
})
it('includes filter and search when set', () => {
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
.toEqual({filter: 'done = false', s: 'foo'})
})
it('omits page when it equals the default of 1', () => {
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'id:desc'})
})
it('includes page when greater than 1', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
.toEqual({page: '3'})
})
it('returns an empty object when nothing is set', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
.toEqual({})
})
it('skips empty strings', () => {
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
.toEqual({})
})
})

View File

@ -1,4 +1,6 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRouter, isNavigationFailure} from 'vue-router'
import type {LocationQueryRaw} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService, {
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth'
import {useViewFiltersStore} from '@/stores/viewFilters'
import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none'
@ -59,6 +62,22 @@ const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
}
interface TaskListQueryState {
sort: string | undefined
filter: string | undefined
s: string | undefined
page: number
}
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
const query: LocationQueryRaw = {}
if (state.sort) query.sort = state.sort
if (state.filter) query.filter = state.filter
if (state.s) query.s = state.s
if (state.page > 1) query.page = String(state.page)
return query
}
// This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless.
@ -94,6 +113,9 @@ export function useTaskList(
const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter())
const router = useRouter()
const viewFiltersStore = useViewFiltersStore()
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const page = useRouteQuery('page', '1', { transform: Number })
@ -119,6 +141,55 @@ export function useTaskList(
},
})
// Mirror the URL query bits this composable owns into the store so
// in-project tab switches and sidebar re-visits can restore them.
//
// `ProjectList`/`ProjectTable` are reused across project switches (no
// `:key` on them in ProjectView.vue), so setup runs only once. We track
// the last viewId we synced — on every viewId transition, if the URL has
// none of our params and the store has an entry, restore it via
// `router.replace` and skip writing back the empty state we'd otherwise
// clobber the saved entry with.
let lastSyncedViewId: number | undefined
watch(
[projectViewId, sortQuery, filter, s, page],
([viewId, sortValue, filterValue, sValue, pageValue]) => {
const viewIdChanged = viewId !== lastSyncedViewId
lastSyncedViewId = viewId
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
// the default so it neither blocks restoration nor wipes stored state.
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
if (viewIdChanged && urlIsEmpty) {
const storedQuery = viewFiltersStore.getViewQuery(viewId)
if (Object.keys(storedQuery).length > 0) {
// Merge so unrelated query params on the route survive the restore.
// Swallow navigation failures (e.g. aborted/duplicated) so the
// ignored promise can't surface as an unhandled rejection.
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
.catch(failure => {
if (!isNavigationFailure(failure)) throw failure
})
return
}
}
const query = buildStoredQuery({
sort: sortValue as string | undefined,
filter: filterValue as string | undefined,
s: sValue as string | undefined,
page: currentPage,
})
if (Object.keys(query).length > 0) {
viewFiltersStore.setViewQuery(viewId, query)
} else {
viewFiltersStore.clearViewQuery(viewId)
}
},
{immediate: true},
)
const allParams = computed(() => {
const loadParams = {...params.value}

View File

@ -0,0 +1,32 @@
import {watch} from 'vue'
import {createSharedComposable, tryOnMounted} from '@vueuse/core'
import {storeToRefs} from 'pinia'
import {useTimeTrackingStore} from '@/stores/timeTracking'
import {getFullBaseUrl} from '@/helpers/getFullBaseUrl'
const TRACKING_FAVICON = `${getFullBaseUrl()}images/icons/favicon-tracking-32x32.png`
function getFaviconLink(): HTMLLinkElement | null {
return document.querySelector<HTMLLinkElement>('link[rel="icon"]')
}
// Swaps in a favicon with a small red dot in the lower left corner while a timer
// is running, so an active time tracking session is visible even when the tab
// isn't focused.
export const useTimeTrackingFavicon = createSharedComposable(() => {
const {hasActiveTimer} = storeToRefs(useTimeTrackingStore())
const originalHref = getFaviconLink()?.getAttribute('href') ?? '/favicon.ico'
function update(active: boolean) {
const link = getFaviconLink()
if (link === null) {
return
}
link.href = active ? TRACKING_FAVICON : originalHref
}
watch(hasActiveTimer, update, {flush: 'post'})
tryOnMounted(() => update(hasActiveTimer.value))
})

View File

@ -0,0 +1,12 @@
/**
* Hash-fragment prefix used to carry a post-login destination in the URL.
*
* Unlike the localStorage redirect, this lives in the address bar so the URL
* stays copyable between browsers (needed for native OAuth clients that open
* /oauth/authorize, see #2654). It uses the hash not a query param so the
* embedded OAuth parameters never reach server or proxy access logs.
*
* Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach
* special-cases.
*/
export const REDIRECT_HASH_PREFIX = '#redirect='

View File

@ -0,0 +1,153 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {refreshToken, removeToken} from './auth'
// Count how many times the refresh endpoint is actually POSTed. The whole point
// of the in-flight dedup is that concurrent refreshToken() calls share a single
// underlying POST, independent of the Web Locks API.
let postCallCount = 0
let resolvePost: ((value: unknown) => void) | null = null
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => ({
post: vi.fn(() => {
postCallCount++
return new Promise((resolve) => {
resolvePost = resolve
})
}),
}),
}))
vi.mock('@/helpers/desktopAuth', () => ({
isDesktopApp: () => false,
refreshDesktopToken: vi.fn(),
}))
const FAKE_TOKEN = 'header.payload.signature'
function settlePost() {
resolvePost?.({data: {token: FAKE_TOKEN}})
}
describe('refreshToken in-flight dedup', () => {
const originalLocks = navigator.locks
beforeEach(() => {
postCallCount = 0
resolvePost = null
removeToken()
localStorage.clear()
})
afterEach(() => {
Object.defineProperty(navigator, 'locks', {
value: originalLocks,
configurable: true,
writable: true,
})
})
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
// undefined, so without this the test would silently fall through to
// the insecure-HTTP branch and never exercise navigator.locks.request.
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
Object.defineProperty(navigator, 'locks', {
value: {request: requestSpy},
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
// Both calls share one underlying request.
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2])
// The Web Locks branch actually ran...
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
// ...and the in-flight dedup still collapsed both calls into one POST.
expect(postCallCount).toBe(1)
})
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
// Simulate an insecure HTTP context where navigator.locks is undefined.
Object.defineProperty(navigator, 'locks', {
value: undefined,
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
const p3 = refreshToken(true)
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2, p3])
expect(postCallCount).toBe(1)
})
it('allows a fresh refresh after the previous one settled', async () => {
const p1 = refreshToken(true)
settlePost()
await p1
expect(postCallCount).toBe(1)
// The in-flight promise was reset, so a later refresh runs anew.
const p2 = refreshToken(true)
expect(postCallCount).toBe(2)
settlePost()
await p2
})
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
const p1 = refreshToken(true)
expect(postCallCount).toBe(1)
// User logs out while the refresh POST is still in flight.
removeToken()
// The in-flight POST resolves afterwards — it must not undo the logout.
settlePost()
await p1
expect(localStorage.getItem('token')).toBeNull()
})
it('an older refresh settling does not clobber a newer in-flight one', async () => {
// Refresh A starts and stays in flight.
const pA = refreshToken(true)
expect(postCallCount).toBe(1)
const resolveA = resolvePost
// User logs out, which drops the in-flight reference to A.
removeToken()
// Refresh B starts; it must claim the in-flight slot.
const pB = refreshToken(true)
expect(postCallCount).toBe(2)
const resolveB = resolvePost
// A settles after B started. Its cleanup must NOT null the in-flight
// slot, since that slot now belongs to B. Without the `=== p` guard,
// A's .finally would clobber B and let a concurrent caller fire a
// second parallel POST.
resolveA?.({data: {token: FAKE_TOKEN}})
await pA
// A concurrent caller while B is still in flight must dedup to B —
// no third POST.
const pB2 = refreshToken(true)
expect(postCallCount).toBe(2)
resolveB?.({data: {token: FAKE_TOKEN}})
await Promise.all([pB, pB2])
})
})

View File

@ -33,18 +33,53 @@ export const removeToken = () => {
savedToken = null
localStorage.removeItem('token')
localStorage.removeItem('desktopOAuthRefreshToken')
// Bump the epoch and drop the in-flight refresh so a refresh that started
// before this logout can't re-persist a token after we cleared it.
authEpoch++
inFlightRefresh = null
}
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
// without this guard, refreshes firing close together each spend the single-use
// cookie and all but one get a 401.
let inFlightRefresh: Promise<void> | null = null
// Incremented on every removeToken()/logout. A refresh captures the epoch when
// it starts and only persists its result if the epoch is unchanged, so a
// refresh that resolves after a logout can't undo it.
let authEpoch = 0
/**
* Refreshes an auth token while ensuring it is updated everywhere.
* The refresh token is sent automatically as an HttpOnly cookie.
* The server rotates the cookie on every call.
*
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
* performs the actual refresh; other tabs waiting for the lock detect that
* the token in localStorage was already updated and adopt it directly.
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the
* Web Locks API inside adds cross-tab coordination only in secure contexts.
*/
export async function refreshToken(persist: boolean): Promise<void> {
if (inFlightRefresh) {
return inFlightRefresh
}
const p = doRefresh(persist)
inFlightRefresh = p
// Only clear if it still points to this promise — a logout (or a newer
// refresh started after it) may have replaced inFlightRefresh meanwhile.
p.finally(() => {
if (inFlightRefresh === p) {
inFlightRefresh = null
}
})
return p
}
async function doRefresh(persist: boolean): Promise<void> {
// Snapshot the epoch so we can tell if a logout happened while we awaited.
const epochAtStart = authEpoch
const loggedOutSinceStart = () => authEpoch !== epochAtStart
// In desktop mode, refresh via IPC to the Electron main process
if (isDesktopApp()) {
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
@ -53,6 +88,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
}
try {
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
if (loggedOutSinceStart()) {
return
}
saveToken(tokens.access_token, persist)
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
} catch (e) {
@ -65,7 +103,13 @@ export async function refreshToken(persist: boolean): Promise<void> {
// if another tab refreshed while we were queued.
const tokenBeforeLock = localStorage.getItem('token')
const doRefresh = async () => {
const refreshUnderLock = async () => {
// A logout may have happened while we waited for the lock — don't
// re-adopt or re-fetch a token after the user signed out.
if (loggedOutSinceStart()) {
return
}
// If the token in localStorage changed while waiting for the lock,
// another tab already refreshed. Just adopt the new token.
const currentToken = localStorage.getItem('token')
@ -78,6 +122,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
const HTTP = HTTPFactory()
try {
const response = await HTTP.post('user/token/refresh')
if (loggedOutSinceStart()) {
return
}
saveToken(response.data.token, persist)
} catch (e) {
throw new Error('Error renewing token: ', {cause: e})
@ -85,10 +132,10 @@ export async function refreshToken(persist: boolean): Promise<void> {
}
if (navigator.locks) {
await navigator.locks.request('vikunja-token-refresh', doRefresh)
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock)
} else {
// Fallback for environments without Web Locks (e.g. insecure HTTP)
await doRefresh()
await refreshUnderLock()
}
}

View File

@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId'
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
import {nextTick} from 'vue'
import {eventToShortcutString} from '@/helpers/shortcut'
import type {Editor} from '@tiptap/core'
import {getPopupContainer} from '@/components/input/editor/popupContainer'
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
return new Promise((resolve) => {
const id = 'link-input-' + createRandomID()
// Append inside the open task <dialog> (top-layer) when present, otherwise
// document.body. A body-level popup is painted behind a showModal() dialog
// and unfocusable through its focus trap, breaking the link prompt in the
// Kanban task popup (#2940).
const container = getPopupContainer(editor)
// Create popup element
const popupElement = document.createElement('div')
@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
inputElement.value = oldValue
wrapperDiv.appendChild(inputElement)
popupElement.appendChild(wrapperDiv)
document.body.appendChild(popupElement)
container.appendChild(popupElement)
// Create a local mutable copy of the position for scroll tracking
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
@ -82,15 +89,41 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
nextTick(() => document.getElementById(id)?.focus())
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
// dialogs close themselves on Escape ("cancel"); swallow that while the
// prompt is open so Escape only dismisses the prompt, not the task dialog.
const dialog = container.closest('dialog') as HTMLDialogElement | null
const handleDialogCancel = (event: Event) => event.preventDefault()
dialog?.addEventListener('cancel', handleDialogCancel)
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
}
}
const cleanup = () => {
window.removeEventListener('scroll', handleScroll, true)
if (document.body.contains(popupElement)) {
document.body.removeChild(popupElement)
document.removeEventListener('click', handleClickOutside)
dialog?.removeEventListener('cancel', handleDialogCancel)
if (container.contains(popupElement)) {
container.removeChild(popupElement)
}
}
document.getElementById(id)?.addEventListener('keydown', event => {
const shortcutString = eventToShortcutString(event)
if (shortcutString === 'Escape') {
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
event.preventDefault()
event.stopPropagation()
resolve('')
cleanup()
return
}
if (shortcutString !== 'Enter') {
return
}
@ -105,15 +138,6 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
cleanup()
})
// Close on click outside
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
document.removeEventListener('click', handleClickOutside)
}
}
// Add slight delay to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', handleClickOutside)

View File

@ -5,6 +5,7 @@ import {i18n} from '@/i18n'
import {createSharedComposable} from '@vueuse/core'
import {computed, toValue, type MaybeRefOrGetter} from 'vue'
import {useDateDisplay} from '@/composables/useDateDisplay'
import {useGlobalNow} from '@/composables/useGlobalNow'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat'
@ -49,8 +50,13 @@ export const formatDateSince = (date: Date | string | null) => {
const locale = DAYJS_LOCALE_MAPPING[i18n.global.locale.value.toLowerCase()] ?? 'en'
// Computing the relative string against the shared, ticking `now` (instead of fromNow's
// internal Date.now()) makes every reactive caller re-render on the 60s tick, so open views
// don't keep showing a stale "x minutes ago".
const {now} = useGlobalNow()
return date
? dayjs(date).locale(locale).fromNow()
? dayjs(date).locale(locale).from(now.value)
: ''
}

View File

@ -393,6 +393,7 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

@ -393,6 +393,7 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

@ -172,6 +172,7 @@
"yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ"
},
"timeFormat": "Μορφή ώρας",
"timeTrackingDefaultStart": "Ώρα έναρξης παρακολούθησης χρόνου με έξυπνο-γέμισμα",
"timeFormatOptions": {
"12h": "12 ώρες (ΠΜ/ΜΜ)",
"24h": "24 ώρες (ΩΩ:ΛΛ)"
@ -781,7 +782,10 @@
"closeDialog": "Κλείσμο του διαλόγου",
"closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών",
"skipToContent": "Μετάβαση στο κύριο περιεχόμενο",
"sortBy": "Ταξινόμηση ανά"
"sortBy": "Ταξινόμηση ανά",
"dateRange": "Εύρος ημερομηνιών",
"notSet": "Μη ορισμένο",
"user": "Χρήστης"
},
"input": {
"projectColor": "Χρώμα έργου",
@ -991,6 +995,7 @@
"repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος",
"percentDone": "Ορισμός Προόδου",
"attachments": "Προσθήκη Συνημμένων",
"timeTracking": "Χρόνος ίχνους",
"relatedTasks": "Προσθήκη Συσχέτισης",
"moveProject": "Μετακίνηση",
"duplicate": "Αντιγραφή",
@ -1460,6 +1465,32 @@
"frontendVersion": "Έκδοση frontend: {version}",
"apiVersion": "Έκδοση API: {version}"
},
"timeTracking": {
"title": "Ιχνηλάτηση χρόνου",
"stop": "Διακοπή χρονομέτρου",
"logTime": "Καταγραφή χρόνου",
"editEntry": "Επεξεργασία εγγραφής",
"form": {
"task": "Εργασία",
"taskSearch": "Αναζήτηση για μια εργασία…",
"commentPlaceholder": "Σε τι δουλέψατε;",
"save": "Αποθήκευση εγγραφής",
"startTimer": "Έναρξη χρονοµέτρου",
"update": "Ενημέρωση εγγραφής",
"smartFill": "Συμπλήρωση από την τελευταία καταχώριση"
},
"list": {
"emptyTask": "Δεν καταγράφηκε ακόμη χρόνος για αυτήν την εργασία.",
"emptyFiltered": "Δεν καταγράφηκε χρόνος με βάση τα επιλεγμένα φίλτρα.",
"total": "Σύνολο",
"time": "Ώρα",
"duration": "Διάρκεια"
},
"browse": {
"selectRange": "Επιλέξτε ένα εύρος",
"userSearch": "Αναζήτηση για ένα χρήστη…"
}
},
"time": {
"units": {
"seconds": "δευτερόλεπτο|δευτερόλεπτα",

View File

@ -393,6 +393,7 @@
"title": "Duplicate this project",
"label": "Duplicate",
"text": "Select a parent project which should hold the duplicated project:",
"shares": "Copy shares (users, teams and link shares) to the duplicate",
"success": "The project was successfully duplicated."
},
"edit": {

View File

@ -172,6 +172,7 @@
"yyyy/mm/dd": "YYYY/MM/DD"
},
"timeFormat": "Формат часу",
"timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу",
"timeFormatOptions": {
"12h": "12-годинний (AM/PM)",
"24h": "24-годинний (HH:mm)"
@ -781,7 +782,10 @@
"closeDialog": "Закрити діалог",
"closeQuickActions": "Закрити швидкі дії",
"skipToContent": "Перейти до основного вмісту",
"sortBy": "Сортувати за"
"sortBy": "Сортувати за",
"dateRange": "Діапазон дат",
"notSet": "Не встановлено",
"user": "Користувач"
},
"input": {
"projectColor": "Колір проєкту",
@ -991,6 +995,7 @@
"repeatAfter": "Повторювати",
"percentDone": "Встановити прогрес",
"attachments": "Вкласти",
"timeTracking": "Відстежити час",
"relatedTasks": "Пов'язати",
"moveProject": "Перемістити",
"duplicate": "Дублювати",
@ -1146,6 +1151,7 @@
"repeat": {
"everyDay": "Щодня",
"everyWeek": "Щотижня",
"every30d": "Кожні 30 днів",
"mode": "Спосіб",
"monthly": "Щомісяця",
"fromCurrentDate": "З дня закінчення",
@ -1459,6 +1465,32 @@
"frontendVersion": "Версія інтерфейсу: {version}",
"apiVersion": "API версія: {version}"
},
"timeTracking": {
"title": "Відстеження часу",
"stop": "Зупинити таймер",
"logTime": "Записати час",
"editEntry": "Редагувати запис",
"form": {
"task": "Завдання",
"taskSearch": "Знайти завдання…",
"commentPlaceholder": "Над чим ви працювали?",
"save": "Зберегти запис",
"startTimer": "Запустити таймер",
"update": "Оновити запис",
"smartFill": "Заповнити з останнього запису"
},
"list": {
"emptyTask": "Для цього завдання ще немає записів обліку часу.",
"emptyFiltered": "Немає записів обліку часу для вибраних фільтрів.",
"total": "Загалом",
"time": "Час",
"duration": "Тривалість"
},
"browse": {
"selectRange": "Обрати діапазон",
"userSearch": "Знайти користувача…"
}
},
"time": {
"units": {
"seconds": "секунда|секунд(и)",

View File

@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract {
projectId: number
duplicatedProject: IProject | null
parentProjectId: IProject['id']
duplicateShares: boolean
}

View File

@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplica
projectId = 0
duplicatedProject: IProject | null = null
parentProjectId = 0
duplicateShares = false
constructor(data : Partial<IProjectDuplicate>) {
super()

View File

@ -6,6 +6,7 @@ import {getProjectViewId} from '@/helpers/projectView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
import {REDIRECT_HASH_PREFIX} from '@/constants/redirectHash'
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
import {PRO_FEATURE} from '@/constants/proFeatures'
@ -30,7 +31,7 @@ const router = createRouter({
}
// Scroll to anchor should still work
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX) && !to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
return {el: to.hash}
}
@ -472,10 +473,22 @@ const router = createRouter({
})
export async function getAuthForRoute(to: RouteLocation, authStore) {
// vue-router already decoded to.hash once, so slicing off the prefix yields the original
// fullPath (e.g. /oauth/authorize?...) losslessly — no extra decodeURIComponent needed.
const redirectDest = to.name === 'user.login' && to.hash.startsWith(REDIRECT_HASH_PREFIX)
? to.hash.slice(REDIRECT_HASH_PREFIX.length)
: ''
if (authStore.authUser || authStore.authLinkShare) {
// An already-signed-in browser that opens a copied /login#redirect=<oauth.authorize> URL
// must run the OAuth flow with its existing session instead of short-circuiting to home.
// The destination has no redirect hash, so the second guard pass just early-returns (#2654).
if (redirectDest) {
return redirectDest
}
return
}
// Check if password reset token is in query params
const resetToken = to.query.userPasswordReset as string | undefined
@ -499,15 +512,35 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
}
}
// Keep the destination in the address bar (not just per-browser localStorage) so a native
// client's /oauth/authorize URL stays copyable into another browser. Hash, not query, so the
// embedded OAuth params never reach access logs (#2654). Pass fullPath raw: vue-router encodes
// the hash itself, so an extra encodeURIComponent here would be double-encoded in the URL.
if (to.name === 'oauth.authorize') {
return {
name: 'user.login',
hash: REDIRECT_HASH_PREFIX + to.fullPath,
}
}
// Fold the hash destination into localStorage: it's the only bridge that survives the
// external OIDC round-trip out of the SPA, so redirectIfSaved() works after any auth method.
// vue-router already decoded to.hash once, so it equals the fullPath we wrote above as-is.
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
const destination = to.hash.slice(REDIRECT_HASH_PREFIX.length)
const resolved = router.resolve(destination)
saveLastVisited(resolved.name as string, resolved.params, resolved.query)
}
// Check if the route the user wants to go to is a route which needs authentication. We use this to
// redirect the user after successful login.
const isValidUserAppRoute = !AUTH_ROUTE_NAMES.has(to.name as string) &&
localStorage.getItem('emailConfirmToken') === null
if (isValidUserAppRoute) {
saveLastVisited(to.name as string, to.params, to.query)
}
if (isValidUserAppRoute) {
return {name: 'user.login'}
}
@ -565,12 +598,25 @@ router.beforeEach(async (to, from) => {
const newRoute = await getAuthForRoute(to, authStore)
if(newRoute) {
// A string target (the decoded redirect destination for an authed browser) already
// carries its own query/path and no redirect hash, so navigate to it verbatim — don't
// re-attach to.hash or it would re-enter the redirect loop.
if (typeof newRoute === 'string') {
return newRoute
}
return {
...newRoute,
hash: to.hash,
...newRoute,
}
}
// to.fullPath keeps the redirect hash url-encoded while to.hash is decoded, so the endsWith
// check below never matches and would re-append the hash forever. The hash is already on the
// URL here, so skip the re-attach (#2654).
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
return
}
if(!to.fullPath.endsWith(to.hash)) {
return to.fullPath + to.hash
}

View File

@ -0,0 +1,139 @@
import {describe, it, expect, beforeEach, vi} from 'vitest'
import {setActivePinia, createPinia} from 'pinia'
import {useAuthStore} from './auth'
import {AUTH_TYPES} from '@/modelTypes/IUser'
const {refreshTokenMock, routerPushMock, getTokenMock} = vi.hoisted(() => ({
refreshTokenMock: vi.fn(),
routerPushMock: vi.fn(),
getTokenMock: vi.fn(() => null as string | null),
}))
vi.mock('@/helpers/auth', () => ({
refreshToken: refreshTokenMock,
getToken: getTokenMock,
saveToken: vi.fn(),
removeToken: vi.fn(),
}))
vi.mock('@/router', () => ({
default: {push: routerPushMock},
}))
vi.mock('@/composables/useWebSocket', () => ({
useWebSocket: () => ({disconnect: vi.fn(), connect: vi.fn()}),
}))
function fakeHttp() {
return {
post: vi.fn().mockResolvedValue({data: {}}),
get: vi.fn().mockResolvedValue({data: {}}),
request: vi.fn().mockResolvedValue({data: {}}),
interceptors: {
request: {use: vi.fn()},
response: {use: vi.fn()},
},
}
}
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => fakeHttp(),
AuthenticatedHTTPFactory: () => fakeHttp(),
getApiBaseUrl: () => 'http://localhost/api/v1/',
}))
vi.mock('@/helpers/redirectToProvider', () => ({
getRedirectUrlFromCurrentFrontendPath: vi.fn(),
redirectToProvider: vi.fn(),
redirectToProviderOnLogout: vi.fn(),
}))
// A refresh failure that looks like a real network/HTTP error so renewToken's
// "is this a genuine logout?" check (it inspects the error cause's status) fires.
function refreshError() {
return new Error('Error renewing token: ', {
cause: {response: {status: 401}},
})
}
// A JWT carrying a not-yet-expired user session, so the checkAuth() call that
// renewToken() runs after a successful refresh treats the session as live.
function freshUserJwt() {
const payload = {
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) + 3600,
}
const encoded = btoa(JSON.stringify(payload))
return `header.${encoded}.signature`
}
describe('auth store renewToken retry (issue #2863)', () => {
beforeEach(() => {
setActivePinia(createPinia())
refreshTokenMock.mockReset()
routerPushMock.mockReset()
getTokenMock.mockReset().mockReturnValue(null)
})
function setupExpiredUserSession(store: ReturnType<typeof useAuthStore>) {
store.setAuthenticated(true)
// Expired exp so renewToken treats a refresh failure as a real logout.
store.setUser({
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) - 60,
} as never, false)
}
it('does NOT log out when the first refresh fails but the retry succeeds', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
// The retry "succeeds" only if it actually leaves a usable token behind:
// renewToken() runs checkAuth() afterwards, which reads getToken(). Start
// with no token, then hand back a fresh JWT once the refresh resolves.
getTokenMock.mockReturnValue(null)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockImplementationOnce(async () => {
getTokenMock.mockReturnValue(freshUserJwt())
})
await store.renewToken()
// Two refresh attempts: the initial one and the single retry.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
// The retry recovered the session: the user is still authenticated...
expect(store.authenticated).toBe(true)
// ...and was not bounced to login.
expect(routerPushMock).not.toHaveBeenCalledWith({name: 'user.login'})
})
it('logs out when BOTH the refresh and its retry fail', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockRejectedValueOnce(refreshError())
await store.renewToken()
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
expect(routerPushMock).toHaveBeenCalledWith({name: 'user.login'})
})
it('retries exactly once (no infinite loop) when the session is genuinely dead', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock.mockRejectedValue(refreshError())
await store.renewToken()
// Initial attempt + exactly one retry — never more.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -55,6 +55,17 @@ function redirectToSpecifiedProvider() {
}
}
// A race-loser's refresh fails but the rotated cookie is already valid, so a
// second attempt succeeds — recovering what would otherwise be a spurious
// logout. Exactly one retry: a genuinely dead session still logs out, no loop.
async function refreshTokenWithRetry(persist: boolean): Promise<void> {
try {
await refreshToken(persist)
} catch {
await refreshToken(persist)
}
}
function getLoggedInVia(): string | null {
return localStorage.getItem('loggedInViaProvider')
}
@ -352,7 +363,7 @@ export const useAuthStore = defineStore('auth', () => {
// refresh before giving up. This lets users who reopen the app
// after the short JWT TTL seamlessly resume their session.
try {
await refreshToken(true)
await refreshTokenWithRetry(true)
const freshJwt = getToken()
if (freshJwt) {
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
@ -512,7 +523,7 @@ export const useAuthStore = defineStore('auth', () => {
saveToken(response.data.token, false)
} else {
// User sessions renew via the refresh-token cookie.
await refreshToken(true)
await refreshTokenWithRetry(true)
}
await checkAuth()
} catch (e) {
@ -533,9 +544,11 @@ export const useAuthStore = defineStore('auth', () => {
// Revoke the server session so the refresh token can't be reused.
// Best-effort: if the network call fails, still clean up locally.
let oidcLogoutUrl = ''
try {
const HTTP = AuthenticatedHTTPFactory()
await HTTP.post('user/logout')
const {data} = await HTTP.post('user/logout')
oidcLogoutUrl = data?.oidc_logout_url ?? ''
} catch (_e) {
// Ignore — session will expire naturally
}
@ -547,7 +560,12 @@ export const useAuthStore = defineStore('auth', () => {
await router.push({name: 'user.login'})
await checkAuth()
// if configured, redirect to OIDC Provider on logout
// Redirect to the OIDC provider to end its session too. Prefer the
// server-built RP-Initiated Logout URL, falling back to the static one.
if (oidcLogoutUrl) {
window.location.href = oidcLogoutUrl
return
}
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
if (fullProvider) {
redirectToProviderOnLogout(fullProvider)

View File

@ -47,6 +47,7 @@ export interface ConfigState {
publicTeamsEnabled: boolean,
allowIconChanges: boolean,
enabledProFeatures: string[],
concurrentWrites: boolean,
}
export const useConfigStore = defineStore('config', () => {
@ -88,6 +89,7 @@ export const useConfigStore = defineStore('config', () => {
publicTeamsEnabled: false,
allowIconChanges: true,
enabledProFeatures: [],
concurrentWrites: false,
})
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)

View File

@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter<IProject['id']>) {
success({message: t('project.edit.success')})
}
async function duplicateProject(parentProjectId: IProject['id']) {
async function duplicateProject(parentProjectId: IProject['id'], duplicateShares: boolean = false) {
const projectDuplicate = new ProjectDuplicateModel({
projectId: Number(toValue(projectId)),
parentProjectId,
duplicateShares,
})
const duplicate = await projectDuplicateService.create(projectDuplicate)

View File

@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'
import {buildDefaultRemindersForQuickAdd} from './tasks'
import {buildDefaultRemindersForQuickAdd, runWrites} from './tasks'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
@ -42,3 +42,39 @@ describe('buildDefaultRemindersForQuickAdd', () => {
expect(result[0].relativeTo).toBe(REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE)
})
})
describe('runWrites', () => {
function deferredWrite() {
const inFlight: string[] = []
let maxConcurrent = 0
const completed: string[] = []
const write = async (item: string) => {
inFlight.push(item)
maxConcurrent = Math.max(maxConcurrent, inFlight.length)
await Promise.resolve()
inFlight.splice(inFlight.indexOf(item), 1)
completed.push(item)
}
return {write, completed, getMaxConcurrent: () => maxConcurrent}
}
it('runs all writes in parallel when concurrent', async () => {
const {write, completed, getMaxConcurrent} = deferredWrite()
await runWrites(['a', 'b', 'c'], write, true)
expect(completed).toHaveLength(3)
expect(getMaxConcurrent()).toBeGreaterThan(1)
})
it('runs writes one at a time when not concurrent', async () => {
const {write, completed, getMaxConcurrent} = deferredWrite()
await runWrites(['a', 'b', 'c'], write, false)
expect(completed).toEqual(['a', 'b', 'c'])
expect(getMaxConcurrent()).toBe(1)
})
it('does nothing for an empty list', async () => {
const {write, completed} = deferredWrite()
await runWrites([], write, false)
expect(completed).toHaveLength(0)
})
})

View File

@ -27,6 +27,7 @@ import type {IProject} from '@/modelTypes/IProject'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {setModuleLoading} from '@/stores/helper'
import {useConfigStore} from '@/stores/config'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
@ -59,6 +60,22 @@ export function buildDefaultRemindersForQuickAdd(
}))
}
// runWrites applies a write to each item. SQLite deadlocks on concurrent writes
// (read-then-write upgrade conflict), so callers pass concurrent=false to serialize.
export async function runWrites<T>(
items: readonly T[],
write: (item: T) => Promise<unknown>,
concurrent: boolean,
): Promise<void> {
if (concurrent) {
await Promise.all(items.map(item => write(item)))
return
}
for (const item of items) {
await write(item)
}
}
// IDEA: maybe use a small fuzzy search here to prevent errors
function findPropertyByValue(object, key, value, fuzzy = false) {
return Object.values(object).find(l => {
@ -131,6 +148,7 @@ export const useTaskStore = defineStore('task', () => {
const labelStore = useLabelStore()
const projectStore = useProjectStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
const isLoading = ref(false)
@ -395,10 +413,7 @@ export const useTaskStore = defineStore('task', () => {
}
const labels = await ensureLabelsExist(parsedLabels)
const labelAddsToWaitFor = labels.map(async l => addLabelToTask(task, l))
// This waits until all labels are created and added to the task
await Promise.all(labelAddsToWaitFor)
await runWrites(labels, l => addLabelToTask(task, l), configStore.concurrentWrites)
return task
}

View File

@ -8,6 +8,12 @@
>
<p>{{ $t('project.duplicate.text') }}</p>
<ProjectSearch v-model="parentProject" />
<FancyCheckbox
v-model="duplicateShares"
class="mbs-2"
>
{{ $t('project.duplicate.shares') }}
</FancyCheckbox>
</CreateEdit>
</template>
@ -18,6 +24,7 @@ import {useI18n} from 'vue-i18n'
import CreateEdit from '@/components/misc/CreateEdit.vue'
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
@ -33,6 +40,7 @@ const projectStore = useProjectStore()
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
const parentProject = ref<IProject | null>(null)
const duplicateShares = ref(true)
const isDuplicating = ref(false)
const loadingModel = computed({
@ -53,7 +61,7 @@ async function duplicate() {
isDuplicating.value = true
try {
await duplicateProject(parentProject.value?.id ?? 0)
await duplicateProject(parentProject.value?.id ?? 0, duplicateShares.value)
success({message: t('project.duplicate.success')})
} finally {
isDuplicating.value = false

View File

@ -0,0 +1,124 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
import {BucketFactory} from '../../factories/bucket'
import {TaskFactory} from '../../factories/task'
import {TaskBucketFactory} from '../../factories/task_buckets'
// Regression test for #2940: in the Kanban task popup the description editor is
// rendered inside a native <dialog> opened via showModal() (browser top-layer).
// The link prompt used to be appended to document.body, so it was painted behind
// the dialog and unfocusable through its focus trap, making "set link" a no-op.
test.describe('Editor link prompt inside the Kanban task popup', () => {
test('creates a link in the description when opened as the Kanban popup', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
// The task popup must be a native <dialog> in the top layer.
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.fill('https://vikunja.io')
await urlInput.press('Enter')
const link = editor.locator('a[href="https://vikunja.io"]')
await expect(link).toBeVisible()
await expect(link).toHaveText('link me')
})
// The link prompt is a sub-modal of the task <dialog>: pressing Escape while
// it is open must cancel only the prompt and leave the task dialog open,
// instead of falling through to the native <dialog>'s Escape-to-close.
test('Escape cancels the link prompt without closing the task dialog', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.press('Escape')
// The prompt is gone, but the task dialog stays open.
await expect(urlInput).toBeHidden()
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
})
})

View File

@ -0,0 +1,55 @@
import {type Page} from '@playwright/test'
import {test, expect} from '../../support/fixtures'
import {TaskFactory} from '../../factories/task'
import {createProjects} from './prepareProjects'
async function selectSortInList(page: Page, optionLabel: string) {
await page.locator('.filter-container').getByRole('button', {name: 'Sort', exact: true}).click()
await page.getByLabel('Sort by').selectOption({label: optionLabel})
await page.getByRole('button', {name: 'Apply sort'}).click()
}
async function navigateViaSidebar(page: Page, projectTitle: string) {
await page.locator('.menu-list .list-menu-link', {
has: page.locator('.project-menu-title', {hasText: new RegExp(`^${projectTitle}$`)}),
}).first().click()
}
test.describe('Sort persistence across sidebar navigation (#2753)', () => {
test('List view: sort persists after navigating to another project and back', async ({authenticatedPage: page}) => {
const projects = await createProjects(2)
const [projectA, projectB] = projects
await TaskFactory.create(3, {
id: '{increment}',
project_id: projectA.id,
title: 'Task {increment}',
})
const listViewA = projectA.views[0].id
await page.goto(`/projects/${projectA.id}/${listViewA}`)
await expect(page).not.toHaveURL(/sort=/)
await selectSortInList(page, 'Due date (Earliest first)')
await expect(page).toHaveURL(/sort=due_date:asc/)
await navigateViaSidebar(page, projectB.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectB.id}/`))
await navigateViaSidebar(page, projectA.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectA.id}/`))
await expect(page).toHaveURL(/sort=due_date:asc/)
})
test('List view: explicit URL sort wins over stored sort', async ({authenticatedPage: page}) => {
const projects = await createProjects(1)
const listView = projects[0].views[0].id
// Seed the store with one sort by visiting with it set.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=due_date:asc`)
await expect(page).toHaveURL(/sort=due_date:asc/)
// Visit a URL that explicitly sets a different sort — that should win.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=priority:desc`)
await expect(page).toHaveURL(/sort=priority:desc/)
})
})

View File

@ -32,10 +32,20 @@ test.describe('OAuth 2.0 Authorization Flow', () => {
})
// Navigate to the OAuth authorize frontend route.
// The user is not logged in, so the router guard saves the route
// and redirects to /login.
// The user is not logged in, so the router guard redirects to /login while
// carrying the authorize destination in a copyable #redirect= hash (not a
// query param, to keep the OAuth params out of access logs).
await page.goto(`/oauth/authorize?${authorizeParams}`)
await expect(page).toHaveURL(/\/login/)
await expect(page).toHaveURL(/\/login#redirect=/)
// The decoded #redirect= destination must carry the full authorize URL, including the
// OAuth params — checking only for the path would pass even if the query were dropped.
const redirectHash = decodeURIComponent(new URL(page.url()).hash)
expect(redirectHash).toContain('/oauth/authorize')
expect(redirectHash).toContain('response_type=code')
expect(redirectHash).toContain('client_id=vikunja')
expect(redirectHash).toContain(`code_challenge=${codeChallenge}`)
expect(redirectHash).toContain(`state=${state}`)
// Register the response listener BEFORE clicking Login, because after
// login redirectIfSaved() navigates back to /oauth/authorize and the
@ -77,4 +87,70 @@ test.describe('OAuth 2.0 Authorization Flow', () => {
expect(tokenBody.token_type).toBe('bearer')
expect(tokenBody.expires_in).toBeGreaterThan(0)
})
// The primary #2654 scenario: the native client opened a different default browser that is
// already signed in to Vikunja. Opening the copied /login#redirect=<oauth.authorize> URL must
// run the OAuth flow with the existing session instead of short-circuiting to home.
test('Already-authenticated browser opening the copied login redirect runs the authorize flow', async ({authenticatedPage, apiContext, currentUser}) => {
const page = authenticatedPage
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: 'vikunja',
redirect_uri: 'vikunja-flutter://callback',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
})
// The component POSTs as soon as it mounts with the existing session, so register the
// listener before navigating.
const authorizeResponsePromise = page.waitForResponse(
response => response.url().includes('/api/v1/oauth/authorize') && response.request().method() === 'POST',
{timeout: 15000},
)
// Open the copyable login URL exactly as it would be pasted from another browser
// (#redirect= is REDIRECT_HASH_PREFIX from @/constants/redirectHash, inlined here because
// the e2e runner has no @ alias).
const redirectDestination = `/oauth/authorize?${authorizeParams}`
await page.goto(`/login#redirect=${encodeURIComponent(redirectDestination)}`)
// The authed guard must send us straight to /oauth/authorize, not home.
await expect(page).toHaveURL(/\/oauth\/authorize/)
const landed = new URL(page.url())
expect(landed.pathname).toBe('/oauth/authorize')
expect(landed.searchParams.get('response_type')).toBe('code')
expect(landed.searchParams.get('client_id')).toBe('vikunja')
expect(landed.searchParams.get('code_challenge')).toBe(codeChallenge)
expect(landed.searchParams.get('state')).toBe(state)
// The PKCE flow completes with the existing session — no second login.
const authorizeResponse = await authorizeResponsePromise
const authorizeBody = await authorizeResponse.json()
expect(authorizeBody.code).toBeTruthy()
expect(authorizeBody.redirect_uri).toBe('vikunja-flutter://callback')
expect(authorizeBody.state).toBe(state)
const tokenResponse = await apiContext.post('oauth/token', {
data: {
grant_type: 'authorization_code',
code: authorizeBody.code,
client_id: 'vikunja',
redirect_uri: 'vikunja-flutter://callback',
code_verifier: codeVerifier,
},
})
expect(tokenResponse.ok()).toBe(true)
const tokenBody = await tokenResponse.json()
expect(tokenBody.access_token).toBeTruthy()
expect(tokenBody.refresh_token).toBeTruthy()
expect(tokenBody.token_type).toBe('bearer')
expect(tokenBody.expires_in).toBeGreaterThan(0)
})
})

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

@ -0,0 +1,254 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package audit_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"code.vikunja.io/api/pkg/audit"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
log.InitLogger()
config.InitDefaultConfig()
keyvalue.InitStorage() // license.SetForTests persists state through keyvalue
os.Exit(m.Run())
}
// One event type per test so each topic has exactly the listeners the test registered.
type pipelineEvent struct {
TaskID int64 `json:"task_id"`
DoerID int64 `json:"doer_id"`
}
func (e *pipelineEvent) Name() string { return "test.audit.pipeline" }
type licenseGateEvent struct {
Marker string `json:"marker"`
}
func (e *licenseGateEvent) Name() string { return "test.audit.licensegate" }
type rotationEvent struct {
Filler string `json:"filler"`
}
func (e *rotationEvent) Name() string { return "test.audit.rotation" }
// otherListener is a second, non-audit listener on the same topic.
type otherListener struct {
called chan struct{}
}
func (l *otherListener) Handle(_ *message.Message) error {
select {
case l.called <- struct{}{}:
default:
}
return nil
}
func (l *otherListener) Name() string { return "other" }
var (
registerTestEventsOnce sync.Once
other = &otherListener{called: make(chan struct{}, 16)}
)
// The listener registry is global and watermill rejects duplicate handler
// names, so register once per process (relevant for -count > 1).
func registerTestEvents() {
registerTestEventsOnce.Do(func() {
audit.RegisterEventForAudit(func(e *pipelineEvent) *audit.Entry {
return &audit.Entry{
Action: "task.created",
Actor: audit.UserActor(e.DoerID),
Target: audit.TaskTarget(e.TaskID),
}
})
events.RegisterListener((&pipelineEvent{}).Name(), other)
audit.RegisterEventForAudit(func(e *licenseGateEvent) *audit.Entry {
return &audit.Entry{
Action: "task.created",
Actor: audit.SystemActor(),
Target: audit.TaskTarget(1),
Metadata: map[string]any{"marker": e.Marker},
}
})
audit.RegisterEventForAudit(func(e *rotationEvent) *audit.Entry {
return &audit.Entry{
Action: "task.created",
Actor: audit.SystemActor(),
Target: audit.TaskTarget(1),
Metadata: map[string]any{"filler": e.Filler},
}
})
})
}
func setupAuditFile(t *testing.T) string {
t.Helper()
logfile := filepath.Join(t.TempDir(), "audit.log")
config.AuditLogfile.Set(logfile)
require.NoError(t, audit.Init())
t.Cleanup(audit.Close)
return logfile
}
func startEventRouter(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ready, err := events.InitEventsForTesting(ctx)
require.NoError(t, err)
<-ready
}
func waitForLines(t *testing.T, logfile string) []string {
t.Helper()
var lines []string
require.Eventually(t, func() bool {
content, err := os.ReadFile(logfile)
if err != nil {
return false
}
lines = strings.Split(strings.TrimSpace(string(content)), "\n")
if len(lines) == 1 && lines[0] == "" {
lines = nil
}
return len(lines) >= 1
}, 5*time.Second, 10*time.Millisecond, "expected at least one audit log line")
return lines
}
func TestAuditPipeline(t *testing.T) {
logfile := setupAuditFile(t)
license.SetForTests([]license.Feature{license.FeatureAuditLogs})
t.Cleanup(license.ResetForTests)
registerTestEvents()
startEventRouter(t)
ctx := events.WithRequestMeta(context.Background(), &events.RequestMeta{
IP: "192.0.2.42",
UserAgent: "test-agent/1.0",
RequestID: "req-123",
})
require.NoError(t, events.DispatchWithContext(ctx, &pipelineEvent{TaskID: 99, DoerID: 7}))
waitForLines(t, logfile)
select {
case <-other.called:
case <-time.After(5 * time.Second):
t.Fatal("other listener on the same topic was not called")
}
// A topic with multiple listeners must produce exactly one audit entry.
events.WaitForPendingHandlers()
lines := waitForLines(t, logfile)
require.Len(t, lines, 1)
var entry audit.Entry
require.NoError(t, json.Unmarshal([]byte(lines[0]), &entry))
assert.NotEmpty(t, entry.EventID)
assert.False(t, entry.Timestamp.IsZero())
assert.Equal(t, "task.created", entry.Action)
assert.Equal(t, audit.UserActor(7), entry.Actor)
assert.Equal(t, audit.TaskTarget(99), entry.Target)
assert.Equal(t, audit.OutcomeSuccess, entry.Outcome)
assert.Equal(t, "192.0.2.42", entry.Source.IP)
assert.Equal(t, "test-agent/1.0", entry.Source.UserAgent)
assert.Equal(t, audit.SourceHTTP, entry.Source.Type)
assert.Equal(t, "req-123", entry.RequestID)
}
func TestAuditLicenseGating(t *testing.T) {
logfile := setupAuditFile(t)
registerTestEvents()
startEventRouter(t)
// Without the licensed feature nothing must be written. The license check
// happens per event at handle time, so give the async handler a settle
// window before flipping the license back on.
license.ResetForTests()
require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "unlicensed"}))
require.Never(t, func() bool {
content, err := os.ReadFile(logfile)
return err == nil && len(content) > 0
}, 500*time.Millisecond, 10*time.Millisecond, "unlicensed event must not be written")
events.WaitForPendingHandlers()
license.SetForTests([]license.Feature{license.FeatureAuditLogs})
t.Cleanup(license.ResetForTests)
require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "licensed"}))
lines := waitForLines(t, logfile)
require.Len(t, lines, 1)
assert.Contains(t, lines[0], `"marker":"licensed"`)
assert.NotContains(t, lines[0], "unlicensed")
assert.Contains(t, lines[0], `"type":"system"`)
}
func TestAuditRotation(t *testing.T) {
logfile := setupAuditFile(t)
license.SetForTests([]license.Feature{license.FeatureAuditLogs})
t.Cleanup(license.ResetForTests)
registerTestEvents()
startEventRouter(t)
// Default max size is 100MB and config values are MB-granular, so two
// entries of ~600KB cross the limit with maxsizemb set to 1.
config.AuditRotationMaxSizeMB.Set("1")
t.Cleanup(func() { config.AuditRotationMaxSizeMB.Set("100") })
require.NoError(t, audit.Init())
filler := strings.Repeat("x", 600*1024)
require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler}))
waitForLines(t, logfile)
require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler}))
waitForLines(t, logfile)
require.Eventually(t, func() bool {
rotated, err := filepath.Glob(strings.TrimSuffix(logfile, ".log") + "-*.log")
return err == nil && len(rotated) == 1
}, 5*time.Second, 10*time.Millisecond, "expected one rotated audit log file")
}
func TestWriteAuditEventNotInitialized(t *testing.T) {
audit.Close()
err := audit.WriteAuditEvent(&audit.Entry{Action: "task.created"})
require.Error(t, err)
}

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

@ -0,0 +1,154 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package audit persists an audit trail of authentication, authorization and
// data lifecycle events as JSONL.
//
// Events opt in via RegisterEventForAudit, which subscribes one audit
// listener per event on the existing watermill bus; the event→Entry mapping
// is a closure passed at registration. The catalog of audited events lives in
// registerEventsForAuditLogging in pkg/models/listeners.go.
//
// Entries reference actors and targets by opaque ID only — deleting a user
// row orphans their audit references, which satisfies GDPR erasure without
// log redaction.
//
// Audit logging is gated twice: registration on the audit.enabled config key,
// and each write on the licensed audit_logs feature. The license is checked
// per event because it can change at runtime; enabled-but-unlicensed means
// listeners run and write nothing.
//
// Request attribution (IP, user agent, request id) flows from an Echo
// middleware through the request context onto message metadata — see
// pkg/events.RequestMeta. Events dispatched outside a request get
// source type "system" instead.
//
// A failed file write is returned to the router for retry. Tamper evidence
// comes from filesystem permissions (the file is created 0600) plus shipping
// the file to an external system, not from hash chains or signatures.
// Rotation is size-based with age-based cleanup of rotated files; retention
// is the operator's concern.
package audit
import "time"
// Entry is one audit log record. It only references actors and targets by
// opaque ID — no names, emails or content — so GDPR erasure is satisfied by
// deleting the referenced row.
type Entry struct {
EventID string `json:"event_id"` // UUIDv7
Timestamp time.Time `json:"timestamp"`
Actor Actor `json:"actor"`
Source Source `json:"source"`
Action string `json:"action"`
Target Target `json:"target"`
Outcome string `json:"outcome"`
Reason string `json:"reason,omitempty"`
RequestID string `json:"request_id,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type actorType string
type targetType string
// Actor is the principal which performed the audited action.
type Actor struct {
Type actorType `json:"type"`
ID int64 `json:"id,omitempty"`
}
// Source describes where the action originated from.
type Source struct {
Type string `json:"type"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// Target is the resource the audited action was performed on.
type Target struct {
Type targetType `json:"type"`
ID int64 `json:"id,omitempty"`
}
// Outcome values for an Entry.
const (
OutcomeSuccess = "success"
OutcomeFailure = "failure"
)
// Source types for an Entry.
const (
SourceHTTP = "http"
SourceSystem = "system"
)
// The action catalog. Every audited action is listed here.
const (
ActionLoginSucceeded = "auth.login.succeeded"
ActionLoginFailed = "auth.login.failed"
ActionLogout = "auth.logout"
ActionAPITokenIssued = "auth.api_token.issued" // #nosec G101 -- action identifier, not a credential
ActionAPITokenRevoked = "auth.api_token.revoked" // #nosec G101
ActionAPITokenUsed = "auth.api_token.used" // #nosec G101
ActionUserCreated = "user.created"
ActionTaskCreated = "task.created"
ActionTaskUpdated = "task.updated"
ActionTaskDeleted = "task.deleted"
ActionTaskAssigneeAdded = "task.assignee.added"
ActionTaskAssigneeRemoved = "task.assignee.removed"
ActionTaskCommentCreated = "task.comment.created"
ActionTaskCommentUpdated = "task.comment.updated"
ActionTaskCommentDeleted = "task.comment.deleted"
ActionTaskAttachmentCreated = "task.attachment.created"
ActionTaskAttachmentDeleted = "task.attachment.deleted"
ActionTaskRelationCreated = "task.relation.created"
ActionTaskRelationDeleted = "task.relation.deleted"
ActionProjectCreated = "project.created"
ActionProjectUpdated = "project.updated"
ActionProjectDeleted = "project.deleted"
ActionProjectSharedWithUser = "project.shared.user"
ActionProjectSharedWithTeam = "project.shared.team"
ActionTeamCreated = "team.created"
ActionTeamDeleted = "team.deleted"
ActionTeamMemberAdded = "team.member.added"
ActionTeamMemberRemoved = "team.member.removed"
)
// The type strings are unexported; these constructors are the only way to
// build an Actor or Target, so a mismatched type/ID pair can't be expressed.
func UserActor(id int64) Actor { return Actor{Type: "user", ID: id} }
func LinkShareActor(id int64) Actor { return Actor{Type: "link_share", ID: id} }
func SystemActor() Actor { return Actor{Type: "system"} }
// ActorFromDoerID maps a doer ID to an actor. Link shares are disguised as
// users with negative IDs throughout the event payloads.
func ActorFromDoerID(id int64) Actor {
if id < 0 {
return LinkShareActor(-id)
}
return UserActor(id)
}
func TaskTarget(id int64) Target { return Target{Type: "task", ID: id} }
func ProjectTarget(id int64) Target { return Target{Type: "project", ID: id} }
func UserTarget(id int64) Target { return Target{Type: "user", ID: id} }
func TeamTarget(id int64) Target { return Target{Type: "team", ID: id} }
func APITokenTarget(id int64) Target { return Target{Type: "api_token", ID: id} }

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

@ -0,0 +1,76 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package audit
import (
"encoding/json"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/license"
"github.com/ThreeDotsLabs/watermill/message"
)
type auditListener struct {
handle func(msg *message.Message) error
}
func (l *auditListener) Handle(msg *message.Message) error {
return l.handle(msg)
}
func (l *auditListener) Name() string {
return "audit"
}
// RegisterEventForAudit opts an event into audit logging. The event→Entry
// mapping is passed at registration, so opting in and defining the mapping
// are one unit and can't drift apart. Returning a nil Entry skips the event.
func RegisterEventForAudit[T any, PT interface {
*T
events.Event
}](toEntry func(PT) *Entry) {
name := PT(new(T)).Name()
events.RegisterListener(name, &auditListener{handle: func(msg *message.Message) error {
if !license.IsFeatureEnabled(license.FeatureAuditLogs) {
return nil // license is runtime-mutable — checked per event, not at registration
}
e := PT(new(T)) // fresh instance per message — handlers run concurrently
if err := json.Unmarshal(msg.Payload, e); err != nil {
return err
}
entry := toEntry(e)
if entry == nil {
return nil
}
enrichFromMetadata(entry, msg.Metadata)
return WriteAuditEvent(entry)
}})
}
func enrichFromMetadata(entry *Entry, meta message.Metadata) {
entry.Source.IP = meta.Get(events.MetadataKeyIP)
entry.Source.UserAgent = meta.Get(events.MetadataKeyUserAgent)
entry.RequestID = meta.Get(events.MetadataKeyRequestID)
if entry.Source.Type == "" {
if entry.Source.IP != "" || entry.Source.UserAgent != "" {
entry.Source.Type = SourceHTTP
} else {
entry.Source.Type = SourceSystem
}
}
}

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

@ -0,0 +1,211 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package audit
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"github.com/google/uuid"
)
var (
mu sync.Mutex
initialized bool
logFile *os.File
logfilePath string
currentSize int64
maxSizeBytes int64
maxAge time.Duration
lastSync time.Time
)
// Init opens the audit log file.
// Safe to call again to re-read the config (used by tests).
func Init() error {
mu.Lock()
defer mu.Unlock()
closeLocked()
logfilePath = config.AuditLogfile.GetString()
if logfilePath == "" {
logfilePath = filepath.Join(config.LogPath.GetString(), "audit.log")
}
maxSizeBytes = config.AuditRotationMaxSizeMB.GetInt64() * 1024 * 1024
maxAge = time.Duration(config.AuditRotationMaxAge.GetInt64()) * 24 * time.Hour
if err := os.MkdirAll(filepath.Dir(logfilePath), 0750); err != nil {
return fmt.Errorf("could not create audit log directory: %w", err)
}
if err := openLogFileLocked(); err != nil {
return err
}
initialized = true
return nil
}
// Close closes the audit log file. Used by tests.
func Close() {
mu.Lock()
defer mu.Unlock()
closeLocked()
}
func closeLocked() {
if logFile != nil {
_ = logFile.Sync()
_ = logFile.Close()
logFile = nil
}
initialized = false
}
func openLogFileLocked() error {
f, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("could not open audit log file %s: %w", logfilePath, err)
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return fmt.Errorf("could not stat audit log file %s: %w", logfilePath, err)
}
logFile = f
currentSize = info.Size()
return nil
}
// WriteAuditEvent writes one entry to the local audit log. A failed write is
// returned so the event router retries it.
func WriteAuditEvent(entry *Entry) error {
if entry.EventID == "" {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("could not generate audit event id: %w", err)
}
entry.EventID = id.String()
}
if entry.Timestamp.IsZero() {
entry.Timestamp = time.Now().UTC()
}
if entry.Outcome == "" {
entry.Outcome = OutcomeSuccess
}
line, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("could not marshal audit entry: %w", err)
}
mu.Lock()
if !initialized {
mu.Unlock()
return fmt.Errorf("audit log not initialized")
}
if err := rotateIfNeededLocked(int64(len(line)) + 1); err != nil {
mu.Unlock()
return err
}
// A failed rotation can leave us without an open file — retry the open
// here so writes self-heal via the router's retries instead of panicking.
if logFile == nil {
if err := openLogFileLocked(); err != nil {
mu.Unlock()
return err
}
}
written, err := logFile.Write(append(line, '\n'))
currentSize += int64(written)
if err == nil && time.Since(lastSync) > time.Second {
err = logFile.Sync()
lastSync = time.Now()
}
mu.Unlock()
if err != nil {
return fmt.Errorf("could not write audit entry: %w", err)
}
return nil
}
func rotateIfNeededLocked(addition int64) error {
if maxSizeBytes <= 0 || currentSize+addition <= maxSizeBytes {
return nil
}
_ = logFile.Sync()
_ = logFile.Close()
logFile = nil
rotatedPath := rotatedFileName(logfilePath, time.Now().UTC())
if err := os.Rename(logfilePath, rotatedPath); err != nil {
// Reopen the original so logging continues even if rotation failed.
if openErr := openLogFileLocked(); openErr != nil {
return errors.Join(fmt.Errorf("could not rotate audit log: %w", err), openErr)
}
return fmt.Errorf("could not rotate audit log: %w", err)
}
cleanupRotatedFiles()
return openLogFileLocked()
}
func rotatedFileName(path string, now time.Time) string {
ext := filepath.Ext(path)
return strings.TrimSuffix(path, ext) + "-" + now.Format("20060102T150405.000") + ext
}
func cleanupRotatedFiles() {
if maxAge <= 0 {
return
}
ext := filepath.Ext(logfilePath)
pattern := strings.TrimSuffix(logfilePath, ext) + "-*" + ext
matches, err := filepath.Glob(pattern)
if err != nil {
log.Errorf("Could not list rotated audit log files: %s", err)
return
}
cutoff := time.Now().Add(-maxAge)
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.ModTime().After(cutoff) {
continue
}
if err := os.Remove(match); err != nil {
log.Errorf("Could not remove old audit log file %s: %s", match, err)
}
}
}

View File

@ -220,6 +220,11 @@ const (
WebhooksProxyPassword Key = `webhooks.proxypassword`
WebhooksAllowNonRoutableIPs Key = `webhooks.allownonroutableips`
AuditEnabled Key = `audit.enabled`
AuditLogfile Key = `audit.logfile`
AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb`
AuditRotationMaxAge Key = `audit.rotation.maxage`
OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips`
OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl`
OutgoingRequestsProxyPassword Key = `outgoingrequests.proxypassword`
@ -483,6 +488,11 @@ func InitDefaultConfig() {
WebhooksEnabled.setDefault(true)
WebhooksTimeoutSeconds.setDefault(30)
WebhooksAllowNonRoutableIPs.setDefault(false)
// Audit
AuditEnabled.setDefault(false)
AuditLogfile.setDefault("") // empty means <log.path>/audit.log, resolved at init
AuditRotationMaxSizeMB.setDefault(100)
AuditRotationMaxAge.setDefault(30)
// Outgoing Requests
OutgoingRequestsAllowNonRoutableIPs.setDefault(false)
OutgoingRequestsTimeoutSeconds.setDefault(30)

View File

@ -127,7 +127,7 @@ func RestoreAndTruncate(table string, contents []map[string]interface{}) (err er
return err
}
} else {
if _, err := x.Query("TRUNCATE TABLE ?", table); err != nil {
if _, err := x.Query("TRUNCATE TABLE " + x.Quote(table)); err != nil {
return err
}
}
@ -148,7 +148,7 @@ func TruncateAllTables() error {
return err
}
} else {
if _, err := x.Query("TRUNCATE TABLE ?", name); err != nil {
if _, err := x.Query("TRUNCATE TABLE " + x.Quote(name)); err != nil {
return err
}
}

View File

@ -201,6 +201,13 @@ func InitEventsForTesting(ctx context.Context) (<-chan struct{}, error) {
// Dispatch dispatches an event
func Dispatch(event Event) error {
return DispatchWithContext(context.Background(), event)
}
// DispatchWithContext dispatches an event and copies request metadata from the
// context (see WithRequestMeta) onto the message metadata, so listeners can
// attribute the event to the originating HTTP request.
func DispatchWithContext(ctx context.Context, event Event) error {
if isUnderTest {
dispatchedTestEvents = append(dispatchedTestEvents, event)
return nil
@ -216,6 +223,17 @@ func Dispatch(event Event) error {
}
msg := message.NewMessage(watermill.NewUUID(), content)
if meta := RequestMetaFromContext(ctx); meta != nil {
if meta.IP != "" {
msg.Metadata.Set(MetadataKeyIP, meta.IP)
}
if meta.UserAgent != "" {
msg.Metadata.Set(MetadataKeyUserAgent, meta.UserAgent)
}
if meta.RequestID != "" {
msg.Metadata.Set(MetadataKeyRequestID, meta.RequestID)
}
}
return pubsub.Publish(event.Name(), msg)
}
@ -241,8 +259,9 @@ func DispatchOnCommit(key any, event Event) {
// DispatchPending dispatches all events accumulated for the given key and removes them.
// Call this after s.Commit(). Safe to call even if no events were registered.
// Request metadata on the context (see WithRequestMeta) is copied onto each message.
// If any event fails to dispatch, the error is logged but remaining events are still dispatched.
func DispatchPending(key any) {
func DispatchPending(ctx context.Context, key any) {
val, ok := pendingEvents.LoadAndDelete(key)
if !ok {
return
@ -251,7 +270,7 @@ func DispatchPending(key any) {
// No need to lock here since we've already removed it from the map
// and this key won't receive new events
for _, event := range queue.events {
if err := Dispatch(event); err != nil {
if err := DispatchWithContext(ctx, event); err != nil {
log.Errorf("Failed to dispatch event %s: %v", event.Name(), err)
}
}

View File

@ -17,6 +17,7 @@
package events
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
@ -40,7 +41,7 @@ func TestDispatchOnCommit(t *testing.T) {
assert.Equal(t, 0, CountDispatchedEvents("test.event"))
// Simulate post-commit dispatch
DispatchPending(key)
DispatchPending(context.Background(), key)
// Now it should be dispatched
assert.Equal(t, 1, CountDispatchedEvents("test.event"))
@ -57,7 +58,7 @@ func TestDispatchOnCommitMultipleEvents(t *testing.T) {
assert.Equal(t, 0, CountDispatchedEvents("test.event"))
DispatchPending(key)
DispatchPending(context.Background(), key)
assert.Equal(t, 3, CountDispatchedEvents("test.event"))
}
@ -74,7 +75,7 @@ func TestCleanupPending(t *testing.T) {
CleanupPending(key)
// Dispatching after cleanup should be a no-op
DispatchPending(key)
DispatchPending(context.Background(), key)
assert.Equal(t, 0, CountDispatchedEvents("test.event"))
}
@ -85,7 +86,7 @@ func TestDispatchPendingNoEvents(t *testing.T) {
key := new(int)
// Should be a no-op
DispatchPending(key)
DispatchPending(context.Background(), key)
// Verify no events were dispatched
assert.Equal(t, 0, CountDispatchedEvents("test.event"))

View File

@ -0,0 +1,55 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import "context"
// RequestMeta carries information about the originating HTTP request. It is
// stashed on the request context by a middleware and copied onto message
// metadata at publish time, so listeners (e.g. audit) can attribute an event
// to a request without every dispatch site changing its signature.
type RequestMeta struct {
IP string
UserAgent string
RequestID string
}
// Message metadata keys holding request information.
const (
MetadataKeyIP = "request_ip"
MetadataKeyUserAgent = "request_user_agent"
MetadataKeyRequestID = "request_id"
)
type requestMetaKeyType struct{}
var requestMetaKey requestMetaKeyType
// WithRequestMeta returns a context carrying the given request metadata.
func WithRequestMeta(ctx context.Context, meta *RequestMeta) context.Context {
return context.WithValue(ctx, requestMetaKey, meta)
}
// RequestMetaFromContext returns the request metadata stored on the context,
// or nil if there is none.
func RequestMetaFromContext(ctx context.Context) *RequestMeta {
if ctx == nil {
return nil
}
meta, _ := ctx.Value(requestMetaKey).(*RequestMeta)
return meta
}

View File

@ -76,6 +76,18 @@ func ClearDispatchedEvents() {
dispatchedTestEvents = nil
}
// GetDispatchedEvents returns all dispatched test events matching the given name, letting tests
// assert on the event payload (not just that it was dispatched).
func GetDispatchedEvents(eventName string) []Event {
var events []Event
for _, testEvent := range dispatchedTestEvents {
if testEvent.Name() == eventName {
events = append(events, testEvent)
}
}
return events
}
// CountDispatchedEvents counts how many events of a specific type have been dispatched.
func CountDispatchedEvents(eventName string) int {
count := 0

View File

@ -19,6 +19,7 @@ package initialize
import (
"time"
"code.vikunja.io/api/pkg/audit"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
@ -98,6 +99,12 @@ func FullInitWithoutAsync() {
// See the package comment in pkg/license/license.go before removing.
license.Init()
if config.AuditEnabled.GetBool() {
if err := audit.Init(); err != nil {
log.Fatalf("Could not initialize audit logging: %s", err)
}
}
// Start the mail daemon
mail.StartMailDaemon()

View File

@ -0,0 +1,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 migration
import (
"fmt"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
type taskPosition20260617153629 struct {
TaskID int64 `xorm:"bigint not null index"`
ProjectViewID int64 `xorm:"bigint not null index"`
Position float64 `xorm:"double not null"`
}
func (taskPosition20260617153629) TableName() string {
return "task_positions"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260617153629",
Description: "deduplicate task positions and add a unique index on task_id + project_view_id",
Migrate: func(tx *xorm.Engine) error {
s := tx.NewSession()
defer s.Close()
err := s.Begin()
if err != nil {
return err
}
// First remove all duplicate entries. A task may only ever have a
// single position per view; rapid task creation could race and
// insert more than one row before this constraint existed.
duplicates := []taskPosition20260617153629{}
err = s.
Select("task_id, project_view_id").
GroupBy("task_id, project_view_id").
Having("count(*) > 1").
Find(&duplicates)
if err != nil {
_ = s.Rollback()
return err
}
// Keep the lowest position of each group so the result is
// deterministic across databases.
kept := []taskPosition20260617153629{}
for _, dup := range duplicates {
row := taskPosition20260617153629{}
has, err := s.
Where("task_id = ? AND project_view_id = ?", dup.TaskID, dup.ProjectViewID).
OrderBy("position ASC").
Get(&row)
if err != nil {
_ = s.Rollback()
return err
}
if !has {
// The pair was just reported as duplicated by the GroupBy above,
// so a row must exist. If it doesn't, fail instead of continuing —
// the delete loop below would otherwise drop every row for the pair
// without re-inserting one.
_ = s.Rollback()
return fmt.Errorf("no task_positions row found for task %d and project view %d while deduplicating positions", dup.TaskID, dup.ProjectViewID)
}
kept = append(kept, row)
}
for _, dup := range duplicates {
_, err = s.
Where("task_id = ? AND project_view_id = ?", dup.TaskID, dup.ProjectViewID).
Delete(&taskPosition20260617153629{})
if err != nil {
_ = s.Rollback()
return err
}
}
for _, position := range kept {
_, err = s.Insert(&position)
if err != nil {
_ = s.Rollback()
return err
}
}
err = s.Commit()
if err != nil {
return err
}
// Then create the unique index
var query string
switch tx.Dialect().URI().DBType {
case schemas.MYSQL:
query = "CREATE UNIQUE INDEX UQE_task_positions_task_project_view ON task_positions (task_id, project_view_id)"
default:
query = "CREATE UNIQUE INDEX IF NOT EXISTS UQE_task_positions_task_project_view ON task_positions (task_id, project_view_id)"
}
_, err = tx.Exec(query)
return err
},
Rollback: func(_ *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,55 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
// Mirrors models.Session; adds the two columns RP-Initiated Logout needs.
type sessionOIDCLogout20260619155410 struct {
ID string `xorm:"varchar(36) not null unique pk"`
UserID int64 `xorm:"bigint not null index"`
TokenHash string `xorm:"varchar(64) not null unique index"`
DeviceInfo string `xorm:"text"`
IPAddress string `xorm:"varchar(100)"`
IsLongSession bool `xorm:"not null default false"`
OIDCIDToken string `xorm:"text"`
OIDCProviderKey string `xorm:"varchar(250)"`
LastActive time.Time `xorm:"not null"`
Created time.Time `xorm:"created not null"`
}
func (sessionOIDCLogout20260619155410) TableName() string {
return "sessions"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260619155410",
Description: "Add oidc_id_token and oidc_provider_key columns to sessions for RP-Initiated Logout",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync(sessionOIDCLogout20260619155410{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -24,6 +24,7 @@ import (
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web"
@ -121,7 +122,17 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) {
}
_, err = s.Insert(t)
return err
if err != nil {
return err
}
events.DispatchOnCommit(s, &APITokenIssuedEvent{
TokenID: t.ID,
DoerID: a.GetID(),
OwnerID: t.OwnerID,
})
return nil
}
func HashToken(token, salt string) string {
@ -192,10 +203,19 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
// @Failure 404 {object} web.HTTPError "The token does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tokens/{tokenID} [delete]
func (t *APIToken) Delete(s *xorm.Session, _ web.Auth) (err error) {
func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) {
// Ownership is verified in CanDelete; delete by ID only.
_, err = s.Where("id = ?", t.ID).Delete(&APIToken{})
return err
if err != nil {
return err
}
events.DispatchOnCommit(s, &APITokenRevokedEvent{
TokenID: t.ID,
DoerID: a.GetID(),
})
return nil
}
// HasCaldavAccess checks whether the token has the caldav access permission.

View File

@ -535,6 +535,34 @@ func (err *ErrProjectViewDoesNotExist) HTTPError() web.HTTPError {
}
}
// ErrProjectHasNoBackground represents an error where a project has no background set.
type ErrProjectHasNoBackground struct {
ProjectID int64
}
// IsErrProjectHasNoBackground checks if an error is ErrProjectHasNoBackground.
func IsErrProjectHasNoBackground(err error) bool {
_, ok := err.(*ErrProjectHasNoBackground)
return ok
}
func (err *ErrProjectHasNoBackground) Error() string {
return fmt.Sprintf("Project has no background [ProjectID: %d]", err.ProjectID)
}
// ErrCodeProjectHasNoBackground holds the unique world-error code of this error
const ErrCodeProjectHasNoBackground = 3015
// HTTPError holds the http error description
func (err *ErrProjectHasNoBackground) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeProjectHasNoBackground,
// Message kept verbatim from v1's inline handler error so the wire body is unchanged.
Message: "Project background not found",
}
}
// ==============
// Task errors
// ==============
@ -2596,3 +2624,32 @@ func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError {
Message: "A time entry's end time cannot be before its start time.",
}
}
// =================
// User export errors
// =================
// ErrUserDataExportDoesNotExist represents an error where a user has no ready data export to download.
type ErrUserDataExportDoesNotExist struct{}
// IsErrUserDataExportDoesNotExist checks if an error is ErrUserDataExportDoesNotExist.
func IsErrUserDataExportDoesNotExist(err error) bool {
_, ok := err.(ErrUserDataExportDoesNotExist)
return ok
}
func (err ErrUserDataExportDoesNotExist) Error() string {
return "No user data export found"
}
// ErrCodeUserDataExportDoesNotExist holds the unique world-error code of this error
const ErrCodeUserDataExportDoesNotExist = 19001
// HTTPError holds the http error description
func (err ErrUserDataExportDoesNotExist) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeUserDataExportDoesNotExist,
Message: "No user data export found.",
}
}

View File

@ -18,7 +18,6 @@ package models
import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
)
/////////////////
@ -230,8 +229,8 @@ func (l *ProjectCreatedEvent) Name() string {
// ProjectUpdatedEvent represents an event where a project has been updated
type ProjectUpdatedEvent struct {
Project *Project `json:"project"`
Doer web.Auth `json:"doer"`
Project *Project `json:"project"`
Doer *user.User `json:"doer"`
}
// Name defines the name for ProjectUpdatedEvent
@ -241,8 +240,8 @@ func (p *ProjectUpdatedEvent) Name() string {
// ProjectDeletedEvent represents an event where a project has been deleted
type ProjectDeletedEvent struct {
Project *Project `json:"project"`
Doer web.Auth `json:"doer"`
Project *Project `json:"project"`
Doer *user.User `json:"doer"`
}
// Name defines the name for ProjectDeletedEvent
@ -258,7 +257,7 @@ func (p *ProjectDeletedEvent) Name() string {
type ProjectSharedWithUserEvent struct {
Project *Project `json:"project"`
User *user.User `json:"user"`
Doer web.Auth `json:"doer"`
Doer *user.User `json:"doer"`
}
// Name defines the name for ProjectSharedWithUserEvent
@ -268,9 +267,9 @@ func (p *ProjectSharedWithUserEvent) Name() string {
// ProjectSharedWithTeamEvent represents an event where a project has been shared with a team
type ProjectSharedWithTeamEvent struct {
Project *Project `json:"project"`
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
Project *Project `json:"project"`
Team *Team `json:"team"`
Doer *user.User `json:"doer"`
}
// Name defines the name for ProjectSharedWithTeamEvent
@ -308,8 +307,8 @@ func (t *TeamMemberRemovedEvent) Name() string {
// TeamCreatedEvent represents a TeamCreatedEvent event
type TeamCreatedEvent struct {
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
Team *Team `json:"team"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TeamCreatedEvent
@ -319,8 +318,8 @@ func (t *TeamCreatedEvent) Name() string {
// TeamDeletedEvent represents a TeamDeletedEvent event
type TeamDeletedEvent struct {
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
Team *Team `json:"team"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TeamDeletedEvent
@ -395,3 +394,44 @@ type TimeEntryDeletedEvent struct {
func (e *TimeEntryDeletedEvent) Name() string {
return "time-entry.deleted"
}
////////////////////
// API Token Events
// API token events carry IDs only: the freshly created token struct holds the
// raw token string, which must never end up in a message payload (the poison
// queue logs payloads on handler failure).
// APITokenIssuedEvent represents an API token being created
type APITokenIssuedEvent struct {
TokenID int64 `json:"token_id"`
DoerID int64 `json:"doer_id"`
OwnerID int64 `json:"owner_id"`
}
// Name defines the name for APITokenIssuedEvent
func (e *APITokenIssuedEvent) Name() string {
return "api-token.issued"
}
// APITokenRevokedEvent represents an API token being deleted
type APITokenRevokedEvent struct {
TokenID int64 `json:"token_id"`
DoerID int64 `json:"doer_id"`
}
// Name defines the name for APITokenRevokedEvent
func (e *APITokenRevokedEvent) Name() string {
return "api-token.revoked"
}
// APITokenUsedEvent represents an API token authenticating a request
type APITokenUsedEvent struct {
TokenID int64 `json:"token_id"`
OwnerID int64 `json:"owner_id"`
}
// Name defines the name for APITokenUsedEvent
func (e *APITokenUsedEvent) Name() string {
return "api-token.used"
}

View File

@ -404,6 +404,64 @@ func exportProjectBackgrounds(s *xorm.Session, u *user.User, wr *zip.Writer) (er
return utils.WriteFilesToZip(backgroundFiles, wr)
}
// GetUserDataExportFile loads the user's ready data export with its bytes open for
// reading. It returns ErrUserDataExportDoesNotExist when the user never requested an
// export or the underlying file is gone. The caller must close the returned reader.
func GetUserDataExportFile(u *user.User) (*files.File, error) {
if u.ExportFileID == 0 {
return nil, ErrUserDataExportDoesNotExist{}
}
exportFile := &files.File{ID: u.ExportFileID}
if err := exportFile.LoadFileMetaByID(); err != nil {
if files.IsErrFileDoesNotExist(err) {
return nil, ErrUserDataExportDoesNotExist{}
}
return nil, err
}
if err := exportFile.LoadFileByID(); err != nil {
if os.IsNotExist(err) {
return nil, ErrUserDataExportDoesNotExist{}
}
return nil, err
}
return exportFile, nil
}
// GetUserDataExportStatus returns metadata about the user's current data export, or
// nil when none exists. The expiry mirrors the cleanup cron's 7-day retention.
func GetUserDataExportStatus(u *user.User) (*UserExportStatus, error) {
if u.ExportFileID == 0 {
return nil, nil
}
exportFile := &files.File{ID: u.ExportFileID}
if err := exportFile.LoadFileMetaByID(); err != nil {
// A missing meta row means there is no export — mirror the download path
// (404 there) instead of surfacing a 500.
if files.IsErrFileDoesNotExist(err) {
return nil, nil
}
return nil, err
}
return &UserExportStatus{
ID: exportFile.ID,
Size: exportFile.Size,
Created: exportFile.Created,
Expires: exportFile.Created.Add(7 * 24 * time.Hour),
}, nil
}
// UserExportStatus is the metadata returned for a user's current data export.
type UserExportStatus struct {
ID int64 `json:"id" readOnly:"true" doc:"The id of the export file."`
Size uint64 `json:"size" readOnly:"true" doc:"The size of the export file in bytes."`
Created time.Time `json:"created" readOnly:"true" doc:"When the export was created."`
Expires time.Time `json:"expires" readOnly:"true" doc:"When the export will be automatically deleted (7 days after creation)."`
}
func RegisterOldExportCleanupCron() {
const logPrefix = "[User Export Cleanup Cron] "

53
pkg/models/export_test.go Normal file
View File

@ -0,0 +1,53 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public 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 (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetUserDataExportStatus(t *testing.T) {
t.Run("no export", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
status, err := GetUserDataExportStatus(&user.User{ID: 15})
require.NoError(t, err)
assert.Nil(t, status)
})
t.Run("with export", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
status, err := GetUserDataExportStatus(&user.User{ID: 1, ExportFileID: 1})
require.NoError(t, err)
require.NotNil(t, status)
assert.Equal(t, int64(1), status.ID)
})
t.Run("export points at a missing file", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// A dangling ExportFileID must read as "no export" rather than erroring,
// matching the download path which 404s the same case.
status, err := GetUserDataExportStatus(&user.User{ID: 15, ExportFileID: 9999})
require.NoError(t, err)
assert.Nil(t, status)
})
}

View File

@ -21,9 +21,10 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
// TaskBucket represents the relation between a task and a kanban bucket.
@ -59,27 +60,19 @@ func (b *TaskBucket) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
}
func (b *TaskBucket) upsert(s *xorm.Session) (err error) {
count, err := s.Where("task_id = ? AND project_view_id = ?", b.TaskID, b.ProjectViewID).
Cols("bucket_id").
Update(b)
if err != nil {
return
}
if count == 0 {
_, err = s.Insert(b)
if err != nil {
// Check if this is a unique constraint violation for the task_buckets table
if db.IsUniqueConstraintError(err, "UQE_task_buckets_task_project_view") {
return ErrTaskAlreadyExistsInBucket{
TaskID: b.TaskID,
ProjectViewID: b.ProjectViewID,
}
}
return
}
// A native upsert moves the task in one atomic statement, without
// depending on the affected-row count (MySQL/MariaDB report 0 affected
// rows for an unchanged value).
onConflict := "ON CONFLICT (task_id, project_view_id) DO UPDATE SET bucket_id = excluded.bucket_id"
if db.Type() == schemas.MYSQL {
onConflict = "ON DUPLICATE KEY UPDATE bucket_id = VALUES(bucket_id)"
}
// Raw SQL bypasses xorm's bean-based table-name handling, so qualify the
// table ourselves to honor a configured postgres schema (database.schema).
table := s.Engine().TableName(b, true)
query := "INSERT INTO " + table + " (task_id, project_view_id, bucket_id) VALUES (?, ?, ?) " + onConflict
_, err = s.Exec(query, b.TaskID, b.ProjectViewID, b.BucketID)
return
}
@ -152,10 +145,8 @@ func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) {
if err != nil {
return err
}
// If the task is already in the default bucket, skip the
// upsert — MySQL's UPDATE returns 0 affected rows when
// the value is unchanged, which would make upsert fall
// through to INSERT and hit the unique constraint.
// The task is already in the default bucket, so there is
// nothing to move and no count to bump.
if b.BucketID == oldTaskBucket.BucketID {
updateBucket = false
}
@ -252,10 +243,9 @@ func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
}
if b.Task != nil {
doer, _ := user.GetFromAuth(a)
events.DispatchOnCommit(s, &TaskUpdatedEvent{
Task: b.Task,
Doer: doer,
Doer: doerFromAuth(s, a),
})
}
return nil

View File

@ -226,6 +226,125 @@ func TestTaskBucket_Update(t *testing.T) {
})
})
t.Run("done task already in another view's done bucket", func(t *testing.T) {
// Regression test: marking a task done syncs it into the done bucket
// of every kanban view in the project. When the task already sits in
// such a view's done bucket the sync is a no-op update, but on
// MySQL/MariaDB an UPDATE that doesn't change the value reports 0
// affected rows. The upsert then mistook that for "row missing" and
// inserted, hitting the unique index with ErrTaskAlreadyExistsInBucket.
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// A second manual kanban view on project 1. Creating it auto-generates
// the To-Do/Doing/Done buckets and sets its done bucket.
secondView := &ProjectView{
Title: "Second Kanban",
ProjectID: 1,
ViewKind: ProjectViewKindKanban,
BucketConfigurationMode: BucketConfigurationModeManual,
}
err := secondView.Create(s, u)
require.NoError(t, err)
require.NotZero(t, secondView.DoneBucketID)
// Pre-place task 1 in the second view's done bucket without going
// through the done-sync, so the task itself is still open and view 4
// still has it in its default bucket.
_, err = s.Where("task_id = ? AND project_view_id = ?", 1, secondView.ID).
Cols("bucket_id").
Update(&TaskBucket{BucketID: secondView.DoneBucketID})
require.NoError(t, err)
// Moving task 1 into view 4's done bucket marks it done and triggers
// the cross-view sync into the second view's done bucket, where it
// already lives. This must succeed rather than error.
tb := &TaskBucket{
TaskID: 1,
BucketID: 3, // done bucket on view 4
ProjectViewID: 4,
ProjectID: 1,
}
err = tb.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.True(t, tb.Task.Done)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"bucket_id": 3,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"project_view_id": secondView.ID,
"bucket_id": secondView.DoneBucketID,
}, false)
})
t.Run("saved filter: first task into empty limited bucket is allowed", func(t *testing.T) {
// Regression test for #2672: on a saved-filter kanban view the bucket
// limit was checked against the total number of tasks matching the
// filter instead of the number of tasks actually in the target bucket,
// so adding the first task to an empty limited bucket was wrongly
// rejected with ErrBucketLimitExceeded.
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// A saved filter matching many tasks; the filter total is well above
// the bucket limit we set below.
sf := &SavedFilter{
Title: "limit-filter",
Filters: &TaskCollection{Filter: "done = false"},
}
err := sf.Create(s, u)
require.NoError(t, err)
filterProjectID := getProjectIDFromSavedFilterID(sf.ID)
view := &ProjectView{}
exists, err := s.Where("project_id = ? AND view_kind = ?", filterProjectID, ProjectViewKindKanban).Get(view)
require.NoError(t, err)
require.True(t, exists)
// All matching tasks are placed in the default bucket on creation;
// pick three of them to move into a fresh, empty bucket.
var defaultTasks []*TaskBucket
err = s.Where("project_view_id = ?", view.ID).Find(&defaultTasks)
require.NoError(t, err)
require.GreaterOrEqual(t, len(defaultTasks), 3, "filter must match enough tasks to exceed the bucket limit")
limitedBucket := &Bucket{
Title: "limited",
ProjectViewID: view.ID,
ProjectID: filterProjectID,
Limit: 2,
}
err = limitedBucket.Create(s, u)
require.NoError(t, err)
moveTaskToBucket := func(taskID int64) error {
tb := &TaskBucket{
TaskID: taskID,
BucketID: limitedBucket.ID,
ProjectViewID: view.ID,
ProjectID: filterProjectID,
}
return tb.Update(s, u)
}
// Moving the FIRST task into the empty bucket must succeed (0/2 -> 1/2).
require.NoError(t, moveTaskToBucket(defaultTasks[0].TaskID))
// The second one fills the bucket up to the limit (1/2 -> 2/2).
require.NoError(t, moveTaskToBucket(defaultTasks[1].TaskID))
// The third one would exceed the limit and must be rejected.
err = moveTaskToBucket(defaultTasks[2].TaskID)
require.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
t.Run("keep done timestamp when moving task between projects", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}

View File

@ -22,6 +22,7 @@ import (
"strconv"
"time"
"code.vikunja.io/api/pkg/audit"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
@ -82,6 +83,249 @@ func RegisterListeners() {
// Internal delivery listener — one message per webhook with its own retry lifecycle
events.RegisterListener((&WebhookDeliveryEvent{}).Name(), &WebhookDeliveryListener{})
}
if config.AuditEnabled.GetBool() {
registerEventsForAuditLogging()
}
}
func auditActorFromUser(u *user.User) audit.Actor {
if u == nil {
return audit.SystemActor()
}
return audit.ActorFromDoerID(u.ID)
}
// registerEventsForAuditLogging opts events into audit logging. This block is
// the catalog of the entire audited surface — an event without a registration
// here is not audited.
func registerEventsForAuditLogging() {
// Auth boundary
audit.RegisterEventForAudit(func(e *user.LoginSucceededEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionLoginSucceeded,
Actor: audit.UserActor(e.User.ID),
Target: audit.UserTarget(e.User.ID),
}
})
audit.RegisterEventForAudit(func(e *user.LoginFailedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionLoginFailed,
Actor: audit.UserActor(e.User.ID),
Target: audit.UserTarget(e.User.ID),
Outcome: audit.OutcomeFailure,
Reason: "wrong password",
}
})
audit.RegisterEventForAudit(func(e *user.LogoutEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionLogout,
Actor: audit.UserActor(e.UserID),
Target: audit.UserTarget(e.UserID),
}
})
audit.RegisterEventForAudit(func(e *APITokenIssuedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionAPITokenIssued,
Actor: audit.UserActor(e.DoerID),
Target: audit.APITokenTarget(e.TokenID),
Metadata: map[string]any{"owner_id": e.OwnerID},
}
})
audit.RegisterEventForAudit(func(e *APITokenRevokedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionAPITokenRevoked,
Actor: audit.UserActor(e.DoerID),
Target: audit.APITokenTarget(e.TokenID),
}
})
audit.RegisterEventForAudit(func(e *APITokenUsedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionAPITokenUsed,
Actor: audit.UserActor(e.OwnerID),
Target: audit.APITokenTarget(e.TokenID),
}
})
// Users
audit.RegisterEventForAudit(func(e *user.CreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionUserCreated,
Actor: audit.UserActor(e.User.ID),
Target: audit.UserTarget(e.User.ID),
}
})
// Tasks
audit.RegisterEventForAudit(func(e *TaskCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
}
})
audit.RegisterEventForAudit(func(e *TaskUpdatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskUpdated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
}
})
audit.RegisterEventForAudit(func(e *TaskDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
}
})
audit.RegisterEventForAudit(func(e *TaskAssigneeCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAssigneeAdded,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"assignee_id": e.Assignee.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskAssigneeDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAssigneeRemoved,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"assignee_id": e.Assignee.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskCommentCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCommentCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"comment_id": e.Comment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskCommentUpdatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCommentUpdated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"comment_id": e.Comment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskCommentDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCommentDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"comment_id": e.Comment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskAttachmentCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAttachmentCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"attachment_id": e.Attachment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskAttachmentDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAttachmentDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"attachment_id": e.Attachment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskRelationCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskRelationCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{
"other_task_id": e.Relation.OtherTaskID,
"relation_kind": e.Relation.RelationKind,
},
}
})
audit.RegisterEventForAudit(func(e *TaskRelationDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskRelationDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{
"other_task_id": e.Relation.OtherTaskID,
"relation_kind": e.Relation.RelationKind,
},
}
})
// Projects
audit.RegisterEventForAudit(func(e *ProjectCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionProjectCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
}
})
audit.RegisterEventForAudit(func(e *ProjectUpdatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionProjectUpdated,
Actor: auditActorFromUser(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
}
})
audit.RegisterEventForAudit(func(e *ProjectDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionProjectDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
}
})
audit.RegisterEventForAudit(func(e *ProjectSharedWithUserEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionProjectSharedWithUser,
Actor: auditActorFromUser(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
Metadata: map[string]any{"user_id": e.User.ID},
}
})
audit.RegisterEventForAudit(func(e *ProjectSharedWithTeamEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionProjectSharedWithTeam,
Actor: auditActorFromUser(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
Metadata: map[string]any{"team_id": e.Team.ID},
}
})
// Teams
audit.RegisterEventForAudit(func(e *TeamCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTeamCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
}
})
audit.RegisterEventForAudit(func(e *TeamDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTeamDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
}
})
audit.RegisterEventForAudit(func(e *TeamMemberAddedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTeamMemberAdded,
Actor: auditActorFromUser(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
Metadata: map[string]any{"member_id": e.Member.ID},
}
})
audit.RegisterEventForAudit(func(e *TeamMemberRemovedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTeamMemberRemoved,
Actor: auditActorFromUser(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
Metadata: map[string]any{"member_id": e.Member.ID},
}
})
}
//////

View File

@ -53,7 +53,13 @@ func (d *DatabaseNotifications) ReadAll(s *xorm.Session, a web.Auth, _ string, p
}
limit, start := getLimitFromPageIndex(page, perPage)
return notifications.GetNotificationsForUser(s, a.GetID(), limit, start)
ns, resultCount, total, err := notifications.GetNotificationsForUser(s, a.GetID(), limit, start)
if err != nil {
return nil, 0, 0, err
}
refreshNotificationsUsers(s, ns)
return ns, resultCount, total, nil
}
// CanUpdate checks if a user can mark a notification as read.

View File

@ -0,0 +1,110 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"encoding/json"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"xorm.io/xorm"
)
// refreshNotificationsUsers reloads each notification's embedded users from the
// database. Notifications serialized before the acting user was resolved with
// its full profile (#2720) stored only id+username, so without this they keep
// rendering the auto-generated username instead of the display name. It runs at
// read time and is not persisted; one cache is shared across the batch so a
// user recurring across notifications is fetched only once.
func refreshNotificationsUsers(s *xorm.Session, dbNotifications []*notifications.DatabaseNotification) {
cache := make(map[int64]*user.User)
for _, dbn := range dbNotifications {
refreshNotificationUsers(s, dbn, cache)
}
}
func refreshNotificationUsers(s *xorm.Session, dbn *notifications.DatabaseNotification, cache map[int64]*user.User) {
typed, ok := notifications.Lookup(dbn.Name)
if !ok {
return
}
raw, err := json.Marshal(dbn.Notification)
if err != nil {
log.Errorf("Could not marshal notification %d to refresh its users: %v", dbn.ID, err)
return
}
if err := json.Unmarshal(raw, typed); err != nil {
log.Errorf("Could not unmarshal notification %d to refresh its users: %v", dbn.ID, err)
return
}
for _, u := range notificationUsers(typed) {
refreshUser(s, u, cache)
}
dbn.Notification = typed
}
// notificationUsers returns the user fields a stored notification renders, so
// they can be reloaded. New notification types carrying a user belong here.
func notificationUsers(n notifications.Notification) []*user.User {
switch n := n.(type) {
case *TaskCommentNotification:
return []*user.User{n.Doer}
case *TaskAssignedNotification:
return []*user.User{n.Doer, n.Assignee}
case *TaskDeletedNotification:
return []*user.User{n.Doer}
case *ProjectCreatedNotification:
return []*user.User{n.Doer}
case *TeamMemberAddedNotification:
return []*user.User{n.Doer, n.Member}
case *UserMentionedInTaskNotification:
return []*user.User{n.Doer}
default:
return nil
}
}
// refreshUser overwrites the user in place with its current database row. A
// disabled or locked account is still returned fully populated, so only a
// missing user or a real database error leaves the stored value untouched.
func refreshUser(s *xorm.Session, u *user.User, cache map[int64]*user.User) {
if u == nil || u.ID == 0 {
return
}
fresh, cached := cache[u.ID]
if !cached {
loaded, err := user.GetUserByID(s, u.ID)
if err != nil && !user.IsErrUserStatusError(err) {
if !user.IsErrUserDoesNotExist(err) {
log.Errorf("Could not refresh user %d for a notification: %v", u.ID, err)
}
cache[u.ID] = nil
return
}
fresh = loaded
cache[u.ID] = fresh
}
if fresh != nil {
*u = *fresh
}
}

View File

@ -0,0 +1,109 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public 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 (
"encoding/json"
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
// TestDatabaseNotifications_ReadAll_RefreshesUsers guards #2720 for notifications
// already in the database: those were serialized with a partial doer (id +
// username, no display Name), so reading them must reload the embedded users so
// the display name is shown. The fix in the dispatch path only helps new
// notifications; old rows are healed here at read time.
func TestDatabaseNotifications_ReadAll_RefreshesUsers(t *testing.T) {
t.Run("fills in the display name from the database", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// user12 has the display name "Name with spaces" in the fixtures.
insertStoredNotification(t, s, 1, &TaskAssignedNotification{
Doer: &user.User{ID: 12, Username: "user12"},
Assignee: &user.User{ID: 12, Username: "user12"},
Task: &Task{ID: 1},
})
got := readAssignedNotification(t, s, 1)
require.Equal(t, "Name with spaces", got.Doer.GetName())
require.Equal(t, "Name with spaces", got.Assignee.GetName())
})
t.Run("keeps the stored value when the user no longer exists", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
insertStoredNotification(t, s, 1, &TaskAssignedNotification{
Doer: &user.User{ID: 999999, Username: "ghost"},
Task: &Task{ID: 1},
})
got := readAssignedNotification(t, s, 1)
require.Equal(t, "ghost", got.Doer.Username)
})
t.Run("refreshes a disabled user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// user17 is disabled in the fixtures; the reload must still win over the
// stale stored value.
insertStoredNotification(t, s, 1, &TaskAssignedNotification{
Doer: &user.User{ID: 17, Username: "stale"},
Task: &Task{ID: 1},
})
got := readAssignedNotification(t, s, 1)
require.Equal(t, "user17", got.Doer.Username)
})
}
func insertStoredNotification(t *testing.T, s *xorm.Session, notifiableID int64, n notifications.Notification) {
t.Helper()
content, err := json.Marshal(n)
require.NoError(t, err)
_, err = s.Insert(&notifications.DatabaseNotification{
NotifiableID: notifiableID,
Notification: json.RawMessage(content),
Name: n.Name(),
})
require.NoError(t, err)
}
func readAssignedNotification(t *testing.T, s *xorm.Session, notifiableID int64) *TaskAssignedNotification {
t.Helper()
result, _, _, err := (&DatabaseNotifications{}).ReadAll(s, &user.User{ID: notifiableID}, "", 1, 50)
require.NoError(t, err)
for _, dbn := range result.([]*notifications.DatabaseNotification) {
if n, is := dbn.Notification.(*TaskAssignedNotification); is {
return n
}
}
t.Fatal("no task.assigned notification was returned")
return nil
}

View File

@ -1072,7 +1072,7 @@ func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBackl
events.DispatchOnCommit(s, &ProjectCreatedEvent{
Project: project,
Doer: doer,
Doer: doerFromAuth(s, auth),
})
return nil
}
@ -1219,7 +1219,7 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje
events.DispatchOnCommit(s, &ProjectUpdatedEvent{
Project: project,
Doer: auth,
Doer: doerFromAuth(s, auth),
})
l, err := GetProjectSimpleByID(s, project.ID)
@ -1450,7 +1450,7 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &ProjectDeletedEvent{
Project: fullProject,
Doer: a,
Doer: doerFromAuth(s, a),
})
childProjects := []*Project{}

View File

@ -34,6 +34,8 @@ type ProjectDuplicate struct {
ProjectID int64 `json:"-" param:"projectid"`
// The target parent project
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."`
// Whether to copy the project's shares to the duplicate
DuplicateShares bool `json:"duplicate_shares,omitempty" doc:"Whether to copy the project's user, team and link shares to the duplicate. Defaults to false."`
// The copied project
Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."`
@ -62,7 +64,7 @@ func (pd *ProjectDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bo
// Create duplicates a project
// @Summary Duplicate an existing project
// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project.
// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project.
// @tags project
// @Accept json
// @Produce json
@ -117,56 +119,58 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
return
}
// Permissions / Shares
// To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent
users := []*ProjectUser{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&users)
if err != nil {
return
}
for _, u := range users {
u.ID = 0
u.ProjectID = pd.Project.ID
if _, err := s.Insert(u); err != nil {
return err
}
}
log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID)
teams := []*TeamProject{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&teams)
if err != nil {
return
}
for _, t := range teams {
t.ID = 0
t.ProjectID = pd.Project.ID
if _, err := s.Insert(t); err != nil {
return err
}
}
// Generate new link shares if any are available
linkShares := []*LinkSharing{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares)
if err != nil {
return
}
for _, share := range linkShares {
share.ID = 0
share.ProjectID = pd.Project.ID
hash, err := utils.CryptoRandomString(40)
if pd.DuplicateShares {
// Permissions / Shares
// To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent
users := []*ProjectUser{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&users)
if err != nil {
return err
return
}
share.Hash = hash
if _, err := s.Insert(share); err != nil {
return err
for _, u := range users {
u.ID = 0
u.ProjectID = pd.Project.ID
if _, err := s.Insert(u); err != nil {
return err
}
}
}
log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID)
log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID)
teams := []*TeamProject{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&teams)
if err != nil {
return
}
for _, t := range teams {
t.ID = 0
t.ProjectID = pd.Project.ID
if _, err := s.Insert(t); err != nil {
return err
}
}
// Generate new link shares if any are available
linkShares := []*LinkSharing{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares)
if err != nil {
return
}
for _, share := range linkShares {
share.ID = 0
share.ProjectID = pd.Project.ID
hash, err := utils.CryptoRandomString(40)
if err != nil {
return err
}
share.Hash = hash
if _, err := s.Insert(share); err != nil {
return err
}
}
log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID)
}
err = pd.Project.ReadOne(s, doer)
return

View File

@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
func TestProjectDuplicate(t *testing.T) {
@ -38,6 +39,54 @@ func TestProjectDuplicate(t *testing.T) {
// (non-Unsplash) background would fail with an internal server error
testProjectDuplicate(t, 35, 6)
})
t.Run("shares are not copied by default", func(t *testing.T) {
files.InitTestFileFixtures(t)
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 3 has user, team and link shares
u := &user.User{ID: 3}
l := &ProjectDuplicate{ProjectID: 3}
can, err := l.CanCreate(s, u)
require.NoError(t, err)
assert.True(t, can)
require.NoError(t, l.Create(s, u))
assertShareCount(t, s, l.Project.ID, 0, 0, 0)
})
t.Run("shares are copied when duplicate_shares is set", func(t *testing.T) {
files.InitTestFileFixtures(t)
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 3 has 2 user shares, 1 team share and 1 link share
u := &user.User{ID: 3}
l := &ProjectDuplicate{ProjectID: 3, DuplicateShares: true}
can, err := l.CanCreate(s, u)
require.NoError(t, err)
assert.True(t, can)
require.NoError(t, l.Create(s, u))
assertShareCount(t, s, l.Project.ID, 2, 1, 1)
})
}
func assertShareCount(t *testing.T, s *xorm.Session, projectID, users, teams, links int64) {
userCount, err := s.Where("project_id = ?", projectID).Count(&ProjectUser{})
require.NoError(t, err)
assert.Equal(t, users, userCount, "unexpected number of user shares")
teamCount, err := s.Where("project_id = ?", projectID).Count(&TeamProject{})
require.NoError(t, err)
assert.Equal(t, teams, teamCount, "unexpected number of team shares")
linkCount, err := s.Where("project_id = ?", projectID).Count(&LinkSharing{})
require.NoError(t, err)
assert.Equal(t, links, linkCount, "unexpected number of link shares")
}
func testProjectDuplicate(t *testing.T, projectID int64, userID int64) {
@ -51,7 +100,8 @@ func testProjectDuplicate(t *testing.T, projectID int64, userID int64) {
}
l := &ProjectDuplicate{
ProjectID: projectID,
ProjectID: projectID,
DuplicateShares: true,
}
can, err := l.CanCreate(s, u)
require.NoError(t, err)

View File

@ -112,7 +112,7 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{
Project: l,
Team: team,
Doer: a,
Doer: doerFromAuth(s, a),
})
err = updateProjectLastUpdated(s, l)

View File

@ -118,7 +118,7 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{
Project: l,
User: u,
Doer: a,
Doer: doerFromAuth(s, a),
})
err = updateProjectLastUpdated(s, l)

View File

@ -79,8 +79,17 @@ func TestCronInsertsNonZeroPosition(t *testing.T) {
require.NoError(t, err)
require.True(t, exists)
// Force the task to a zero position in this view to simulate the unhealed
// state. A task only ever has one position row per view, so update it if it
// already exists (e.g. created with the filter) instead of inserting a duplicate.
tp := &TaskPosition{TaskID: task.ID, ProjectViewID: view.ID, Position: 0}
_, err = s.Insert(tp)
hasPosition, err := s.Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).Exist(&TaskPosition{})
require.NoError(t, err)
if hasPosition {
_, err = s.Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).Cols("position").Update(tp)
} else {
_, err = s.Insert(tp)
}
require.NoError(t, err)
_, err = calculateNewPositionForTask(s, u, task, view)

View File

@ -49,6 +49,10 @@ type Session struct {
IPAddress string `xorm:"varchar(100)" json:"ip_address" readOnly:"true" doc:"IP address captured from the login request."`
// Whether this is a "remember me" session (controls max refresh lifetime).
IsLongSession bool `xorm:"not null default false" json:"-"`
// Raw OIDC ID token, kept so logout can replay it as id_token_hint. Empty for non-OIDC sessions.
OIDCIDToken string `xorm:"text" json:"-"`
// OIDC provider that created this session, used to find its end-session endpoint at logout.
OIDCProviderKey string `xorm:"varchar(250)" json:"-"`
// When this session was last refreshed.
LastActive time.Time `xorm:"not null" json:"last_active" readOnly:"true" doc:"When this session was last refreshed."`
// When this session was created (login time).
@ -81,9 +85,17 @@ func generateHashedToken() (rawToken, hash string, err error) {
return rawToken, HashSessionToken(rawToken), nil
}
// SessionOIDCData carries the OIDC metadata persisted on a session so an
// RP-Initiated Logout request can be built later. Nil for non-OIDC logins.
type SessionOIDCData struct {
IDToken string
ProviderKey string
}
// CreateSession creates a new session record and generates a refresh token.
// Returns the session with RefreshToken populated (cleartext, shown only once).
func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool) (*Session, error) {
// Pass oidc for OpenID Connect logins to persist the logout data; nil otherwise.
func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool, oidc *SessionOIDCData) (*Session, error) {
rawToken, hash, err := generateHashedToken()
if err != nil {
return nil, err
@ -98,6 +110,10 @@ func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string,
IsLongSession: isLongSession,
LastActive: time.Now(),
}
if oidc != nil {
session.OIDCIDToken = oidc.IDToken
session.OIDCProviderKey = oidc.ProviderKey
}
_, err = s.Insert(session)
if err != nil {

View File

@ -181,7 +181,7 @@ func (la *TaskAssginee) Delete(s *xorm.Session, a web.Auth) (err error) {
return err
}
doer, _ := user.GetFromAuth(a)
doer := doerFromAuth(s, a)
task, err := GetTaskByIDSimple(s, la.TaskID)
if err != nil {
return err
@ -270,7 +270,7 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, project
return err
}
doer, _ := user.GetFromAuth(auth)
doer := doerFromAuth(s, auth)
task, err := GetTaskSimple(s, &Task{ID: t.ID})
if err != nil {
return err

View File

@ -0,0 +1,80 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"context"
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/require"
)
// TestTaskAssignee_DoerHasDisplayName guards against the regression in #2720: the doer attached to
// notification events was built straight from the JWT (id + username only), so notifications and
// emails rendered the auto-generated username instead of the user's display Name. The dispatch sites
// now resolve the full user from the database, so the doer must carry the display Name even when the
// acting auth object only has id + username (as GetUserFromClaims produces).
func TestTaskAssignee_DoerHasDisplayName(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Mimics the partial user GetUserFromClaims builds from a JWT: id + username, no Name.
// user12 has the display name "Name with spaces" in the fixtures and owns project 23.
doer := &user.User{ID: 12, Username: "user12"}
require.Equal(t, "user12", doer.GetName(), "the auth doer must start without a display name")
task := &Task{Title: "assign me", ProjectID: 23}
require.NoError(t, task.Create(s, doer))
events.ClearDispatchedEvents()
ta := &TaskAssginee{TaskID: task.ID, UserID: 12}
require.NoError(t, ta.Create(s, doer))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
dispatched := events.GetDispatchedEvents((&TaskAssigneeCreatedEvent{}).Name())
require.Len(t, dispatched, 1)
ev := dispatched[0].(*TaskAssigneeCreatedEvent)
require.NotNil(t, ev.Doer)
require.Equal(t, "Name with spaces", ev.Doer.GetName(),
"notification doer must carry the display Name, not the username")
}
// TestDoerFromAuth_DisabledUser ensures resolving the event doer keeps working when acting on behalf
// of a disabled account (e.g. user deletion deletes that user's tasks). The full user is still
// returned with its display name, the disabled status error is swallowed.
func TestDoerFromAuth_DisabledUser(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// user17 is disabled in the fixtures.
_, err := user.GetUserByID(s, 17)
require.Error(t, err, "fixture user17 is expected to be disabled")
require.True(t, user.IsErrAccountDisabled(err))
doer := doerFromAuth(s, &user.User{ID: 17, Username: "user17"})
require.NotNil(t, doer)
require.Equal(t, int64(17), doer.ID)
}

View File

@ -17,6 +17,7 @@
package models
import (
"context"
"fmt"
"testing"
@ -45,7 +46,7 @@ func TestTaskComment_Create(t *testing.T) {
assert.Equal(t, int64(1), tc.Author.ID)
err = s.Commit()
require.NoError(t, err)
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TaskCommentCreatedEvent{})
db.AssertExists(t, "task_comments", map[string]interface{}{

View File

@ -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" readOnly:"true" doc:"The numeric id of the task this position belongs to. Taken from the URL; ignored in the request body."`
TaskID int64 `xorm:"bigint not null index unique(task_view)" 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" 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."`
ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" 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).
@ -341,6 +341,57 @@ func calculateNewPositionForTask(s *xorm.Session, a web.Auth, t *Task, view *Pro
}, nil
}
type taskPositionKey struct {
taskID int64
viewID int64
}
// filterNewTaskPositions returns the positions whose (task_id, project_view_id)
// row does not exist yet, also deduplicating within the slice. Position creation
// during task creation can trigger a full recalculation (calculateNewPositionForTask
// or moveTaskToDoneBuckets) that already persists rows for the new task, so inserting
// the queued positions unconditionally would violate the unique index on
// (task_id, project_view_id).
func filterNewTaskPositions(s *xorm.Session, positions []*TaskPosition) ([]*TaskPosition, error) {
if len(positions) == 0 {
return positions, nil
}
taskIDs := make([]int64, 0, len(positions))
seenTask := make(map[int64]bool, len(positions))
for _, p := range positions {
if seenTask[p.TaskID] {
continue
}
seenTask[p.TaskID] = true
taskIDs = append(taskIDs, p.TaskID)
}
// Fetch all existing rows for the involved tasks in one query so this stays
// cheap when createTask runs in a loop (bulk import, project duplication).
existing := []*TaskPosition{}
err := s.In("task_id", taskIDs).Find(&existing)
if err != nil {
return nil, err
}
seen := make(map[taskPositionKey]bool, len(positions)+len(existing))
for _, e := range existing {
seen[taskPositionKey{taskID: e.TaskID, viewID: e.ProjectViewID}] = true
}
filtered := make([]*TaskPosition, 0, len(positions))
for _, p := range positions {
key := taskPositionKey{taskID: p.TaskID, viewID: p.ProjectViewID}
if seen[key] {
continue
}
seen[key] = true
filtered = append(filtered, p)
}
return filtered, nil
}
// DeleteOrphanedTaskPositions removes task position records that reference
// tasks or project views that no longer exist.
// If dryRun is true, it counts the orphaned records without deleting them.

View File

@ -830,9 +830,15 @@ func checkBucketLimit(s *xorm.Session, a web.Auth, t *Task, bucket *Bucket) (tas
}
if view.ProjectID < 0 || (view.Filter != nil && view.Filter.Filter != "") {
// For saved filters or views with a filter, the count must be scoped to
// this bucket *and* the filter: raw task_buckets rows can include tasks
// that no longer match the filter (#355), while the unscoped filter total
// counts tasks across all buckets, not just this one (#2672). ReadAll
// combines the bucket_id condition with the saved-filter / view filter.
tc := &TaskCollection{
ProjectID: view.ProjectID,
ProjectViewID: bucket.ProjectViewID,
Filter: "bucket_id = " + strconv.FormatInt(bucket.ID, 10),
}
_, _, taskCount, err = tc.ReadAll(s, a, "", 1, 1)
@ -978,6 +984,13 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, setB
return err
}
if len(positions) > 0 {
positions, err = filterNewTaskPositions(s, positions)
if err != nil {
return err
}
}
if len(positions) > 0 {
_, err = s.Insert(&positions)
if err != nil {
@ -1449,10 +1462,9 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
}
t.Updated = nt.Updated
doer, _ := user.GetFromAuth(a)
events.DispatchOnCommit(s, &TaskUpdatedEvent{
Task: t,
Doer: doer,
Doer: doerFromAuth(s, a),
})
return updateProjectLastUpdated(s, &Project{ID: t.ProjectID})
@ -1735,6 +1747,20 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) {
newTask.Done = false
}
var (
checklistTiptapCheckedRegex = regexp.MustCompile(`(data-checked=")true(")`)
checklistInputCheckedRegex = regexp.MustCompile(`(<input[^>]*type=["']checkbox["'][^>]*?)\s+checked(?:=["'][^"']*["'])?`)
)
// resetDescriptionChecklist unchecks every checklist item in a TipTap HTML description
// (descriptions are always stored as HTML, never markdown) without touching other content,
// so a recurring task's next occurrence does not inherit checked items.
func resetDescriptionChecklist(description string) string {
description = checklistTiptapCheckedRegex.ReplaceAllString(description, "${1}false${2}")
description = checklistInputCheckedRegex.ReplaceAllString(description, "$1")
return description
}
// This helper function updates the reminders, doneAt, start, end and due dates of the *old* task
// and saves the new values in the newTask object.
// We make a few assumptions here:
@ -1754,6 +1780,11 @@ func updateDone(oldTask *Task, newTask *Task) (updateDoneAt bool) {
setTaskDatesDefault(oldTask, newTask)
}
// A recurring task reopens for its next occurrence, so its checklist starts fresh.
if oldTask.isRepeating() && !newTask.Done {
newTask.Description = resetDescriptionChecklist(newTask.Description)
}
newTask.DoneAt = time.Now()
}
@ -1948,10 +1979,9 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
return err
}
doer, _ := user.GetFromAuth(a)
events.DispatchOnCommit(s, &TaskDeletedEvent{
Task: fullTask,
Doer: doer,
Doer: doerFromAuth(s, a),
})
err = updateProjectLastUpdated(s, &Project{ID: t.ProjectID})
@ -2019,10 +2049,9 @@ func triggerTaskUpdatedEventForTaskID(s *xorm.Session, auth web.Auth, taskID int
return err
}
doer, _ := user.GetFromAuth(auth)
events.DispatchOnCommit(s, &TaskUpdatedEvent{
Task: &t,
Doer: doer,
Doer: doerFromAuth(s, auth),
})
return nil
}

View File

@ -17,6 +17,7 @@
package models
import (
"context"
"testing"
"time"
@ -70,7 +71,7 @@ func TestTask_Create(t *testing.T) {
"bucket_id": 1,
}, false)
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TaskCreatedEvent{})
})
t.Run("with reminders", func(t *testing.T) {
@ -280,7 +281,7 @@ func TestTask_Update(t *testing.T) {
err = s.Commit()
require.NoError(t, err)
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
// Verify exactly ONE task.updated event was dispatched
count := events.CountDispatchedEvents("task.updated")
assert.Equal(t, 1, count, "Expected exactly 1 task.updated event, got %d", count)
@ -985,6 +986,45 @@ func TestUpdateDone(t *testing.T) {
assert.False(t, newTask.Done)
})
})
t.Run("reset checklist on recurrence", func(t *testing.T) {
const checked = `before<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Item</p></li></ul>after`
const unchecked = `before<ul data-type="taskList"><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Item</p></li></ul>after`
oldTask := &Task{
Done: false,
RepeatAfter: 8600,
DueDate: time.Unix(1550000000, 0),
}
newTask := &Task{
Done: true,
Description: checked,
}
updateDone(oldTask, newTask)
assert.False(t, newTask.Done)
assert.True(t, newTask.DueDate.After(oldTask.DueDate))
assert.Equal(t, unchecked, newTask.Description)
})
t.Run("non-recurring description untouched", func(t *testing.T) {
const checked = `before<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Item</p></li></ul>after`
oldTask := &Task{
Done: false,
RepeatAfter: 0,
RepeatMode: TaskRepeatModeDefault,
DueDate: time.Unix(1550000000, 0),
}
newTask := &Task{
Done: true,
Description: checked,
}
updateDone(oldTask, newTask)
assert.True(t, newTask.Done)
assert.Equal(t, checked, newTask.Description)
})
})
}

View File

@ -69,11 +69,10 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
doer, _ := user2.GetFromAuth(a)
events.DispatchOnCommit(s, &TeamMemberAddedEvent{
Team: team,
Member: member,
Doer: doer,
Doer: doerFromAuth(s, a),
})
return nil
}

View File

@ -222,7 +222,7 @@ func (t *Team) CreateNewTeam(s *xorm.Session, a web.Auth, firstUserShouldBeAdmin
events.DispatchOnCommit(s, &TeamCreatedEvent{
Team: t,
Doer: a,
Doer: doer,
})
return nil
}
@ -362,7 +362,7 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &TeamDeletedEvent{
Team: t,
Doer: a,
Doer: doerFromAuth(s, a),
})
return nil
}

View File

@ -17,6 +17,7 @@
package models
import (
"context"
"encoding/json"
"testing"
"time"
@ -596,7 +597,7 @@ func TestTimeEntry_Events(t *testing.T) {
te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
require.NoError(t, te.Create(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryCreatedEvent{})
})
@ -612,7 +613,7 @@ func TestTimeEntry_Events(t *testing.T) {
require.True(t, can)
require.NoError(t, te.Update(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
})
@ -624,7 +625,7 @@ func TestTimeEntry_Events(t *testing.T) {
require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryDeletedEvent{})
})
@ -637,7 +638,7 @@ func TestTimeEntry_Events(t *testing.T) {
// entry 4 is user1's running timer; a new running timer auto-stops it
require.NoError(t, (&TimeEntry{TaskID: 1}).Create(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryCreatedEvent{})
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
})
@ -651,7 +652,7 @@ func TestTimeEntry_Events(t *testing.T) {
te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
require.NoError(t, te.Create(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
assert.Equal(t, 1, events.CountDispatchedEvents((&TimeEntryCreatedEvent{}).Name()))
assert.Equal(t, 0, events.CountDispatchedEvents((&TimeEntryUpdatedEvent{}).Name()), "a completed manual entry must not auto-stop")
})
@ -665,7 +666,7 @@ func TestTimeEntry_Events(t *testing.T) {
_, err := StopRunningTimer(s, u)
require.NoError(t, err)
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
})
}

View File

@ -17,6 +17,8 @@
package models
import (
"context"
"code.vikunja.io/api/pkg/modules/avatar"
"code.vikunja.io/api/pkg/user"
@ -66,12 +68,12 @@ func NewUserGeneralSettings(u *user.User) *UserGeneralSettings {
// ChangeUserPassword verifies the old password, sets the new one, and
// invalidates all of the user's sessions. Lives here (not in pkg/user) because
// it needs DeleteAllUserSessions, which pkg/user cannot import.
func ChangeUserPassword(s *xorm.Session, u *user.User, oldPassword, newPassword string) error {
func ChangeUserPassword(ctx context.Context, s *xorm.Session, u *user.User, oldPassword, newPassword string) error {
if oldPassword == "" {
return user.ErrEmptyOldPassword{}
}
if _, err := user.CheckUserCredentials(s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil {
if _, err := user.CheckUserCredentials(ctx, s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil {
return err
}

View File

@ -22,6 +22,33 @@ import (
"xorm.io/xorm"
)
// doerFromAuth resolves the authenticated principal into a full user for event payloads. The JWT
// only carries id + username, so without a re-fetch notifications and emails render the
// auto-generated username instead of the display name (#2720). Status errors (disabled/locked) are
// swallowed because their user is still populated and some flows act on behalf of such accounts
// (e.g. user deletion deletes that user's tasks); the partial principal is used as a last resort.
func doerFromAuth(s *xorm.Session, a web.Auth) *user.User {
if a == nil {
return nil
}
doer, err := GetUserOrLinkShareUser(s, a)
if err != nil && !user.IsErrUserStatusError(err) {
doer = nil
}
if doer != nil && doer.ID != 0 {
return doer
}
if u, is := a.(*user.User); is {
return u
}
if share, is := a.(*LinkSharing); is {
return share.toUser()
}
return &user.User{ID: a.GetID()}
}
// GetUserOrLinkShareUser returns either a user or a link share disguised as a user.
func GetUserOrLinkShareUser(s *xorm.Session, a web.Auth) (uu *user.User, err error) {
if u, is := a.(*user.User); is {

View File

@ -26,6 +26,8 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/user"
@ -98,42 +100,77 @@ func ClearRefreshTokenCookie(c *echo.Context) {
SetRefreshTokenCookie(c, "", -1)
}
// NewUserAuthTokenResponse creates a new user auth token response from a user object.
func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error {
// IssuedUserToken bundles a freshly minted access token with the matching
// refresh token and the cookie max-age both v1 and v2 use to set the
// HttpOnly refresh cookie.
type IssuedUserToken struct {
AccessToken string
RefreshToken string
CookieMaxAge int
}
// IssueUserToken creates a session for the user and mints a JWT access token plus
// a refresh token for it. It is the transport-agnostic core both v1 (which writes
// the echo response) and v2 (Huma) call; callers set the refresh cookie and the
// Cache-Control header themselves via WriteUserAuthCookies. Pass oidc for
// OpenID Connect logins to store the logout data; nil otherwise.
func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool, oidc *models.SessionOIDCData) (*IssuedUserToken, error) {
s := db.NewSession()
defer s.Close()
deviceInfo := c.Request().UserAgent()
ipAddress := c.RealIP()
session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long)
session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long, oidc)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
t, err := NewUserJWTAuthtoken(u, session.ID)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
return nil, err
}
if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil {
log.Errorf("Could not dispatch login succeeded event: %s", err)
}
// Set the refresh token as an HttpOnly cookie. The cookie is path-scoped
// to the refresh endpoint, so the browser only sends it there. JavaScript
// never sees the refresh token — this protects it from XSS.
cookieMaxAge := int(config.ServiceJWTTTL.GetInt64())
if long {
cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64())
}
SetRefreshTokenCookie(c, session.RefreshToken, cookieMaxAge)
return &IssuedUserToken{
AccessToken: t,
RefreshToken: session.RefreshToken,
CookieMaxAge: cookieMaxAge,
}, nil
}
// WriteUserAuthCookies sets the HttpOnly refresh-token cookie and the
// Cache-Control: no-store header on a response. The cookie is path-scoped to the
// refresh endpoint, so the browser only sends it there; JavaScript never sees the
// refresh token, which protects it from XSS. Shared by the v1 echo handlers and
// the v2 Huma handlers (which reach the echo context via humaecho5.Unwrap).
func WriteUserAuthCookies(c *echo.Context, token *IssuedUserToken) {
SetRefreshTokenCookie(c, token.RefreshToken, token.CookieMaxAge)
c.Response().Header().Set("Cache-Control", "no-store")
return c.JSON(http.StatusOK, Token{Token: t})
}
// NewUserAuthTokenResponse creates a new user auth token response from a user object.
// Pass oidc for OpenID Connect logins to store the logout data; nil otherwise.
func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool, oidc *models.SessionOIDCData) error {
token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long, oidc)
if err != nil {
return err
}
WriteUserAuthCookies(c, token)
return c.JSON(http.StatusOK, Token{Token: token.AccessToken})
}
// NewUserJWTAuthtoken generates and signs a new short-lived jwt token for a user.
@ -386,6 +423,26 @@ func RefreshSession(rawRefreshToken string) (*RefreshResult, error) {
}, nil
}
// SessionIDFromContext reads the session id (the `sid` claim) off the user JWT
// in the echo context. It returns "" when there is no user JWT or no sid claim
// (API tokens and link shares carry no session), which callers treat as a no-op.
func SessionIDFromContext(c *echo.Context) string {
raw := c.Get("user")
if raw == nil {
return ""
}
jwtinf, ok := raw.(*jwt.Token)
if !ok {
return ""
}
claims, ok := jwtinf.Claims.(jwt.MapClaims)
if !ok {
return ""
}
sid, _ := claims["sid"].(string)
return sid
}
// GetAuthFromContext retrieves the authenticated web.Auth from a plain
// context.Context, bridging Huma handlers to Vikunja's echo JWT flow. The
// humaecho5 adapter stashes the *echo.Context under EchoContextKey first.

View File

@ -26,8 +26,8 @@ import (
"github.com/labstack/echo/v5"
)
// authorizeRequest represents the JSON body for the authorize endpoint.
type authorizeRequest struct {
// AuthorizeRequest represents the body for the authorize endpoint.
type AuthorizeRequest struct {
ResponseType string `json:"response_type"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
@ -47,54 +47,66 @@ type AuthorizeResponse struct {
// It validates the OAuth parameters, creates an authorization code, and
// returns it as JSON. Authentication is handled by the token middleware.
func HandleAuthorize(c *echo.Context) error {
var req authorizeRequest
var req AuthorizeRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
}
// Validate response_type
if req.ResponseType != "code" {
return echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'")
}
// Validate redirect_uri
if !ValidateRedirectURI(req.RedirectURI) {
return &models.ErrOAuthInvalidRedirectURI{}
}
// Validate PKCE (required)
if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" {
return &models.ErrOAuthMissingPKCE{}
}
// Get the authenticated user from the middleware
u, err := user.GetCurrentUser(c)
if err != nil {
return err
}
resp, err := Authorize(&req, u.ID)
if err != nil {
return err
}
return c.JSON(http.StatusOK, resp)
}
// Authorize validates the OAuth authorization parameters for the given
// authenticated user and creates a single-use authorization code, independent
// of the HTTP layer. Callers own request binding and resolving the user.
func Authorize(req *AuthorizeRequest, userID int64) (*AuthorizeResponse, error) {
// Validate response_type
if req.ResponseType != "code" {
return nil, echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'")
}
// Validate redirect_uri
if !ValidateRedirectURI(req.RedirectURI) {
return nil, &models.ErrOAuthInvalidRedirectURI{}
}
// Validate PKCE (required)
if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" {
return nil, &models.ErrOAuthMissingPKCE{}
}
s := db.NewSession()
defer s.Close()
fullUser, err := user.GetUserByID(s, u.ID)
fullUser, err := user.GetUserByID(s, userID)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
code, err := models.CreateOAuthCode(s, fullUser.ID, req.ClientID, req.RedirectURI, req.CodeChallenge, req.CodeChallengeMethod)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
if err := s.Commit(); err != nil {
return err
return nil, err
}
return c.JSON(http.StatusOK, AuthorizeResponse{
return &AuthorizeResponse{
Code: code,
RedirectURI: req.RedirectURI,
State: req.State,
})
}, nil
}

View File

@ -17,10 +17,14 @@
package oauth2server
import (
"context"
"net/http"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/user"
@ -36,35 +40,51 @@ type TokenResponse struct {
RefreshToken string `json:"refresh_token"`
}
// tokenRequest holds the JSON body of a POST /oauth/token request.
type tokenRequest struct {
GrantType string `json:"grant_type"`
Code string `json:"code"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
CodeVerifier string `json:"code_verifier"`
RefreshToken string `json:"refresh_token"`
// TokenRequest holds the parameters of a POST /oauth/token request. v1 binds it
// from JSON; v2 accepts spec-compliant application/x-www-form-urlencoded as well
// (form tags mirror the json names).
type TokenRequest struct {
GrantType string `json:"grant_type" form:"grant_type"`
Code string `json:"code" form:"code"`
ClientID string `json:"client_id" form:"client_id"`
RedirectURI string `json:"redirect_uri" form:"redirect_uri"`
CodeVerifier string `json:"code_verifier" form:"code_verifier"`
RefreshToken string `json:"refresh_token" form:"refresh_token"`
}
// HandleToken handles POST /oauth/token.
// Supports grant_type=authorization_code and grant_type=refresh_token.
func HandleToken(c *echo.Context) error {
var req tokenRequest
var req TokenRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
}
resp, err := ExchangeToken(c.Request().Context(), &req, c.Request().UserAgent(), c.RealIP())
if err != nil {
return err
}
c.Response().Header().Set("Cache-Control", "no-store")
return c.JSON(http.StatusOK, resp)
}
// ExchangeToken runs the grant-type dispatch and token issuance for the OAuth
// token endpoint, independent of the HTTP layer. Callers own request binding and
// the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded
// on the session created for the authorization_code grant.
func ExchangeToken(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) {
switch req.GrantType {
case "authorization_code":
return handleAuthorizationCodeGrant(c, &req)
return exchangeAuthorizationCode(ctx, req, deviceInfo, ipAddress)
case "refresh_token":
return handleRefreshTokenGrant(c, &req)
return exchangeRefreshToken(req)
default:
return &models.ErrOAuthInvalidGrantType{}
return nil, &models.ErrOAuthInvalidGrantType{}
}
}
func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error {
func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) {
s := db.NewSession()
defer s.Close()
@ -72,73 +92,75 @@ func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error {
oauthCode, err := models.GetAndDeleteOAuthCode(s, req.Code)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
// Validate client_id matches
if oauthCode.ClientID != req.ClientID {
_ = s.Rollback()
return &models.ErrOAuthClientNotFound{}
return nil, &models.ErrOAuthClientNotFound{}
}
// Validate redirect_uri matches
if oauthCode.RedirectURI != req.RedirectURI {
_ = s.Rollback()
return &models.ErrOAuthInvalidRedirectURI{}
return nil, &models.ErrOAuthInvalidRedirectURI{}
}
// Verify PKCE
if !VerifyPKCE(req.CodeVerifier, oauthCode.CodeChallenge, oauthCode.CodeChallengeMethod) {
_ = s.Rollback()
return &models.ErrOAuthPKCEVerifyFailed{}
return nil, &models.ErrOAuthPKCEVerifyFailed{}
}
// Create a session (reuses existing session infrastructure)
deviceInfo := c.Request().UserAgent()
ipAddress := c.RealIP()
session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false)
session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false, nil)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
u, err := user.GetUserByID(s, oauthCode.UserID)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
// Generate JWT
accessToken, err := auth.NewUserJWTAuthtoken(u, session.ID)
if err != nil {
_ = s.Rollback()
return err
return nil, err
}
if err := s.Commit(); err != nil {
return err
return nil, err
}
c.Response().Header().Set("Cache-Control", "no-store")
return c.JSON(http.StatusOK, TokenResponse{
// The code exchange mints a fresh session, so it is a login for the
// audit trail, same as NewUserAuthTokenResponse.
if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil {
log.Errorf("Could not dispatch login succeeded event: %s", err)
}
return &TokenResponse{
AccessToken: accessToken,
TokenType: "bearer",
ExpiresIn: config.ServiceJWTTTLShort.GetInt64(),
RefreshToken: session.RefreshToken,
})
}, nil
}
func handleRefreshTokenGrant(c *echo.Context, req *tokenRequest) error {
func exchangeRefreshToken(req *TokenRequest) (*TokenResponse, error) {
result, err := auth.RefreshSession(req.RefreshToken)
if err != nil {
return err
return nil, err
}
c.Response().Header().Set("Cache-Control", "no-store")
return c.JSON(http.StatusOK, TokenResponse{
return &TokenResponse{
AccessToken: result.AccessToken,
TokenType: "bearer",
ExpiresIn: result.ExpiresIn,
RefreshToken: result.NewRefreshToken,
})
}, nil
}

View File

@ -0,0 +1,110 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package openid
import (
"net/url"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
)
// EndSessionEndpoint returns the provider's RP-Initiated Logout endpoint
// (discovery's end_session_endpoint, cached at init), falling back to the static
// logouturl. Never triggers discovery so logout stays responsive when the OP is
// unreachable.
func (p *Provider) EndSessionEndpoint() string {
if p.EndSessionURL != "" {
return p.EndSessionURL
}
return p.LogoutURL
}
// discoveredEndSessionEndpoint reads end_session_endpoint from the discovery
// document already cached on the *oidc.Provider, so Claims unmarshals in memory
// without a request.
func (p *Provider) discoveredEndSessionEndpoint() string {
if p.openIDProvider == nil {
return ""
}
var meta struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err := p.openIDProvider.Claims(&meta); err != nil {
log.Debugf("Could not read end_session_endpoint for provider %s: %v", p.Key, err)
return ""
}
return meta.EndSessionEndpoint
}
// BuildEndSessionURL builds an OpenID Connect RP-Initiated Logout 1.0 request URL
// (id_token_hint + post_logout_redirect_uri + client_id; see RP-Initiated Logout
// 1.0 §2). post_logout_redirect_uri defaults to service.publicurl, and the OP
// only honors it when id_token_hint is present. Returns "" when neither an
// end_session_endpoint nor a static logouturl is configured.
func BuildEndSessionURL(providerKey string, oidc *models.SessionOIDCData) (string, error) {
// GetProvider would trigger OIDC discovery (a live HTTP GET that blocks when
// the OP is down); the cached static fields are all logout needs.
provider, err := getCachedProvider(providerKey)
if err != nil {
return "", err
}
if provider == nil {
return "", nil
}
idToken := ""
if oidc != nil {
idToken = oidc.IDToken
}
return buildEndSessionURL(
provider.EndSessionEndpoint(),
provider.ClientID,
idToken,
config.ServicePublicURL.GetString(),
)
}
// buildEndSessionURL appends the logout query params onto endpoint, omitting
// empty ones, and returns "" for an empty endpoint.
func buildEndSessionURL(endpoint, clientID, idToken, postLogoutRedirectURI string) (string, error) {
if endpoint == "" {
return "", nil
}
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
q := u.Query()
if clientID != "" {
q.Set("client_id", clientID)
}
if idToken != "" {
q.Set("id_token_hint", idToken)
}
if postLogoutRedirectURI != "" {
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
}
u.RawQuery = q.Encode()
return u.String(), nil
}

View File

@ -0,0 +1,234 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package openid
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newMockOIDCServerWithEndSession publishes a discovery document with an
// end_session_endpoint.
func newMockOIDCServerWithEndSession() *httptest.Server {
var server *httptest.Server
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
discovery := map[string]interface{}{
"issuer": server.URL,
"authorization_endpoint": server.URL + "/auth",
"token_endpoint": server.URL + "/token",
"jwks_uri": server.URL + "/jwks",
"end_session_endpoint": server.URL + "/logout",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(discovery)
})
server = httptest.NewServer(mux)
return server
}
func TestBuildEndSessionURLAssembly(t *testing.T) {
t.Run("all params", func(t *testing.T) {
got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "the-id-token", "https://vikunja.example.com/")
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "op.example.com", u.Host)
assert.Equal(t, "/logout", u.Path)
assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "my-client", q.Get("client_id"))
})
t.Run("preserves existing endpoint query params", func(t *testing.T) {
got, err := buildEndSessionURL("https://op.example.com/logout?foo=bar", "my-client", "the-id-token", "https://vikunja.example.com/")
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, "bar", q.Get("foo"))
assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
})
t.Run("omits id_token_hint when no token", func(t *testing.T) {
got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "", "https://vikunja.example.com/")
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.False(t, q.Has("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "my-client", q.Get("client_id"))
})
t.Run("empty endpoint returns empty", func(t *testing.T) {
got, err := buildEndSessionURL("", "my-client", "the-id-token", "https://vikunja.example.com/")
require.NoError(t, err)
assert.Empty(t, got)
})
}
func TestBuildEndSessionURLFromDiscovery(t *testing.T) {
defer CleanupSavedOpenIDProviders()
server := newMockOIDCServerWithEndSession()
defer server.Close()
config.AuthOpenIDEnabled.Set(true)
config.ServicePublicURL.Set("https://vikunja.example.com/")
config.AuthOpenIDProviders.Set(map[string]interface{}{
"provider1": map[string]interface{}{
"name": "Provider One",
"authurl": server.URL,
"clientid": "client1",
"clientsecret": "secret1",
},
})
_ = keyvalue.Del("openid_providers")
_ = keyvalue.Del("openid_provider_provider1")
got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
IDToken: "raw-id-token",
ProviderKey: "provider1",
})
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, server.URL+"/logout", u.Scheme+"://"+u.Host+u.Path)
assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "client1", q.Get("client_id"))
}
func TestBuildEndSessionURLFromCachedProviderWithoutLiveObject(t *testing.T) {
defer CleanupSavedOpenIDProviders()
config.AuthOpenIDEnabled.Set(true)
config.ServicePublicURL.Set("https://vikunja.example.com/")
// Seed only the cached static fields (no live openIDProvider), mimicking a
// provider restored from keyvalue whose OP is unreachable.
_ = keyvalue.Del("openid_providers")
require.NoError(t, keyvalue.Put("openid_provider_provider1", &Provider{
Key: "provider1",
ClientID: "client1",
EndSessionURL: "https://op.example.com/end-session",
}))
got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
IDToken: "raw-id-token",
ProviderKey: "provider1",
})
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, "https://op.example.com/end-session", u.Scheme+"://"+u.Host+u.Path)
assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "client1", q.Get("client_id"))
}
func TestEndSessionEndpointUsesCachedURLWithoutDiscovery(t *testing.T) {
// A nil openIDProvider models a provider restored from cache (or an
// unreachable OP): EndSessionEndpoint must answer from the cached URL.
p := &Provider{
Key: "provider1",
LogoutURL: "https://op.example.com/static-logout",
EndSessionURL: "https://op.example.com/end-session",
}
assert.Equal(t, "https://op.example.com/end-session", p.EndSessionEndpoint())
}
func TestEndSessionEndpointFallsBackToLogoutURLWhenNotCached(t *testing.T) {
p := &Provider{
Key: "provider1",
LogoutURL: "https://op.example.com/static-logout",
}
assert.Equal(t, "https://op.example.com/static-logout", p.EndSessionEndpoint())
}
func TestEndSessionEndpointCachedFromDiscoveryOnInit(t *testing.T) {
defer CleanupSavedOpenIDProviders()
server := newMockOIDCServerWithEndSession()
defer server.Close()
config.AuthOpenIDEnabled.Set(true)
config.AuthOpenIDProviders.Set(map[string]interface{}{
"provider1": map[string]interface{}{
"name": "Provider One",
"authurl": server.URL,
"clientid": "client1",
"clientsecret": "secret1",
},
})
_ = keyvalue.Del("openid_providers")
_ = keyvalue.Del("openid_provider_provider1")
provider, err := GetProvider("provider1")
require.NoError(t, err)
require.NotNil(t, provider)
assert.Equal(t, server.URL+"/logout", provider.EndSessionURL)
assert.Equal(t, server.URL+"/logout", provider.EndSessionEndpoint())
}
func TestEndSessionEndpointFallsBackToStaticLogoutURL(t *testing.T) {
defer CleanupSavedOpenIDProviders()
// newMockOIDCServer publishes no end_session_endpoint, forcing the logouturl fallback.
server := newMockOIDCServer()
defer server.Close()
config.AuthOpenIDEnabled.Set(true)
config.AuthOpenIDProviders.Set(map[string]interface{}{
"provider1": map[string]interface{}{
"name": "Provider One",
"authurl": server.URL,
"clientid": "client1",
"clientsecret": "secret1",
"logouturl": "https://op.example.com/static-logout",
},
})
_ = keyvalue.Del("openid_providers")
_ = keyvalue.Del("openid_provider_provider1")
provider, err := GetProvider("provider1")
require.NoError(t, err)
require.NotNil(t, provider)
assert.Equal(t, "https://op.example.com/static-logout", provider.EndSessionEndpoint())
}

View File

@ -27,6 +27,7 @@ import (
"strings"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -68,8 +69,12 @@ type Provider struct {
ForceUserInfo bool `json:"force_user_info"`
RequireAvailability bool `json:"-"`
ClientSecret string `json:"-"`
openIDProvider *oidc.Provider
Oauth2Config *oauth2.Config `json:"-"`
// RP-Initiated Logout endpoint, cached at init so logout never fetches.
// Exported so it survives the gob keyvalue round-trip (gob skips unexported
// fields like openIDProvider); json:"-" keeps it out of /info.
EndSessionURL string `json:"-"`
openIDProvider *oidc.Provider
Oauth2Config *oauth2.Config `json:"-"`
}
type claims struct {
@ -167,8 +172,12 @@ func enforceTOTPIfRequired(s *xorm.Session, u *user.User, totpPasscode string) e
// @Failure 500 {object} models.Message "Internal error"
// @Router /auth/openid/{provider}/callback [post]
func HandleCallback(c *echo.Context) error {
cb := &Callback{}
if err := c.Bind(cb); err != nil {
return &models.ErrOpenIDBadRequest{Message: "Bad data"}
}
provider, cb, oauthToken, idToken, err := getProviderAndOidcTokens(c)
u, oidcData, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider"))
if err != nil {
var detailedErr *models.ErrOpenIDBadRequestWithDetails
if errors.As(err, &detailedErr) {
@ -180,29 +189,58 @@ func HandleCallback(c *echo.Context) error {
return err
}
cl, err := getClaims(provider, oauthToken, idToken)
// Create token
return auth.NewUserAuthTokenResponse(u, c, false, oidcData)
}
// AuthenticateCallback resolves an OpenID Connect callback to an authenticated
// user: it exchanges the auth code, verifies the ID token, creates or updates the
// matching local user, enforces the account-status and TOTP gates, and syncs the
// user's external teams. It is the transport-agnostic core shared by the v1 echo
// handler and the v2 Huma handler; the caller issues the auth token. The
// ErrOpenIDBadRequestWithDetails error keeps its provider detail so v1 can render
// its bespoke body and v2 can map it to RFC 9457.
func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, *models.SessionOIDCData, error) {
// ctx is threaded through only to dispatch the login event; the OIDC token
// exchange, claim verification and user/avatar sync run on their own
// background contexts, exactly as the v1 callback always did.
provider, oauthToken, idToken, rawIDToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck
if err != nil {
return err
return nil, nil, err
}
// Stored so logout can replay it as id_token_hint in an RP-Initiated Logout.
oidcData := &models.SessionOIDCData{
IDToken: rawIDToken,
ProviderKey: providerKey,
}
cl, err := getClaims(provider, oauthToken, idToken) //nolint:contextcheck
if err != nil {
return nil, nil, err
}
s := db.NewSession()
defer s.Close()
// Discards events queued during a rolled-back transaction (e.g. user
// creation); a no-op once DispatchPending has run.
defer events.CleanupPending(s)
// Check if we have seen this user before
u, err := getOrCreateUser(s, cl, provider, idToken)
u, err := getOrCreateUser(s, cl, provider, idToken) //nolint:contextcheck
if err != nil {
_ = s.Rollback()
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
return err
return nil, nil, err
}
if u.Status == user.StatusDisabled {
_ = s.Rollback()
return &user.ErrAccountDisabled{UserID: u.ID}
return nil, nil, &user.ErrAccountDisabled{UserID: u.ID}
}
if u.Status == user.StatusAccountLocked {
_ = s.Rollback()
return &user.ErrAccountLocked{UserID: u.ID}
return nil, nil, &user.ErrAccountLocked{UserID: u.ID}
}
// Must run before team sync so a failed 2FA attempt cannot mutate team
@ -212,29 +250,33 @@ func HandleCallback(c *echo.Context) error {
if err := enforceTOTPIfRequired(s, u, cb.TOTPPasscode); err != nil {
if commitErr := s.Commit(); commitErr != nil {
log.Errorf("Error committing session after failed OIDC TOTP attempt for user %d: %v", u.ID, commitErr)
} else {
// The user creation above was committed, so its events are real.
events.DispatchPending(ctx, s)
}
if user.IsErrInvalidTOTPPasscode(err) {
user.HandleFailedTOTPAuth(u)
}
return err
return nil, nil, err
}
teamData := getTeamDataFromToken(cl.VikunjaGroups, provider)
err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name)
if err != nil {
return err
return nil, nil, err
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
log.Errorf("Error creating new team for provider %s: %v", provider.Name, err)
return err
return nil, nil, err
}
// Create token
return auth.NewUserAuthTokenResponse(u, c, false)
events.DispatchPending(ctx, s)
return u, oidcData, nil
}
func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) {
@ -335,6 +377,46 @@ func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string)
return nil
}
// fallbackSearchUsers builds the ordered list of local-user lookups used to link an OIDC
// login to an existing account when the provider has email and/or username fallback enabled.
// GetUserWithEmail ANDs all non-zero fields, so the email (when set) is combined with each
// username candidate.
func fallbackSearchUsers(cl *claims, provider *Provider, idToken *oidc.IDToken) []*user.User {
fallbackEmail := ""
if provider.EmailFallback {
// Used alone, allow for someone to connect from various provider to the same account.
// Discouraged for untrusted providers where someone can set email without verification.
// Note: mapping on email prevents auto-updating the user email.
fallbackEmail = cl.Email
}
// Try the subject first (keeps working for IdPs where sub == username), then the
// preferred_username. The latter lets providers with an opaque sub (e.g. a random
// UUID, like PocketID) still link to an existing local account.
var searches []*user.User
if provider.UsernameFallback {
// Skip empty username candidates: GetUserWithEmail ANDs only non-zero fields, so a
// {Issuer, Username:"", Email:""} would degenerate to an issuer-only lookup and link
// an arbitrary local user. idToken.Subject is non-empty per OIDC, but guard anyway.
if idToken.Subject != "" {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: idToken.Subject, Email: fallbackEmail})
}
preferred := strings.ReplaceAll(cl.PreferredUsername, " ", "-")
if preferred != "" && preferred != idToken.Subject {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: preferred, Email: fallbackEmail})
}
}
// EmailFallback without UsernameFallback: a single email-only lookup (the caller only
// runs this when at least one fallback is enabled, so EmailFallback is guaranteed here).
// Only add it when there is a real email — an empty email would degenerate to an
// issuer-only lookup and link an arbitrary local user.
if len(searches) == 0 && cl.Email != "" {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Email: cl.Email})
}
return searches
}
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
// set defaults
@ -360,33 +442,21 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) {
// try finding the user on fallback mappingproperties
// try finding the user on fallback mapping properties
for _, searchUser := range fallbackSearchUsers(cl, provider, idToken) {
u, err = user.GetUserWithEmail(s, searchUser)
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
return nil, err
}
fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
searchUser := &user.User{
Issuer: user.IssuerLocal,
}
if provider.UsernameFallback {
// Match oidc subject on username as each is unique identifier in its own referential
// Discouraged if multiple account providers are used.
searchUser.Username = idToken.Subject
}
if provider.EmailFallback {
// Used alone, allow for someone to connect from various provider to the same account
// Discouraged for untrusted provider where someone can set email without verification
// Note : mapping on email prevent from auto-updating user email
searchUser.Email = cl.Email
}
// Check if the user exists for the given fallback matching options
u, err = user.GetUserWithEmail(s, searchUser)
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
return nil, err
}
fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
// Same as above: disabled/locked user found via fallback — return early.
if fallbackMatchFound && user.IsErrUserStatusError(err) {
return u, nil
// Same as above: disabled/locked user found via fallback — return early.
if fallbackMatchFound && user.IsErrUserStatusError(err) {
return u, nil
}
if fallbackMatchFound {
break
}
}
}
@ -507,21 +577,17 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo
return cl, nil
}
func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.Token, *oidc.IDToken, error) {
cb := &Callback{}
if err := c.Bind(cb); err != nil {
return nil, nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Bad data"}
}
// Check if the provider exists
providerKey := c.Param("provider")
// exchangeOidcTokens resolves the provider, exchanges the callback's auth code,
// and verifies the returned ID token. It takes an already-bound Callback so it
// can be shared by the v1 echo handler (which binds from the request) and the v2
// Huma handler (which binds via its typed body).
func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, string, error) {
provider, err := GetProvider(providerKey)
if err != nil {
return nil, cb, nil, nil, err
return nil, nil, nil, "", err
}
if provider == nil {
return nil, cb, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
}
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
@ -537,25 +603,25 @@ func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.To
if err := json.Unmarshal(rerr.Body, &details); err != nil {
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
log.Debugf("Raw token value is %s", rerr.Body)
return nil, cb, nil, nil, err
return nil, nil, nil, "", err
}
log.Errorf("Error retrieving token: %s", err)
log.Debugf("Raw token value is %s", rerr.Body)
return nil, cb, nil, nil, &models.ErrOpenIDBadRequestWithDetails{
return nil, nil, nil, "", &models.ErrOpenIDBadRequestWithDetails{
Message: "Could not authenticate against third party.",
Details: details,
}
}
return nil, cb, nil, nil, err
return nil, nil, nil, "", err
}
// Extract the ID Token from OAuth2 token.
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
log.Debugf("Could not get id_token, raw token is %v", oauth2Token)
return nil, cb, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"}
return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Missing token"}
}
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
@ -564,8 +630,8 @@ func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.To
idToken, err := verifier.Verify(context.Background(), rawIDToken)
if err != nil {
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
return nil, cb, nil, nil, err
return nil, nil, nil, "", err
}
return provider, cb, oauth2Token, idToken, nil
return provider, oauth2Token, idToken, rawIDToken, nil
}

View File

@ -254,11 +254,61 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
t.Run("ProviderFallback: Match to existing local user on preferred_username when sub differs", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
PreferredUsername: "user11",
}
provider := &Provider{
UsernameFallback: true,
}
// PocketID-style: the subject is an opaque UUID that does not match any local username.
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "c0ffee00-dead-beef-cafe-000000000011"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.Equal(t, "user11", u.Username, "should link to the local user matching preferred_username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
// No duplicate user must be created for the opaque subject.
db.AssertMissing(t, "users", map[string]interface{}{
"subject": idToken.Subject,
})
})
t.Run("ProviderFallback: Falls back to sub when preferred_username is empty", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
PreferredUsername: "",
}
provider := &Provider{
UsernameFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "user11"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
assert.Equal(t, idToken.Subject, u.Username, "subject should match username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
t.Run("ProviderFallback: Match to existing local user on email", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usersBefore, err := s.Count(&user.User{})
require.NoError(t, err)
cl := &claims{
Email: "user11@example.com",
}
@ -272,6 +322,42 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, cl.Email, u.Email, "email should match")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
// The email-only fallback must link the existing user, not create a duplicate.
usersAfter, err := s.Count(&user.User{})
require.NoError(t, err)
assert.Equal(t, usersBefore, usersAfter, "no new user should have been created")
})
t.Run("ProviderFallback: empty email claim does not link to an arbitrary local user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usersBefore, err := s.Count(&user.User{})
require.NoError(t, err)
// EmailFallback on, no username fallback, and the IdP sent no email claim. The
// email-only search must not degenerate to an issuer-only lookup matching an
// arbitrary local user. With no email there is nothing safe to match on, so the
// flow falls through to user creation (which then errors because an email is
// required) rather than silently linking an existing local account.
cl := &claims{
Email: "",
PreferredUsername: "brandNewOidcUser",
}
provider := &Provider{
EmailFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "opaque-subject-no-email"}
u, err := getOrCreateUser(s, cl, provider, idToken)
// Must not have linked an existing local user.
require.Error(t, err, "an empty email must not silently link an existing local user")
assert.Nil(t, u, "no existing local user should be returned for an empty email claim")
usersAfter, err := s.Count(&user.User{})
require.NoError(t, err)
assert.Equal(t, usersBefore, usersAfter, "no user should have been linked or created from an empty email claim")
})
t.Run("ProviderFallback: Match to existing local user on username and email", func(t *testing.T) {

View File

@ -180,6 +180,29 @@ func GetProvider(key string) (provider *Provider, err error) {
return
}
// getCachedProvider returns the provider from keyvalue without re-establishing
// the live OIDC connection, so the logout path never blocks on an unreachable OP.
func getCachedProvider(key string) (provider *Provider, err error) {
provider = &Provider{}
exists, err := keyvalue.GetWithValue("openid_provider_"+key, provider)
if err != nil {
return nil, err
}
if !exists {
_, err = GetAllProviders() // This will put all providers in cache
if err != nil {
return nil, err
}
_, err = keyvalue.GetWithValue("openid_provider_"+key, provider)
if err != nil {
return nil, err
}
}
return provider, nil
}
// parseBoolField reads a boolean-valued config field from a provider map,
// tolerating both native bools (from YAML/JSON) and strings (from env vars or
// the GetConfigValueFromFile path, which always return strings). Missing or
@ -313,6 +336,8 @@ func getProviderFromMap(pi map[string]interface{}, key string) (provider *Provid
provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL
provider.EndSessionURL = provider.discoveredEndSessionEndpoint()
return
}

View File

@ -31,6 +31,7 @@ import (
"image"
"io"
"net/http"
"os"
"strconv"
"strings"
@ -43,6 +44,7 @@ import (
"code.vikunja.io/api/pkg/modules/background/unsplash"
"code.vikunja.io/api/pkg/modules/background/upload"
"code.vikunja.io/api/pkg/web"
webfiles "code.vikunja.io/api/pkg/web/files"
"github.com/bbrks/go-blurhash"
"github.com/gabriel-vasile/mimetype"
@ -204,44 +206,17 @@ func (bp *BackgroundProvider) UploadBackground(c *echo.Context) error {
}
defer srcf.Close()
// Validate we're dealing with an image
mime, err := mimetype.DetectReader(srcf)
if err != nil {
if err := ValidateAndSaveBackgroundUpload(s, auth, project, srcf, file.Filename, uint64(file.Size)); err != nil {
_ = s.Rollback()
return err
}
if !strings.HasPrefix(mime.String(), "image") {
_ = s.Rollback()
return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."})
}
supported := false
for _, m := range allowedImageMimes {
if mime.Is(m) {
supported = true
break
if IsErrFileIsNoImage(err) {
return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."})
}
}
if !supported {
_ = s.Rollback()
return c.JSON(http.StatusBadRequest, models.Message{Message: "Unsupported image format. Allowed: " + strings.Join(allowedImageMimes, ",")})
}
err = SaveBackgroundFile(s, auth, project, srcf, file.Filename, uint64(file.Size))
if err != nil {
_ = s.Rollback()
if files.IsErrFileIsTooLarge(err) {
return echo.ErrBadRequest
}
if IsErrFileUnsupportedImageFormat(err) {
return c.JSON(http.StatusBadRequest, models.Message{Message: "Unsupported image format. Allowed: " + strings.Join(allowedImageMimes, ",")})
}
return err
}
err = project.ReadOne(s, auth)
if err != nil {
_ = s.Rollback()
return err
}
@ -253,6 +228,41 @@ func (bp *BackgroundProvider) UploadBackground(c *echo.Context) error {
return c.JSON(http.StatusOK, project)
}
// ValidateAndSaveBackgroundUpload validates that srcf is a decodable image of an
// allowed type, stores it as the project's background and reloads the project so
// callers get the updated background metadata. It is the shared body of the v1 and
// v2 upload handlers; the multipart parsing and error-to-HTTP mapping stay in each
// handler. project must already be loaded and the caller must have verified write
// permission. On a non-image it returns ErrFileIsNoImage; on a recognized but
// undecodable format ErrFileUnsupportedImageFormat.
func ValidateAndSaveBackgroundUpload(s *xorm.Session, auth web.Auth, project *models.Project, srcf io.ReadSeeker, filename string, filesize uint64) error {
mime, err := mimetype.DetectReader(srcf)
if err != nil {
return err
}
if !strings.HasPrefix(mime.String(), "image") {
return ErrFileIsNoImage{Mime: mime.String()}
}
supported := false
for _, m := range allowedImageMimes {
if mime.Is(m) {
supported = true
break
}
}
if !supported {
return ErrFileUnsupportedImageFormat{Mime: mime.String()}
}
// DetectReader consumed the head of the reader; SaveBackgroundFile seeks back to
// the start itself, so no rewind is needed here.
if err := SaveBackgroundFile(s, auth, project, srcf, filename, filesize); err != nil {
return err
}
return project.ReadOne(s, auth)
}
func SaveBackgroundFile(s *xorm.Session, auth web.Auth, project *models.Project, srcf io.ReadSeeker, filename string, filesize uint64) (err error) {
mime, _ := mimetype.DetectReader(srcf)
_, _ = srcf.Seek(0, io.SeekStart)
@ -377,54 +387,47 @@ func GetProjectBackground(c *echo.Context) error {
return err
}
if project.BackgroundFileID == 0 {
_ = s.Rollback()
return echo.NewHTTPError(http.StatusNotFound, "Project background not found")
}
// Get the file
bgFile := &files.File{
ID: project.BackgroundFileID,
}
if err := bgFile.LoadFileByID(); err != nil {
_ = s.Rollback()
return err
}
stat, err := files.FileStat(bgFile)
bgFile, stat, err := LoadProjectBackgroundForDownload(s, project)
if err != nil {
_ = s.Rollback()
if models.IsErrProjectHasNoBackground(err) {
return echo.NewHTTPError(http.StatusNotFound, "Project background not found")
}
return err
}
// Unsplash requires pingbacks as per their api usage guidelines.
// To do this in a privacy-preserving manner, we do the ping from inside of Vikunja to not expose any user details.
// FIXME: This should use an event once we have events
unsplash.Pingback(s, bgFile)
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
// Override the global no-store directive so browsers can cache background images.
// no-cache allows caching but requires revalidation via If-Modified-Since.
c.Response().Header().Set("Cache-Control", "no-cache")
webfiles.WriteProjectBackground(c.Response(), c.Request(), bgFile, stat)
return nil
}
// Set Last-Modified header if we have the file stat, so clients can decide whether to use cached files
if stat != nil {
modTime := stat.ModTime().UTC()
c.Response().Header().Set(echo.HeaderLastModified, modTime.Format(http.TimeFormat))
// Check If-Modified-Since and return 304 if the file hasn't changed
if ifModSince := c.Request().Header.Get("If-Modified-Since"); ifModSince != "" {
if t, err := http.ParseTime(ifModSince); err == nil && !modTime.After(t) {
return c.NoContent(http.StatusNotModified)
}
}
// LoadProjectBackgroundForDownload opens the project's background file (bytes ready to
// read) and stats it for the modtime the download uses for caching. It also fires the
// Unsplash pingback side effect, required by Unsplash's API guidelines and done
// server-side so no user details are exposed. Returns ErrProjectHasNoBackground when the
// project has none; the caller owns committing the session and closing bgFile.File.
func LoadProjectBackgroundForDownload(s *xorm.Session, project *models.Project) (bgFile *files.File, stat os.FileInfo, err error) {
if project.BackgroundFileID == 0 {
return nil, nil, &models.ErrProjectHasNoBackground{ProjectID: project.ID}
}
// Serve the file
return c.Stream(http.StatusOK, "image/jpg", bgFile.File)
bgFile = &files.File{ID: project.BackgroundFileID}
if err := bgFile.LoadFileByID(); err != nil {
return nil, nil, err
}
stat, err = files.FileStat(bgFile)
if err != nil {
return nil, nil, err
}
// FIXME: This should use an event once we have events
unsplash.Pingback(s, bgFile)
return bgFile, stat, nil
}
// RemoveProjectBackground removes a project background, no matter the background provider

View File

@ -40,3 +40,22 @@ func IsErrFileUnsupportedImageFormat(err error) bool {
ok := errors.As(err, &errFileUnsupportedImageFormat)
return ok
}
// ErrFileIsNoImage is returned when an uploaded background does not sniff as an
// image at all (its detected mime type does not start with "image"). It is
// distinct from ErrFileUnsupportedImageFormat, which is a recognized image type
// the imaging library can't decode.
type ErrFileIsNoImage struct {
Mime string
}
// Error is the error implementation of ErrFileIsNoImage
func (err ErrFileIsNoImage) Error() string {
return fmt.Sprintf("uploaded file is not an image [Mime: %s]", err.Mime)
}
// IsErrFileIsNoImage checks if an error is ErrFileIsNoImage
func IsErrFileIsNoImage(err error) bool {
var errFileIsNoImage ErrFileIsNoImage
return errors.As(err, &errFileIsNoImage)
}

View File

@ -18,32 +18,95 @@ package unsplash
import (
"context"
"errors"
"io"
"net/http"
"strings"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web"
"github.com/labstack/echo/v5"
)
func unsplashImage(url string, c *echo.Context) error {
// ErrUnsplashImageDoesNotExist is returned when Unsplash answers an image proxy fetch
// with a non-success status, mirroring v1's echo.ErrNotFound. It satisfies
// web.HTTPErrorProcessor so the v2 error bridge maps it to a 404.
type ErrUnsplashImageDoesNotExist struct{}
// IsErrUnsplashImageDoesNotExist checks if an error is ErrUnsplashImageDoesNotExist.
func IsErrUnsplashImageDoesNotExist(err error) bool {
var target *ErrUnsplashImageDoesNotExist
return errors.As(err, &target)
}
func (err *ErrUnsplashImageDoesNotExist) Error() string {
return "Unsplash image does not exist"
}
// HTTPError holds the http error description.
func (err *ErrUnsplashImageDoesNotExist) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusNotFound, Message: "Not Found"}
}
// fetchUnsplashImage fetches an image from Unsplash through the SSRF-safe client and
// returns its still-open response body for the caller to stream and close. The url is
// rebased onto the hardcoded images.unsplash.com host (stripping any client-supplied
// host) so the proxy can only ever reach Unsplash. It returns
// ErrUnsplashImageDoesNotExist on a non-success upstream status.
func fetchUnsplashImage(url string) (io.ReadCloser, error) {
// Replacing and appending the url for security reasons
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://images.unsplash.com/"+strings.Replace(url, "https://images.unsplash.com/", "", 1), nil)
if err != nil {
return err
return nil, err
}
resp, err := utils.NewSSRFSafeHTTPClient().Do(req) //nolint:gosec // SSRF protection is handled by the SSRF-safe client
if err != nil {
return err
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 399 {
return echo.ErrNotFound
_ = resp.Body.Close()
return nil, &ErrUnsplashImageDoesNotExist{}
}
return c.Stream(http.StatusOK, "image/jpg", resp.Body)
return resp.Body, nil
}
// ProxyUnsplashImage proxies a thumbnail from unsplash for privacy reasons.
// FetchUnsplashImageByID resolves an Unsplash image by id, fires the required pingback,
// and returns the full-resolution image body for the caller to stream and close.
func FetchUnsplashImageByID(imageID string) (io.ReadCloser, error) {
photo, err := getUnsplashPhotoInfoByID(imageID)
if err != nil {
return nil, err
}
pingbackByPhotoID(photo.ID)
return fetchUnsplashImage(photo.Urls.Raw)
}
// FetchUnsplashThumbByID resolves an Unsplash image by id, fires the required pingback,
// and returns a thumbnail (max width 200px) body for the caller to stream and close.
func FetchUnsplashThumbByID(imageID string) (io.ReadCloser, error) {
photo, err := getUnsplashPhotoInfoByID(imageID)
if err != nil {
return nil, err
}
pingbackByPhotoID(photo.ID)
return fetchUnsplashImage("https://images.unsplash.com/" + getImageID(photo.Urls.Raw) + "?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ")
}
// streamUnsplashImage streams a fetched image body to the v1 echo response, mapping the
// not-found sentinel back to echo.ErrNotFound so v1's wire response is unchanged.
func streamUnsplashImage(body io.ReadCloser, err error, c *echo.Context) error {
if err != nil {
if IsErrUnsplashImageDoesNotExist(err) {
return echo.ErrNotFound
}
return err
}
defer body.Close()
return c.Stream(http.StatusOK, "image/jpg", body)
}
// ProxyUnsplashImage proxies an image from unsplash for privacy reasons.
// @Summary Get an unsplash image
// @Description Get an unsplash image. **Returns json on error.**
// @tags project
@ -55,12 +118,8 @@ func unsplashImage(url string, c *echo.Context) error {
// @Failure 500 {object} models.Message "Internal error"
// @Router /backgrounds/unsplash/image/{image} [get]
func ProxyUnsplashImage(c *echo.Context) error {
photo, err := getUnsplashPhotoInfoByID(c.Param("image"))
if err != nil {
return err
}
pingbackByPhotoID(photo.ID)
return unsplashImage(photo.Urls.Raw, c)
body, err := FetchUnsplashImageByID(c.Param("image"))
return streamUnsplashImage(body, err, c)
}
// ProxyUnsplashThumb proxies a thumbnail from unsplash for privacy reasons.
@ -75,10 +134,6 @@ func ProxyUnsplashImage(c *echo.Context) error {
// @Failure 500 {object} models.Message "Internal error"
// @Router /backgrounds/unsplash/image/{image}/thumb [get]
func ProxyUnsplashThumb(c *echo.Context) error {
photo, err := getUnsplashPhotoInfoByID(c.Param("image"))
if err != nil {
return err
}
pingbackByPhotoID(photo.ID)
return unsplashImage("https://images.unsplash.com/"+getImageID(photo.Urls.Raw)+"?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ", c)
body, err := FetchUnsplashThumbByID(c.Param("image"))
return streamUnsplashImage(body, err, c)
}

View File

@ -18,6 +18,7 @@ package migration
import (
"bytes"
"context"
"xorm.io/xorm"
@ -50,7 +51,7 @@ func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.Us
return err
}
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
return nil
}

View File

@ -107,28 +107,28 @@ var AllTaskAttributes = []TaskAttribute{
// ColumnMapping represents a mapping from a CSV column to a task attribute
type ColumnMapping struct {
ColumnIndex int `json:"column_index"`
ColumnName string `json:"column_name"`
Attribute TaskAttribute `json:"attribute"`
ColumnIndex int `json:"column_index" doc:"The zero-based index of the CSV column this mapping applies to."`
ColumnName string `json:"column_name" doc:"The header name of the CSV column, for display."`
Attribute TaskAttribute `json:"attribute" enum:"title,description,due_date,start_date,end_date,done,priority,labels,project,reminder,ignore" doc:"The task attribute the column maps to. Use \"ignore\" to drop the column."`
}
// DetectionResult contains the auto-detected CSV structure
type DetectionResult struct {
Columns []string `json:"columns"`
Delimiter string `json:"delimiter"`
QuoteChar string `json:"quote_char"`
DateFormat string `json:"date_format"`
SuggestedMapping []ColumnMapping `json:"suggested_mapping"`
PreviewRows [][]string `json:"preview_rows"`
Columns []string `json:"columns" doc:"The detected column header names, in order."`
Delimiter string `json:"delimiter" doc:"The detected field delimiter (one of \",\", \";\", tab, \"|\")."`
QuoteChar string `json:"quote_char" doc:"The detected quote character."`
DateFormat string `json:"date_format" doc:"The detected Go reference date layout used to parse date columns."`
SuggestedMapping []ColumnMapping `json:"suggested_mapping" doc:"A best-guess column-to-attribute mapping; the client may edit it before previewing or migrating."`
PreviewRows [][]string `json:"preview_rows" doc:"The first few raw rows of the file, for the client to render a preview."`
}
// ImportConfig contains the configuration for CSV import
type ImportConfig struct {
Delimiter string `json:"delimiter"`
QuoteChar string `json:"quote_char"`
DateFormat string `json:"date_format"`
SkipRows int `json:"skip_rows"`
Mapping []ColumnMapping `json:"mapping"`
Delimiter string `json:"delimiter" doc:"The field delimiter to parse with. Defaults to comma when empty."`
QuoteChar string `json:"quote_char" doc:"The quote character to parse with."`
DateFormat string `json:"date_format" doc:"The Go reference date layout used to parse date columns."`
SkipRows int `json:"skip_rows" doc:"Number of leading rows to skip (e.g. a header row) before importing."`
Mapping []ColumnMapping `json:"mapping" doc:"The column-to-attribute mappings that drive the import."`
}
// PreviewTask represents a task preview before import
@ -146,8 +146,8 @@ type PreviewTask struct {
// PreviewResult contains preview data before import
type PreviewResult struct {
Tasks []PreviewTask `json:"tasks"`
TotalRows int `json:"total_rows"`
Tasks []PreviewTask `json:"tasks" doc:"The first few tasks that would be imported with the given config."`
TotalRows int `json:"total_rows" doc:"The total number of data rows in the file."`
}
// stripBOM removes the UTF-8 BOM from the beginning of a reader
@ -557,6 +557,22 @@ func (m *Migrator) Migrate(_ *user.User, _ io.ReaderAt, _ int64) error {
return &migration.ErrCSVConfigRequired{}
}
// RunMigration records the migration's start, imports the CSV with the given
// config and records its finish. Shared by the v1 and v2 HTTP layers so the
// status bookkeeping around MigrateWithConfig lives in one place.
func RunMigration(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error {
status, err := migration.StartMigration(&Migrator{}, u)
if err != nil {
return err
}
if err := MigrateWithConfig(u, file, size, config); err != nil {
return err
}
return migration.FinishMigration(status)
}
// MigrateWithConfig imports CSV data into Vikunja with the provided configuration
func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error {
if size == 0 {

View File

@ -186,19 +186,7 @@ func (c *MigratorWeb) Migrate(ctx *echo.Context) error {
}
defer src.Close()
m := &Migrator{}
status, err := migration.StartMigration(m, u)
if err != nil {
return err
}
err = MigrateWithConfig(u, src, file.Size, &config)
if err != nil {
return err
}
err = migration.FinishMigration(status)
if err != nil {
if err := RunMigration(u, src, file.Size, &config); err != nil {
return err
}

View File

@ -17,6 +17,7 @@
package handler
import (
"io"
"net/http"
"code.vikunja.io/api/pkg/models"
@ -36,6 +37,22 @@ func (fw *FileMigratorWeb) RegisterRoutes(g *echo.Group) {
g.PUT("/"+ms.Name()+"/migrate", fw.Migrate)
}
// RunFileMigration records the migration's start, runs the file migrator and
// records its finish. Shared by the v1 and v2 HTTP layers so the orchestration
// lives in one place; the caller supplies the already-opened upload.
func RunFileMigration(ms migration.FileMigrator, u *user2.User, file io.ReaderAt, size int64) error {
m, err := migration.StartMigration(ms, u)
if err != nil {
return err
}
if err := ms.Migrate(u, file, size); err != nil {
return err
}
return migration.FinishMigration(m)
}
// Migrate calls the migration method
func (fw *FileMigratorWeb) Migrate(c *echo.Context) error {
ms := fw.MigrationStruct()
@ -56,19 +73,7 @@ func (fw *FileMigratorWeb) Migrate(c *echo.Context) error {
}
defer src.Close()
m, err := migration.StartMigration(ms, user)
if err != nil {
return err
}
// Do the migration
err = ms.Migrate(user, src, file.Size)
if err != nil {
return err
}
err = migration.FinishMigration(m)
if err != nil {
if err := RunFileMigration(ms, user, src, file.Size); err != nil {
return err
}

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